Compare commits

...

2 Commits

Author SHA1 Message Date
683e4d6c07 feat: streamline workspace controls 2026-07-08 23:18:08 +08:00
c1d4c1e4ce feat: improve plugin overlay workflow 2026-07-08 23:17:59 +08:00
10 changed files with 337 additions and 205 deletions

View File

@@ -208,6 +208,7 @@ export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _
const demoPluginIds = new Set(demoAllPluginIds); const demoPluginIds = new Set(demoAllPluginIds);
const runningDemoResults = analysisResults.filter((r) => (r.status === 'processing' || r.status === 'pending') && r.type === 'plugin' && r.pluginId && demoPluginIds.has(r.pluginId)); const runningDemoResults = analysisResults.filter((r) => (r.status === 'processing' || r.status === 'pending') && r.type === 'plugin' && r.pluginId && demoPluginIds.has(r.pluginId));
const demoFlowRunning = runningDemoResults.length > 0; const demoFlowRunning = runningDemoResults.length > 0;
const demoFlowActive = demoFlowRunning || runDemoAll.isPending;
const selectedPcCount = selectedPcFileIds?.length ?? 0; const selectedPcCount = selectedPcFileIds?.length ?? 0;
const selectedCadCount = selectedCadFileIds?.length ?? 0; const selectedCadCount = selectedCadFileIds?.length ?? 0;
@@ -490,7 +491,7 @@ export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _
</div> </div>
{/* 未配准提示:有点云文件且选了测量算法,但所选点云均未配准 */} {/* 未配准提示:有点云文件且选了测量算法,但所选点云均未配准 */}
{selectedMeasIds.length > 0 && !hasRegisteredCloud && (taskPcFileCount ?? 0) > 0 && ( {selectedMeasIds.length > 0 && !hasRegisteredCloud && (taskPcFileCount ?? 0) > 0 && !demoFlowActive && (
<div className="flex items-start gap-1.5 bg-amber-900/20 border border-amber-700/40 rounded p-2"> <div className="flex items-start gap-1.5 bg-amber-900/20 border border-amber-700/40 rounded p-2">
<AlertTriangle className="w-3 h-3 text-amber-400 flex-shrink-0 mt-0.5" /> <AlertTriangle className="w-3 h-3 text-amber-400 flex-shrink-0 mt-0.5" />
<span className="text-[10px] text-amber-300"> <span className="text-[10px] text-amber-300">

View File

@@ -55,7 +55,13 @@ interface Props {
/** 外部分析任务是否正在提交中 */ /** 外部分析任务是否正在提交中 */
externalPending?: boolean; externalPending?: boolean;
/** 插件叠加层:用户点击「在 3D 中查看叠加层」时回调 Home → PointCloudViewer */ /** 插件叠加层:用户点击「在 3D 中查看叠加层」时回调 Home → PointCloudViewer */
onViewPluginOverlay?: (args: { taskId: number; resultId: number; overlay: Overlay3D }) => void; onViewPluginOverlay?: (args: {
taskId: number;
resultId: number;
overlay: Overlay3D;
inputPointCloudFileIds?: number[];
inputCadFileIds?: number[];
}) => void;
} }
type Measurement = { name: string; value: number; unit: string; tolerance: number; status: string }; type Measurement = { name: string; value: number; unit: string; tolerance: number; status: string };
@@ -226,6 +232,8 @@ type DemoFlowMeta = {
total?: number; total?: number;
}; };
const SHOW_LEGACY_ANALYSIS_ACTIONS = false;
type AnalysisListItem = type AnalysisListItem =
| { kind: 'result'; result: AnalysisResultRow } | { kind: 'result'; result: AnalysisResultRow }
| { kind: 'demoFlow'; runId: string; results: AnalysisResultRow[] }; | { kind: 'demoFlow'; runId: string; results: AnalysisResultRow[] };
@@ -752,6 +760,12 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
> >
<Maximize2 className="w-3 h-3" /> <Maximize2 className="w-3 h-3" />
</button> </button>
{/* 产品策略:旧分析入口暂不在标题栏展示,保留实现便于后续恢复。
误差分析当前后端仍是模拟统计结果,不作为正式入口展示。
装配检测仍有 legacy 后台任务,但暂不作为主流程按钮展示。
偏差热力依赖 STL/OBJ,不适合当前以 STEP 为主的流程,暂时隐藏。 */}
{SHOW_LEGACY_ANALYSIS_ACTIONS && (
<>
<button <button
className={`industrial-btn industrial-btn-primary py-0.5 text-[10px] flex items-center gap-0.5 ${running ? 'opacity-60 cursor-not-allowed' : ''}`} className={`industrial-btn industrial-btn-primary py-0.5 text-[10px] flex items-center gap-0.5 ${running ? 'opacity-60 cursor-not-allowed' : ''}`}
onClick={() => !running && runAnalysis.mutate({ taskId, type: 'error_analysis' })} onClick={() => !running && runAnalysis.mutate({ taskId, type: 'error_analysis' })}
@@ -783,6 +797,8 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
偏差热力 偏差热力
</button> </button>
)} )}
</>
)}
</div> </div>
</div> </div>

View File

@@ -1,15 +1,7 @@
import { useAuth } from "@/_core/hooks/useAuth"; import { useAuth } from "@/_core/hooks/useAuth";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
SidebarFooter,
SidebarHeader, SidebarHeader,
SidebarInset, SidebarInset,
SidebarMenu, SidebarMenu,
@@ -21,7 +13,7 @@ import {
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { getLoginUrl } from "@/const"; import { getLoginUrl } from "@/const";
import { useIsMobile } from "@/hooks/useMobile"; import { useIsMobile } from "@/hooks/useMobile";
import { LayoutDashboard, LogOut, PanelLeft, Users } from "lucide-react"; import { LayoutDashboard, PanelLeft, Users } from "lucide-react";
import { CSSProperties, useEffect, useRef, useState } from "react"; import { CSSProperties, useEffect, useRef, useState } from "react";
import { useLocation } from "wouter"; import { useLocation } from "wouter";
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton'; import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
@@ -106,7 +98,6 @@ function DashboardLayoutContent({
children, children,
setSidebarWidth, setSidebarWidth,
}: DashboardLayoutContentProps) { }: DashboardLayoutContentProps) {
const { user, logout } = useAuth();
const [location, setLocation] = useLocation(); const [location, setLocation] = useLocation();
const { state, toggleSidebar } = useSidebar(); const { state, toggleSidebar } = useSidebar();
const isCollapsed = state === "collapsed"; const isCollapsed = state === "collapsed";
@@ -201,36 +192,6 @@ function DashboardLayoutContent({
</SidebarMenu> </SidebarMenu>
</SidebarContent> </SidebarContent>
<SidebarFooter className="p-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-3 rounded-lg px-1 py-1 hover:bg-accent/50 transition-colors w-full text-left group-data-[collapsible=icon]:justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<Avatar className="h-9 w-9 border shrink-0">
<AvatarFallback className="text-xs font-medium">
{user?.name?.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0 group-data-[collapsible=icon]:hidden">
<p className="text-sm font-medium truncate leading-none">
{user?.name || "-"}
</p>
<p className="text-xs text-muted-foreground truncate mt-1.5">
{user?.email || "-"}
</p>
</div>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
onClick={logout}
className="cursor-pointer text-destructive focus:text-destructive"
>
<LogOut className="mr-2 h-4 w-4" />
<span>Sign out</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarFooter>
</Sidebar> </Sidebar>
<div <div
className={`absolute top-0 right-0 w-1 h-full cursor-col-resize hover:bg-primary/20 transition-colors ${isCollapsed ? "hidden" : ""}`} className={`absolute top-0 right-0 w-1 h-full cursor-col-resize hover:bg-primary/20 transition-colors ${isCollapsed ? "hidden" : ""}`}

View File

@@ -38,7 +38,11 @@ interface ErrorStats {
type Overlay3D = type Overlay3D =
| { kind: 'none' } | { kind: 'none' }
| { kind: 'vertexColors'; file: string; vertexCount: number; dispMin: number; dispMax: number } | { kind: 'vertexColors'; file: string; vertexCount: number; dispMin: number; dispMax: number }
| { kind: 'ply'; file: string }; | { kind: 'ply'; file: string; files?: string[] };
type RawOverlay3D =
| Overlay3D
| { kind: 'vertexColors'; file?: string; url?: string; vertexCount?: number; dispMin?: number; dispMax?: number };
interface PluginResultLike { interface PluginResultLike {
pluginId?: string; pluginId?: string;
@@ -46,7 +50,9 @@ interface PluginResultLike {
message?: string; message?: string;
measurements?: Measurement[]; measurements?: Measurement[];
errorStats?: ErrorStats | null; errorStats?: ErrorStats | null;
overlay3d?: Overlay3D; overlay3d?: RawOverlay3D;
inputPointCloudFileIds?: unknown;
inputCadFileIds?: unknown;
images?: string[]; images?: string[];
/** outDir 下的报表产物(相对路径,如 report.xlsx),前端展示下载入口 */ /** outDir 下的报表产物(相对路径,如 report.xlsx),前端展示下载入口 */
files?: (string | { name?: string; path?: string })[]; files?: (string | { name?: string; path?: string })[];
@@ -61,7 +67,13 @@ interface Props {
pluginName?: string; pluginName?: string;
data: PluginResultLike; data: PluginResultLike;
/** 用户点击「在 3D 中查看」时回调,把叠加层信息传给 Home → PointCloudViewer */ /** 用户点击「在 3D 中查看」时回调,把叠加层信息传给 Home → PointCloudViewer */
onViewOverlay?: (overlay: { taskId: number; resultId: number; overlay: Overlay3D }) => void; onViewOverlay?: (overlay: {
taskId: number;
resultId: number;
overlay: Overlay3D;
inputPointCloudFileIds?: number[];
inputCadFileIds?: number[];
}) => void;
} }
function statusClass(s?: Measurement['status']) { function statusClass(s?: Measurement['status']) {
@@ -97,6 +109,48 @@ function formatValue(v: number): string {
return v.toFixed(3); return v.toFixed(3);
} }
function numberArrayFromUnknown(value: unknown): number[] | undefined {
if (!Array.isArray(value)) return undefined;
const ids = value.filter((id): id is number => typeof id === 'number' && Number.isInteger(id));
return ids.length > 0 ? ids : undefined;
}
function normalizeOverlay3D(overlay?: RawOverlay3D): Overlay3D {
if (!overlay || overlay.kind === 'none') return { kind: 'none' };
if (overlay.kind === 'ply') {
const files = Array.isArray(overlay.files)
? overlay.files.filter((f): f is string => typeof f === 'string' && f.length > 0)
: undefined;
return files && files.length > 0
? { kind: 'ply', file: overlay.file, files }
: { kind: 'ply', file: overlay.file };
}
const legacyUrl = 'url' in overlay ? overlay.url : undefined;
const file = typeof overlay.file === 'string' && overlay.file.length > 0
? overlay.file
: typeof legacyUrl === 'string' && legacyUrl.length > 0
? legacyUrl
: undefined;
if (!file) return { kind: 'none' };
if (
typeof overlay.vertexCount === 'number' &&
typeof overlay.dispMin === 'number' &&
typeof overlay.dispMax === 'number'
) {
return {
kind: 'vertexColors',
file,
vertexCount: overlay.vertexCount,
dispMin: overlay.dispMin,
dispMax: overlay.dispMax,
};
}
return { kind: 'ply', file };
}
export default function GenericResultView({ export default function GenericResultView({
taskId, taskId,
resultId, resultId,
@@ -112,7 +166,9 @@ export default function GenericResultView({
const files = (data.files ?? []).map((f) => const files = (data.files ?? []).map((f) =>
typeof f === 'string' ? f : (f as { name?: string; path?: string }).name ?? String(f) typeof f === 'string' ? f : (f as { name?: string; path?: string }).name ?? String(f)
); );
const overlay = data.overlay3d ?? { kind: 'none' }; const overlay = normalizeOverlay3D(data.overlay3d);
const inputPointCloudFileIds = numberArrayFromUnknown(data.inputPointCloudFileIds);
const inputCadFileIds = numberArrayFromUnknown(data.inputCadFileIds);
const displayPluginName = pluginName ?? pluginId ?? data.pluginId; const displayPluginName = pluginName ?? pluginId ?? data.pluginId;
return ( return (
@@ -191,7 +247,7 @@ export default function GenericResultView({
<button <button
type="button" type="button"
className="industrial-btn industrial-btn-primary py-1 text-[10px] flex items-center gap-1 w-full justify-center" className="industrial-btn industrial-btn-primary py-1 text-[10px] flex items-center gap-1 w-full justify-center"
onClick={() => onViewOverlay({ taskId, resultId, overlay })} onClick={() => onViewOverlay({ taskId, resultId, overlay, inputPointCloudFileIds, inputCadFileIds })}
> >
<Box className="w-3 h-3" /> <Box className="w-3 h-3" />
在 3D 中查看叠加层 在 3D 中查看叠加层

View File

@@ -1023,7 +1023,7 @@ const CADModel = memo(function CADModel({
} }
}, [useVertexColors, geometry]); }, [useVertexColors, geometry]);
if (!visible && !useVertexColors) return null; if (!visible) return null;
if (!geometry && !loading) return null; if (!geometry && !loading) return null;
@@ -1178,6 +1178,7 @@ interface SceneContentProps {
pointCloudData?: PointCloudData | null; pointCloudData?: PointCloudData | null;
pointSize: number; pointSize: number;
showHeatmap: boolean; showHeatmap: boolean;
showPointCloud: boolean;
pointCloudRenderMode: PointCloudRenderMode; pointCloudRenderMode: PointCloudRenderMode;
enablePointPicking: boolean; enablePointPicking: boolean;
showCADModel: boolean; showCADModel: boolean;
@@ -1207,6 +1208,7 @@ const SceneContent = memo(function SceneContent({
pointCloudData, pointCloudData,
pointSize, pointSize,
showHeatmap, showHeatmap,
showPointCloud,
pointCloudRenderMode, pointCloudRenderMode,
enablePointPicking, enablePointPicking,
showCADModel, showCADModel,
@@ -1253,9 +1255,15 @@ const SceneContent = memo(function SceneContent({
const cadShellHeatmapOn = const cadShellHeatmapOn =
pointCloudRenderMode === 'smoothHeatmap' && pointCloudRenderMode === 'smoothHeatmap' &&
Boolean(pointCloudData && cadSurfaceGeometry) && Boolean(pointCloudData && cadSurfaceGeometry) &&
showCADModel &&
!cadHeatmapSuppressClient && !cadHeatmapSuppressClient &&
!useDirectColoring; !useDirectColoring;
const showCadReferenceMesh = (showCADModel && !cadShellHeatmapOn) || useDirectColoring; const showCadReferenceMesh = showCADModel && !cadShellHeatmapOn;
const showSmoothPointFallback =
pointCloudData &&
showPointCloud &&
pointCloudRenderMode === 'smoothHeatmap' &&
(!cadSurfaceGeometry || cadHeatmapSuppressClient || !showCADModel);
return ( return (
<> <>
@@ -1292,7 +1300,7 @@ const SceneContent = memo(function SceneContent({
{!lightweightScene && <AmbientParticles />} {!lightweightScene && <AmbientParticles />}
{/* Point cloud - 只有当有数据时才渲染,完全禁用事件以提高性能 */} {/* Point cloud - 只有当有数据时才渲染,完全禁用事件以提高性能 */}
{pointCloudData && pointCloudRenderMode === 'points' && ( {pointCloudData && showPointCloud && pointCloudRenderMode === 'points' && (
<PointCloudMesh <PointCloudMesh
data={pointCloudData} data={pointCloudData}
pointSize={pointSize} pointSize={pointSize}
@@ -1305,6 +1313,7 @@ const SceneContent = memo(function SceneContent({
{pointCloudData && {pointCloudData &&
pointCloudRenderMode === 'smoothHeatmap' && pointCloudRenderMode === 'smoothHeatmap' &&
cadSurfaceGeometry && cadSurfaceGeometry &&
showCADModel &&
!cadHeatmapSuppressClient && !cadHeatmapSuppressClient &&
!useDirectColoring && ( !useDirectColoring && (
<CadSurfaceHeatmapMesh <CadSurfaceHeatmapMesh
@@ -1314,34 +1323,23 @@ const SceneContent = memo(function SceneContent({
showWireframe={showWireframe} showWireframe={showWireframe}
/> />
)} )}
{pointCloudData && {showSmoothPointFallback && (
pointCloudRenderMode === 'smoothHeatmap' &&
cadSurfaceGeometry &&
cadHeatmapSuppressClient && (
<PointCloudMesh
data={pointCloudData}
pointSize={Math.min(pointSize * 2.8, 0.12)}
showHeatmap={showHeatmap}
enablePointPicking={false}
errorRangeMode="percentile"
pointOpacity={showHeatmap ? 0.38 : 0.55}
/>
)}
{pointCloudData && pointCloudRenderMode === 'smoothHeatmap' && !cadSurfaceGeometry && (
<> <>
<PointCloudMesh <PointCloudMesh
data={pointCloudData} data={pointCloudData}
pointSize={Math.min(pointSize * 3.2, 0.14)} pointSize={Math.min(pointSize * (cadSurfaceGeometry ? 2.8 : 3.2), cadSurfaceGeometry ? 0.12 : 0.14)}
showHeatmap={showHeatmap} showHeatmap={showHeatmap}
enablePointPicking={false} enablePointPicking={false}
errorRangeMode="percentile" errorRangeMode="percentile"
pointOpacity={showHeatmap ? 0.42 : 0.6} pointOpacity={showHeatmap ? (cadSurfaceGeometry ? 0.38 : 0.42) : (cadSurfaceGeometry ? 0.55 : 0.6)}
/> />
{!cadSurfaceGeometry && (
<SmoothHeatmapMesh <SmoothHeatmapMesh
data={pointCloudData} data={pointCloudData}
showHeatmap={showHeatmap} showHeatmap={showHeatmap}
showWireframe={showWireframe} showWireframe={showWireframe}
/> />
)}
</> </>
)} )}
@@ -1408,6 +1406,7 @@ const SceneContent = memo(function SceneContent({
return prevProps.pointCloudData === nextProps.pointCloudData && return prevProps.pointCloudData === nextProps.pointCloudData &&
prevProps.pointSize === nextProps.pointSize && prevProps.pointSize === nextProps.pointSize &&
prevProps.showHeatmap === nextProps.showHeatmap && prevProps.showHeatmap === nextProps.showHeatmap &&
prevProps.showPointCloud === nextProps.showPointCloud &&
prevProps.pointCloudRenderMode === nextProps.pointCloudRenderMode && prevProps.pointCloudRenderMode === nextProps.pointCloudRenderMode &&
prevProps.enablePointPicking === nextProps.enablePointPicking && prevProps.enablePointPicking === nextProps.enablePointPicking &&
prevProps.showCADModel === nextProps.showCADModel && prevProps.showCADModel === nextProps.showCADModel &&
@@ -1578,6 +1577,7 @@ interface PointCloudViewerProps {
*/ */
loadKey?: number; loadKey?: number;
showHeatmap?: boolean; showHeatmap?: boolean;
showPointCloud?: boolean;
showCADModel?: boolean; showCADModel?: boolean;
showWireframe?: boolean; showWireframe?: boolean;
pointSize?: number; pointSize?: number;
@@ -1614,7 +1614,7 @@ interface PointCloudViewerProps {
overlay: overlay:
| { kind: 'none' } | { kind: 'none' }
| { kind: 'vertexColors'; file: string; vertexCount: number; dispMin: number; dispMax: number } | { kind: 'vertexColors'; file: string; vertexCount: number; dispMin: number; dispMax: number }
| { kind: 'ply'; file: string }; | { kind: 'ply'; file: string; files?: string[] };
} | null; } | null;
/** /**
* ROI 坐标拾取模式。激活时,用户在 3D 视图中点击任意点, * ROI 坐标拾取模式。激活时,用户在 3D 视图中点击任意点,
@@ -1630,6 +1630,7 @@ export default function PointCloudViewer({
batchTaskId, batchTaskId,
loadKey, loadKey,
showHeatmap = true, showHeatmap = true,
showPointCloud = true,
showCADModel = true, showCADModel = true,
showWireframe = false, showWireframe = false,
pointSize = 0.03, pointSize = 0.03,
@@ -1769,6 +1770,7 @@ export default function PointCloudViewer({
// PLY 叠加层状态(kind=ply) // PLY 叠加层状态(kind=ply)
const [plyOverlayData, setPlyOverlayData] = useState<{ positions: Float32Array; colors: Float32Array } | null>(null); const [plyOverlayData, setPlyOverlayData] = useState<{ positions: Float32Array; colors: Float32Array } | null>(null);
const [plyOverlayStatus, setPlyOverlayStatus] = useState<{ status: 'idle' | 'loading' | 'ready' | 'error'; message?: string }>({ status: 'idle' });
// 主点云的坐标变换参数,供 ply 叠加层对齐用 // 主点云的坐标变换参数,供 ply 叠加层对齐用
const pcTransformRef = useRef<{ cx: number; cy: number; cz: number; scale: number } | null>(null); const pcTransformRef = useRef<{ cx: number; cy: number; cz: number; scale: number } | null>(null);
@@ -1776,16 +1778,28 @@ export default function PointCloudViewer({
useEffect(() => { useEffect(() => {
if (pluginOverlay == null || pluginOverlay.overlay.kind !== 'ply') { if (pluginOverlay == null || pluginOverlay.overlay.kind !== 'ply') {
setPlyOverlayData(null); setPlyOverlayData(null);
setPlyOverlayStatus({ status: 'idle' });
return;
}
const t = pcTransformRef.current;
if (!t || !pointCloudData?.points?.length) {
setPlyOverlayData(null);
setPlyOverlayStatus({
status: 'error',
message: '请先加载本算法结果对应的点云文件,再查看 PLY 叠加层',
});
return; return;
} }
const { taskId, resultId, overlay } = pluginOverlay; const { taskId, resultId, overlay } = pluginOverlay;
const ac = new AbortController(); const ac = new AbortController();
setPlyOverlayStatus({ status: 'loading', message: '正在加载 PLY 叠加层' });
(async () => { (async () => {
try { try {
// 用 /sample/batch 接口按 filePath 采样,最多 30 万点 // 用 /sample/batch 接口按 filePath 采样,最多 30 万点
// 支持多文件(各面板 PLY),合并采样 // 支持多文件(各面板 PLY),合并采样
const plyFiles: string[] = (overlay as { files?: string[]; file: string }).files const plyFiles: string[] = Array.isArray(overlay.files) && overlay.files.length > 0
?? [overlay.file]; ? overlay.files
: [overlay.file];
const resp = await fetch('/api/files/sample/batch', { const resp = await fetch('/api/files/sample/batch', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -1801,7 +1815,10 @@ export default function PointCloudViewer({
const buf = await resp.arrayBuffer(); const buf = await resp.arrayBuffer();
const floats = new Float32Array(buf); const floats = new Float32Array(buf);
const count = Math.floor(floats.length / stride); const count = Math.floor(floats.length / stride);
if (count === 0) return; if (count === 0) {
setPlyOverlayStatus({ status: 'error', message: 'PLY 叠加层没有可渲染点' });
return;
}
const positions = new Float32Array(count * 3); const positions = new Float32Array(count * 3);
const colors = new Float32Array(count * 3); const colors = new Float32Array(count * 3);
if (hasColor) { if (hasColor) {
@@ -1826,23 +1843,23 @@ export default function PointCloudViewer({
} }
} }
// 用主点云的坐标变换对齐叠加层坐标 // 用主点云的坐标变换对齐叠加层坐标
const t = pcTransformRef.current;
if (t) {
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
positions[i * 3] = (positions[i * 3] - t.cx) * t.scale; positions[i * 3] = (positions[i * 3] - t.cx) * t.scale;
positions[i * 3 + 1] = (positions[i * 3 + 1] - t.cy) * t.scale; positions[i * 3 + 1] = (positions[i * 3 + 1] - t.cy) * t.scale;
positions[i * 3 + 2] = (positions[i * 3 + 2] - t.cz) * t.scale; positions[i * 3 + 2] = (positions[i * 3 + 2] - t.cz) * t.scale;
} }
}
setPlyOverlayData({ positions, colors }); setPlyOverlayData({ positions, colors });
setPlyOverlayStatus({ status: 'ready' });
console.log(`[PlyOverlay] loaded ${count} pts, hasColor=${hasColor}, transform=${JSON.stringify(t)}`); console.log(`[PlyOverlay] loaded ${count} pts, hasColor=${hasColor}, transform=${JSON.stringify(t)}`);
} catch (e) { } catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') return; if (e instanceof DOMException && e.name === 'AbortError') return;
console.warn('[PlyOverlay] load failed:', e); console.warn('[PlyOverlay] load failed:', e);
setPlyOverlayData(null);
setPlyOverlayStatus({ status: 'error', message: '加载 PLY 叠加层失败' });
} }
})(); })();
return () => ac.abort(); return () => ac.abort();
}, [pluginOverlay]); }, [pluginOverlay, pointCloudData]);
useEffect(() => { useEffect(() => {
// 当插件叠加层激活时,跳过 legacy heatmap effect,避免与下面那个 effect 抢 ref // 当插件叠加层激活时,跳过 legacy heatmap effect,避免与下面那个 effect 抢 ref
@@ -1947,16 +1964,21 @@ export default function PointCloudViewer({
const heatmapLegendBounds = useMemo(() => { const heatmapLegendBounds = useMemo(() => {
if (!showHeatmap) return null; if (!showHeatmap) return null;
const hasVisibleCadHeatmap =
pointCloudRenderMode === 'smoothHeatmap' &&
serverCadHeatmap.status === 'ready' &&
showCADModel;
const pts = pointCloudData?.points; const pts = pointCloudData?.points;
if (!pts?.length) return null; if (!hasVisibleCadHeatmap && (!showPointCloud || !pts?.length)) return null;
if (pointCloudRenderMode === 'smoothHeatmap' && serverCadHeatmap.status === 'ready') { if (pointCloudRenderMode === 'smoothHeatmap' && serverCadHeatmap.status === 'ready') {
const lo = serverCadHeatmap.dispMin; const lo = serverCadHeatmap.dispMin;
const hi = serverCadHeatmap.dispMax; const hi = serverCadHeatmap.dispMax;
const midTick = lo < 0 && hi > 0 ? '0' : ((lo + hi) / 2).toFixed(3); const midTick = lo < 0 && hi > 0 ? '0' : ((lo + hi) / 2).toFixed(3);
return { min: lo, max: hi, midTick }; return { min: lo, max: hi, midTick };
} }
if (!pts?.length) return null;
return getHeatmapLegendBounds(pts, pointCloudRenderMode); return getHeatmapLegendBounds(pts, pointCloudRenderMode);
}, [showHeatmap, pointCloudData, pointCloudRenderMode, serverCadHeatmap]); }, [showHeatmap, showPointCloud, showCADModel, pointCloudData, pointCloudRenderMode, serverCadHeatmap]);
return ( return (
<div className={`relative ${className}`} style={{ background: 'oklch(0.08 0.006 240)' }}> <div className={`relative ${className}`} style={{ background: 'oklch(0.08 0.006 240)' }}>
@@ -2010,6 +2032,29 @@ export default function PointCloudViewer({
</div> </div>
)} )}
{pluginOverlay?.overlay.kind === 'ply' && plyOverlayStatus.status !== 'idle' && plyOverlayStatus.status !== 'ready' && (
<div className="absolute inset-0 z-20 flex items-center justify-center pointer-events-none">
<div className="rounded-lg border border-border/60 bg-[oklch(0.1_0.01_240)]/90 px-5 py-3 shadow-lg backdrop-blur-sm flex items-center gap-3 max-w-sm">
{plyOverlayStatus.status === 'loading' && (
<svg className="animate-spin h-5 w-5 text-primary" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
)}
<div className="text-sm">
<div className="text-foreground/90 font-medium">
{plyOverlayStatus.status === 'error' ? 'PLY 叠加层不可用' : '正在加载 PLY 叠加层…'}
</div>
{plyOverlayStatus.message && (
<div className="text-[10px] text-muted-foreground mt-0.5">
{plyOverlayStatus.message}
</div>
)}
</div>
</div>
</div>
)}
{/* 热力图点击拾取信息 */} {/* 热力图点击拾取信息 */}
{heatmapPick && ( {heatmapPick && (
<div className="absolute left-3 bottom-14 z-20 rounded-lg border border-border/60 bg-[oklch(0.1_0.01_240)]/90 px-4 py-2.5 shadow-lg backdrop-blur-sm max-w-[220px]"> <div className="absolute left-3 bottom-14 z-20 rounded-lg border border-border/60 bg-[oklch(0.1_0.01_240)]/90 px-4 py-2.5 shadow-lg backdrop-blur-sm max-w-[220px]">
@@ -2055,6 +2100,7 @@ export default function PointCloudViewer({
pointCloudData={pointCloudData} pointCloudData={pointCloudData}
pointSize={pointSize} pointSize={pointSize}
showHeatmap={showHeatmap} showHeatmap={showHeatmap}
showPointCloud={showPointCloud}
pointCloudRenderMode={pointCloudRenderMode} pointCloudRenderMode={pointCloudRenderMode}
enablePointPicking={enableErrorPicking} enablePointPicking={enableErrorPicking}
showCADModel={showCADModel} showCADModel={showCADModel}

View File

@@ -616,8 +616,9 @@ export default function TaskManager({
)} )}
</div> </div>
<div className="flex items-center gap-1">
<button <button
className="industrial-btn industrial-btn-primary w-full py-1 text-[10px] flex items-center justify-center gap-1" className="industrial-btn industrial-btn-primary flex-1 min-w-0 py-1 text-[10px] flex items-center justify-center gap-1"
disabled={activeTaskId == null || selectedModelPcFileIds.size === 0 || linkPcFilesFromModel.isPending} disabled={activeTaskId == null || selectedModelPcFileIds.size === 0 || linkPcFilesFromModel.isPending}
onClick={() => { onClick={() => {
if (activeTaskId == null || selectedModelPcFileIds.size === 0) return; if (activeTaskId == null || selectedModelPcFileIds.size === 0) return;
@@ -630,6 +631,16 @@ export default function TaskManager({
<Plus className="w-3 h-3" /> <Plus className="w-3 h-3" />
添加到当前任务{selectedModelPcFileIds.size > 0 ? ` (${selectedModelPcFileIds.size})` : ''} 添加到当前任务{selectedModelPcFileIds.size > 0 ? ` (${selectedModelPcFileIds.size})` : ''}
</button> </button>
<button
type="button"
className="industrial-btn w-7 flex-shrink-0 px-0 py-1 text-[10px] flex items-center justify-center bg-red-900/55 border-red-500/60 text-red-100 hover:bg-red-800/70 hover:border-red-400"
title="关闭车型点云库"
aria-label="关闭车型点云库"
onClick={() => setPcLibraryOpen(false)}
>
<X className="w-3 h-3 flex-shrink-0" />
</button>
</div>
</div> </div>
)} )}
@@ -848,8 +859,9 @@ export default function TaskManager({
)} )}
</div> </div>
<div className="flex items-center gap-1">
<button <button
className="industrial-btn industrial-btn-primary w-full py-1 text-[10px] flex items-center justify-center gap-1" className="industrial-btn industrial-btn-primary flex-1 min-w-0 py-1 text-[10px] flex items-center justify-center gap-1"
disabled={activeTaskId == null || selectedModelCadFileIds.size === 0 || linkCadFilesFromModel.isPending} disabled={activeTaskId == null || selectedModelCadFileIds.size === 0 || linkCadFilesFromModel.isPending}
onClick={() => { onClick={() => {
if (activeTaskId == null || selectedModelCadFileIds.size === 0) return; if (activeTaskId == null || selectedModelCadFileIds.size === 0) return;
@@ -862,6 +874,16 @@ export default function TaskManager({
<Plus className="w-3 h-3" /> <Plus className="w-3 h-3" />
添加到当前任务{selectedModelCadFileIds.size > 0 ? ` (${selectedModelCadFileIds.size})` : ''} 添加到当前任务{selectedModelCadFileIds.size > 0 ? ` (${selectedModelCadFileIds.size})` : ''}
</button> </button>
<button
type="button"
className="industrial-btn w-7 flex-shrink-0 px-0 py-1 text-[10px] flex items-center justify-center bg-red-900/55 border-red-500/60 text-red-100 hover:bg-red-800/70 hover:border-red-400"
title="关闭车型 CAD 库"
aria-label="关闭车型 CAD 库"
onClick={() => setCadLibraryOpen(false)}
>
<X className="w-3 h-3 flex-shrink-0" />
</button>
</div>
</div> </div>
)} )}

View File

@@ -10,20 +10,19 @@ import type { Overlay3D } from '@/components/GenericResultView';
import { trpc } from '@/lib/trpc'; import { trpc } from '@/lib/trpc';
import { import {
LogIn, LogIn,
LogOut,
Box, Box,
Cloud,
Eye, Eye,
EyeOff, EyeOff,
Settings, Settings,
Maximize2, Maximize2,
Crosshair,
Train, Train,
Loader2, Loader2,
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
export default function Home() { export default function Home() {
const { user, loading, isAuthenticated, logout } = useAuth(); const { loading, isAuthenticated } = useAuth();
const utils = trpc.useUtils(); const utils = trpc.useUtils();
// ── 树导航选中状态 ───────────────────────────────────────────────────────── // ── 树导航选中状态 ─────────────────────────────────────────────────────────
@@ -69,7 +68,8 @@ export default function Home() {
// ── 视图控制 ─────────────────────────────────────────────────────────────── // ── 视图控制 ───────────────────────────────────────────────────────────────
const [showHeatmap, setShowHeatmap] = useState(true); const [showHeatmap, setShowHeatmap] = useState(true);
const [showCADModel, setShowCADModel] = useState(true); const [showPointCloud, setShowPointCloud] = useState(true);
const [showCADModel] = useState(true);
const [showWireframe, setShowWireframe] = useState(false); const [showWireframe, setShowWireframe] = useState(false);
const [pointSize, setPointSize] = useState(0.03); const [pointSize, setPointSize] = useState(0.03);
/** 点云预览采样预算(非全量);调低可明显减轻卡顿 */ /** 点云预览采样预算(非全量);调低可明显减轻卡顿 */
@@ -88,7 +88,7 @@ export default function Home() {
resultId: number; resultId: number;
overlay: Overlay3D; overlay: Overlay3D;
} | null>(null); } | null>(null);
const [errorPickEnabled, setErrorPickEnabled] = useState(false); const [errorPickEnabled] = useState(false);
const [showLeftPanel, setShowLeftPanel] = useState(true); const [showLeftPanel, setShowLeftPanel] = useState(true);
const [showRightPanel, setShowRightPanel] = useState(true); const [showRightPanel, setShowRightPanel] = useState(true);
@@ -307,12 +307,47 @@ export default function Home() {
setHeatmapViewResultId(resultId); setHeatmapViewResultId(resultId);
setPointCloudRenderMode('smoothHeatmap'); setPointCloudRenderMode('smoothHeatmap');
}} }}
onViewPluginOverlay={({ taskId, resultId, overlay }) => { onViewPluginOverlay={({ taskId, resultId, overlay, inputPointCloudFileIds }) => {
const expectedIds = inputPointCloudFileIds ?? [];
const expectedFiles = expectedIds
.map((id) => taskPcFiles.find((f) => f.id === id))
.filter((f): f is NonNullable<typeof f> => f != null);
const expectedNames = expectedIds
.map((id) => taskPcFiles.find((f) => f.id === id)?.name ?? `#${id}`)
.join('、');
const expectedLabel = expectedFiles.length > 0 && expectedFiles.every((f) => (f as { source?: string }).source === 'registered')
? '配准后的点云'
: '本算法结果对应的点云文件';
const expectedHint = expectedNames ? `${expectedLabel}:${expectedNames}` : expectedLabel;
if (pointCloudTaskId == null || pointCloudUrls.length === 0) {
toast.error(`请先在左侧选择并加载${expectedHint}`);
return;
}
if (pcLoading) {
toast.warning('点云仍在加载中,请等待加载完成后再查看叠加层');
return;
}
if (pointCloudTaskId !== taskId) {
toast.error(`当前 3D 视口加载的点云不属于该算法结果,请先在左侧选择并加载${expectedHint}`);
return;
}
if (expectedIds.length === 0) {
toast.warning('无法确认匹配关系,请加载该结果运行时使用的点云');
} else {
const loadedIds = selectedPcFileIds.length > 0
? selectedPcFileIds
: taskPcFiles.map((f) => f.id);
const missingIds = expectedIds.filter((id) => !loadedIds.includes(id));
if (missingIds.length > 0) {
toast.error(`当前加载的点云与该结果不匹配,请在左侧选择并加载${expectedHint}`);
return;
}
}
setHeatmapViewResultId(null); setHeatmapViewResultId(null);
setPluginOverlay({ taskId, resultId, overlay }); setPluginOverlay({ taskId, resultId, overlay });
if (overlay.kind === 'vertexColors') { setPointCloudRenderMode(overlay.kind === 'vertexColors' ? 'smoothHeatmap' : 'points');
setPointCloudRenderMode('smoothHeatmap');
}
}} }}
externalPending={runAnalysisJob.isPending} externalPending={runAnalysisJob.isPending}
onRunAssembly={() => { onRunAssembly={() => {
@@ -374,33 +409,22 @@ export default function Home() {
</button> </button>
</div> </div>
{(pcLoading || cadLoading) && (
<div className="flex items-center gap-2 px-3 py-1 industrial-card"> <div className="flex items-center gap-2 px-3 py-1 industrial-card">
<span className={`status-led ${ <span className="status-led status-led-processing" />
pcLoading ? 'status-led-processing' :
cadLoading ? 'status-led-processing' :
'status-led-idle'
}`} />
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{pcLoading ? `加载点云 ${pcProgress}%` : {pcLoading ? `加载点云 ${pcProgress}%` : `加载 CAD ${cadLoadProgress}%`}
cadLoading ? `加载 CAD ${cadLoadProgress}%` :
'待机'}
</span> </span>
</div> </div>
)}
{loading ? ( {loading ? (
<div className="w-7 h-7 rounded bg-secondary animate-pulse" /> <div className="w-7 h-7 rounded bg-secondary animate-pulse" />
) : isAuthenticated ? ( ) : !isAuthenticated ? (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">{user?.name || '用户'}</span>
<button className="industrial-btn py-1.5" onClick={() => logout()}>
<LogOut className="w-3.5 h-3.5" />
</button>
</div>
) : (
<a href={getLoginUrl()} className="industrial-btn industrial-btn-primary py-1.5"> <a href={getLoginUrl()} className="industrial-btn industrial-btn-primary py-1.5">
<LogIn className="w-3.5 h-3.5" /> <LogIn className="w-3.5 h-3.5" />
</a> </a>
)} ) : null}
</div> </div>
</header> </header>
@@ -439,6 +463,7 @@ export default function Home() {
pointCloudUrls={pointCloudUrlsStable} pointCloudUrls={pointCloudUrlsStable}
batchTaskId={batchTaskId ?? undefined} batchTaskId={batchTaskId ?? undefined}
loadKey={pcLoadKey} loadKey={pcLoadKey}
showPointCloud={showPointCloud}
selectedPcFileIds={selectedPcFileIds.length > 0 ? selectedPcFileIds : undefined} selectedPcFileIds={selectedPcFileIds.length > 0 ? selectedPcFileIds : undefined}
onCadProgress={handleCadProgress} onCadProgress={handleCadProgress}
onCadLoaded={handleCadLoaded} onCadLoaded={handleCadLoaded}
@@ -474,13 +499,9 @@ export default function Home() {
{/* 操作提示 */} {/* 操作提示 */}
<div className="absolute bottom-3 left-3 info-overlay flex items-center gap-4 text-[10px] flex-wrap"> <div className="absolute bottom-3 left-3 info-overlay flex items-center gap-4 text-[10px] flex-wrap">
{errorPickEnabled && pointCloudRenderMode === 'smoothHeatmap' && ( <span>左键旋转</span>
<span className="text-amber-400/90">平滑模式不支持选点,请切回「点云」</span> <span>右键平移</span>
)} <span>滚轮缩放</span>
{errorPickEnabled && pointCloudRenderMode === 'points'
? <><span>左键点击选点误差</span><span>右键平移</span><span>滚轮缩放</span></>
: <><span>左键旋转</span><span>右键平移</span><span>滚轮缩放</span></>
}
</div> </div>
{/* 选中点信息 */} {/* 选中点信息 */}
@@ -522,15 +543,21 @@ export default function Home() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
type="button" type="button"
className={`industrial-btn py-1 ${pointCloudRenderMode === 'points' ? 'industrial-btn-primary' : ''}`} className={`industrial-btn h-7 px-2 py-0 inline-flex items-center gap-1 text-[10px] ${showPointCloud ? 'industrial-btn-primary' : ''}`}
onClick={() => { onClick={() => {
if (pointCloudRenderMode !== 'points') {
setPointCloudRenderMode('points'); setPointCloudRenderMode('points');
setHeatmapViewResultId(null); setHeatmapViewResultId(null);
setPluginOverlay(null); setPluginOverlay(null);
setShowPointCloud(true);
return;
}
setShowPointCloud(v => !v);
}} }}
title="逐点显示(支持误差拾取)" title={showPointCloud ? '隐藏点云' : '显示点云'}
> >
<span className="text-[10px]">点云</span> <Cloud className="w-3.5 h-3.5" />
<span>点云</span>
</button> </button>
{heatmapViewResultId != null && pointCloudRenderMode === 'smoothHeatmap' && ( {heatmapViewResultId != null && pointCloudRenderMode === 'smoothHeatmap' && (
<span className="text-[9px] text-primary/90 px-1.5 py-0.5 rounded bg-primary/10 border border-primary/20"> <span className="text-[9px] text-primary/90 px-1.5 py-0.5 rounded bg-primary/10 border border-primary/20">
@@ -538,26 +565,12 @@ export default function Home() {
</span> </span>
)} )}
<button <button
className={`industrial-btn py-1 ${showHeatmap ? 'industrial-btn-primary' : ''}`} type="button"
className={`industrial-btn h-7 px-2 py-0 inline-flex items-center gap-1 text-[10px] ${showHeatmap ? 'industrial-btn-primary' : ''}`}
onClick={() => setShowHeatmap(v => !v)} onClick={() => setShowHeatmap(v => !v)}
> >
{showHeatmap ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />} {showHeatmap ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
<span className="ml-1">热力图</span> <span>热力图</span>
</button>
<button
className={`industrial-btn py-1 ${showCADModel ? 'industrial-btn-primary' : ''}`}
onClick={() => setShowCADModel(v => !v)}
>
<Box className="w-3.5 h-3.5" />
<span className="ml-1">CAD</span>
</button>
<button
className={`industrial-btn py-1 ${errorPickEnabled ? 'industrial-btn-primary' : ''}`}
onClick={() => setErrorPickEnabled(v => !v)}
title="开启后左键点击显示该点误差"
>
<Crosshair className="w-3.5 h-3.5" />
<span className="ml-1">误差拾取</span>
</button> </button>
</div> </div>
@@ -584,7 +597,7 @@ export default function Home() {
<input <input
type="range" type="range"
min={100_000} min={100_000}
max={5_000_000} max={2_000_000}
step={100_000} step={100_000}
value={renderPointBudget} value={renderPointBudget}
onChange={e => setRenderPointBudget(Number(e.target.value))} onChange={e => setRenderPointBudget(Number(e.target.value))}

View File

@@ -122,6 +122,8 @@ async function writeInputJson(
params: Record<string, unknown>, params: Record<string, unknown>,
pointCloudPaths: string[], pointCloudPaths: string[],
cadPath: string | null, cadPath: string | null,
inputPointCloudFileIds: number[],
inputCadFileIds: number[],
) { ) {
const inputJson = { const inputJson = {
schemaVersion: 1, schemaVersion: 1,
@@ -133,7 +135,9 @@ async function writeInputJson(
outDir: workDir, outDir: workDir,
inputs: { inputs: {
pointCloudPaths, pointCloudPaths,
pointCloudFileIds: inputPointCloudFileIds,
cadPath, cadPath,
cadFileIds: inputCadFileIds,
config: params, config: params,
}, },
}; };
@@ -331,6 +335,9 @@ export async function runAlgorithmJob(
pointCloudFileIds, pointCloudFileIds,
cadFileIds, cadFileIds,
); );
const inputPointCloudFileIds = pointCloudRecords.map((r) => r.id);
const inputCadFileIds = Array.isArray(cadFileIds) ? cadFileIds.filter((id) => Number.isInteger(id)) : [];
const inputJsonPath = await writeInputJson( const inputJsonPath = await writeInputJson(
workDir, workDir,
taskId, taskId,
@@ -339,6 +346,8 @@ export async function runAlgorithmJob(
params, params,
pointCloudPaths, pointCloudPaths,
cadPath, cadPath,
inputPointCloudFileIds,
inputCadFileIds,
); );
let exitCode: number | null = 0; let exitCode: number | null = 0;
@@ -363,6 +372,8 @@ export async function runAlgorithmJob(
progress: finalStatus === 'completed' ? 1 : undefined, progress: finalStatus === 'completed' ? 1 : undefined,
stage: finalStatus === 'completed' ? '完成' : '失败', stage: finalStatus === 'completed' ? '完成' : '失败',
exitCode, exitCode,
inputPointCloudFileIds,
inputCadFileIds,
// 把 result.json 内容平展到 resultData 里,方便前端直接使用 // 把 result.json 内容平展到 resultData 里,方便前端直接使用
schemaVersion: result?.schemaVersion ?? manifest.schemaVersion, schemaVersion: result?.schemaVersion ?? manifest.schemaVersion,
message: result?.message, message: result?.message,

View File

@@ -394,7 +394,7 @@ def main() -> int:
overlay3d: dict = {"kind": "none"} overlay3d: dict = {"kind": "none"}
for ply_key in ("contourPly", "curvaturePly"): for ply_key in ("contourPly", "curvaturePly"):
if ply_key in extra: if ply_key in extra:
overlay3d = {"kind": "vertexColors", "url": extra[ply_key]} overlay3d = {"kind": "ply", "file": extra[ply_key]}
break break
if skipped: if skipped:

View File

@@ -141,6 +141,8 @@ export type PluginOverlay3D =
/** 着色 PLY 叠加文件(同样相对 outDir) */ /** 着色 PLY 叠加文件(同样相对 outDir) */
kind: 'ply'; kind: 'ply';
file: string; file: string;
/** 批量结果可包含多个 PLY 叠加文件 */
files?: string[];
}; };
/** 算法写出的 result.json(与 INTEGRATION.md 描述对齐) */ /** 算法写出的 result.json(与 INTEGRATION.md 描述对齐) */
@@ -177,4 +179,8 @@ export interface PluginResultData extends Partial<PluginResult> {
error?: string; error?: string;
/** 子进程已结束时的退出码 */ /** 子进程已结束时的退出码 */
exitCode?: number | null; exitCode?: number | null;
/** 本次算法运行实际使用的任务点云文件 ID,用于前端叠加层匹配校验 */
inputPointCloudFileIds?: number[];
/** 本次算法运行前端选择的任务 CAD 文件 ID,用于后续匹配/追溯 */
inputCadFileIds?: number[];
} }