import { useState, useCallback, useEffect, useRef, useMemo } from 'react'; import { useAuth } from '@/_core/hooks/useAuth'; import { getLoginUrl } from '@/const'; import PointCloudViewer from '@/components/PointCloudViewer'; import VehicleTree, { TreeSelection } from '@/components/VehicleTree'; import TaskManager from '@/components/TaskManager'; import AnalysisPanel from '@/components/AnalysisPanel'; import AlgorithmRunPanel from '@/components/AlgorithmRunPanel'; import type { Overlay3D } from '@/components/GenericResultView'; import WorkflowProgress, { type WorkflowState, type WorkflowStepId, type StepStatus } from '@/components/WorkflowProgress'; import { trpc } from '@/lib/trpc'; import { LogIn, LogOut, Box, Eye, EyeOff, Settings, Maximize2, Crosshair, Train, Loader2, } from 'lucide-react'; import { toast } from 'sonner'; export default function Home() { const { user, loading, isAuthenticated, logout } = useAuth(); const utils = trpc.useUtils(); // ── 树导航选中状态 ───────────────────────────────────────────────────────── const [selection, setSelection] = useState({ seriesId: null, modelId: null, taskId: null, }); // ── 3D 视图数据 ──────────────────────────────────────────────────────────── const [cadModelUrl, setCadModelUrl] = useState(); const [cadModelFormat, setCadModelFormat] = useState(); const [activeCadName, setActiveCadName] = useState(); const [cadLoading, setCadLoading] = useState(false); const [cadLoadProgress, setCadLoadProgress] = useState(0); const [pointCloudTaskId, setPointCloudTaskId] = useState(null); const [pointCloudUrls, setPointCloudUrls] = useState([]); const [pcLoading, setPcLoading] = useState(false); const [pcProgress, setPcProgress] = useState(0); /** 每次用户主动点「加载」时递增,强制 PointCloudViewer 重新采样 */ const [pcLoadKey, setPcLoadKey] = useState(0); /** 当前任务下用户勾选的点云文件 ID(空 = 加载全部) */ const [selectedPcFileIds, setSelectedPcFileIds] = useState([]); /** 当前任务下所有点云文件列表(用于判断是否含已配准点云) */ const { data: taskPcFiles = [] } = trpc.pointcloudFile.list.useQuery( { taskId: selection.taskId! }, { enabled: selection.taskId != null }, ); /** 已选中的点云文件中,是否包含配准算法产出的点云 */ const hasRegisteredCloud = selectedPcFileIds.length === 0 ? taskPcFiles.some((f) => (f as { source?: string }).source === 'registered') : taskPcFiles .filter((f) => selectedPcFileIds.includes(f.id)) .some((f) => (f as { source?: string }).source === 'registered'); /** 当前任务下用户勾选的 CAD 文件 ID(空 = 不传 CAD) */ const [selectedTaskCadFileIds, setSelectedTaskCadFileIds] = useState([]); /** ROI 坐标拾取状态(train-measurement 专用) */ const [roiPickMode, setRoiPickMode] = useState(false); const [roiPickedPoint, setRoiPickedPoint] = useState<{ x: number; y: number; z: number } | null>(null); const [activeRoiPickTarget, setActiveRoiPickTarget] = useState<{ taskIdx: number; roiKey: 'source_roi' | 'target_roi'; corner: 'min' | 'max' } | null>(null); // ── 视图控制 ─────────────────────────────────────────────────────────────── const [showHeatmap, setShowHeatmap] = useState(true); const [showCADModel, setShowCADModel] = useState(true); const [showWireframe, setShowWireframe] = useState(false); const [pointSize, setPointSize] = useState(0.03); /** 点云预览采样预算(非全量);调低可明显减轻卡顿 */ const [renderPointBudget, setRenderPointBudget] = useState(400_000); /** 点云:逐点;平滑热力:仅在分析结果就绪后由右侧「在 3D 中查看」切换 */ const [pointCloudRenderMode, setPointCloudRenderMode] = useState<'points' | 'smoothHeatmap'>('points'); /** 后台「平滑热力」分析结果 ID,用于拉取顶点色 */ const [heatmapViewResultId, setHeatmapViewResultId] = useState(null); /** * 算法插件叠加层(Route B):用户在 AnalysisPanel 点「在 3D 中查看叠加层」时 * 把当前结果挂到 PointCloudViewer。PointCloudViewer 据此优先于 legacy * heatmapAnalysisResultId 走插件叠加链路。 */ const [pluginOverlay, setPluginOverlay] = useState<{ taskId: number; resultId: number; overlay: Overlay3D; } | null>(null); const [errorPickEnabled, setErrorPickEnabled] = useState(false); const [isCreatingTask, setIsCreatingTask] = useState(false); // 触发分析时的 completed 结果基线数(key=taskId, value=触发时已有的 completed 数) const [analysisBaselines, setAnalysisBaselines] = useState>(new Map()); // 用 ref 在 onSuccess 闭包中读取最新 analysisResults,避免过时闭包 const analysisResultsRef = useRef([]); const [showLeftPanel, setShowLeftPanel] = useState(true); const [showRightPanel, setShowRightPanel] = useState(true); const [selectedPoint, setSelectedPoint] = useState<{ position: { x: number; y: number; z: number }; error: number; index: number; } | null>(null); // ── 当前车型信息(用于面板标题) ─────────────────────────────────────────── const { data: activeModel } = trpc.vehicleModel.get.useQuery( { id: selection.modelId! }, { enabled: selection.modelId != null } ); /** 平滑热力与装配检测共用同一 mutation,避免重复订阅;勿在 batchTaskId 为空时 disabled,否则点击无任何反馈 */ const runAnalysisJob = trpc.analysisResult.runAnalysis.useMutation({ onSuccess: (_data, variables) => { if (variables.type === 'smooth_heatmap') { toast.success('已提交平滑热力后台分析,请稍后在右侧「分析结果」中查看;完成后点击「在 3D 中查看」'); } else if (variables.type === 'assembly_inspection') { toast.success('已提交装配检测(STEP + 最近零件),请稍后在右侧「分析结果」查看各零件统计'); } else if (variables.type === 'obj_deviation') { toast.success('已提交偏差热力分析(STL/OBJ + 点云距离),完成后点击「在 3D 中查看」'); } void utils.analysisResult.list.invalidate({ taskId: variables.taskId }); // 记录此时已有的 completed 数作为基线,只有新增结果才算本次分析完成 const baselineCount = analysisResultsRef.current.filter((r: any) => r.status === 'completed').length; setAnalysisBaselines(prev => { const next = new Map(prev); if (!next.has(variables.taskId)) next.set(variables.taskId, baselineCount); return next; }); }, onError: (e) => toast.error(e.message || '提交失败'), }); // 切换车型时清空 CAD,避免沿用上一车型的 URL useEffect(() => { setCadModelUrl(undefined); setCadModelFormat(undefined); setActiveCadName(undefined); setCadLoading(false); setCadLoadProgress(0); }, [selection.modelId]); useEffect(() => { setHeatmapViewResultId(null); setPluginOverlay(null); setPointCloudRenderMode('points'); setIsCreatingTask(false); setSelectedPcFileIds([]); setSelectedTaskCadFileIds([]); setRoiPickMode(false); setRoiPickedPoint(null); setActiveRoiPickTarget(null); }, [selection.taskId]); // ── 回调 ─────────────────────────────────────────────────────────────────── /** STEP 无浏览器解析:若服务端已有 assembly manifest,则用首件 STL 作为 3D 预览 */ const resolveCadPreview = useCallback( async (modelId: number, file: { id: number; name: string; format: string }) => { const lower = file.name.toLowerCase(); if (!lower.endsWith('.stp') && !lower.endsWith('.step')) { return { url: `/api/files/read/cad/${modelId}/${encodeURIComponent(file.name)}`, format: file.format, label: file.name, }; } try { const res = await utils.cadFile.assemblyManifest.fetch({ modelId, cadFileId: file.id }); const manifest = res.manifest as { parts?: { stlRelative: string; name: string }[] } | null; if (res.ready && manifest?.parts && manifest.parts.length > 0) { const p0 = manifest.parts[0]!; return { url: `/api/files/read/cad/${modelId}/${encodeURIComponent( `.step_parse/${file.id}/${p0.stlRelative}`, )}`, format: 'stl', label: `${file.name} → ${p0.name}`, }; } } catch (e) { console.warn('[Home] assemblyManifest', e); } return null; }, [utils.cadFile.assemblyManifest], ); const handleCadProgress = useCallback((p: number) => setCadLoadProgress(p), []); const handleCadLoaded = useCallback(() => { setCadLoading(false); setCadLoadProgress(100); }, []); const handleCadLoadError = useCallback((message: string) => { setCadLoading(false); setCadLoadProgress(0); toast.error(message); }, []); const handleSelectCad = useCallback( async (url: string, format: string, name: string, cadFileId?: number) => { setCadLoading(true); setCadLoadProgress(3); if (selection.modelId != null && cadFileId != null) { const resolved = await resolveCadPreview(selection.modelId, { id: cadFileId, name, format }); if (resolved) { setCadModelUrl(resolved.url); setCadModelFormat(resolved.format); setActiveCadName(resolved.label); return; } const isStep = name.toLowerCase().match(/\.(stp|step)$/); if (isStep) { toast.error( 'STEP 尚未生成三角网格预览。请先点击底部「装配检测」跑完一次(需 Python cadquery),或改用 STL/OBJ 做 3D 预览。', ); setCadLoading(false); setCadLoadProgress(0); setCadModelUrl(undefined); setCadModelFormat(undefined); setActiveCadName(undefined); return; } } setCadModelUrl(url); setCadModelFormat(format); setActiveCadName(name); }, [resolveCadPreview, selection.modelId], ); const handleLoadPointClouds = useCallback(async (taskId: number, _fileIds: number[]) => { setPointCloudTaskId(taskId); setPointCloudUrls([`__task:${taskId}`]); setPcLoading(true); setPcProgress(0); // 每次主动加载都递增 key,确保 PointCloudViewer 重新采样 // (即使 taskId / selectedPcFileIds 没变也会触发) setPcLoadKey(k => k + 1); }, []); const handlePcProgress = useCallback((p: number) => setPcProgress(p), []); const handlePcLoaded = useCallback(() => { setPcLoading(false); setPcProgress(100); }, []); const handlePointSelect = useCallback((pt: any) => { if (pt) setSelectedPoint({ position: pt.position, error: pt.error, index: pt.index }); }, []); // ── 把 __task:N 格式的 URL 转成 batch 请求参数 ────────────────────────────── const batchTaskId = pointCloudTaskId; // ── 工作流进度条状态推导 ─────────────────────────────────────────────────── const { data: analysisResults = [] } = trpc.analysisResult.list.useQuery( { taskId: selection.taskId! }, { enabled: selection.taskId != null, refetchInterval: 5000 }, ); useEffect(() => { analysisResultsRef.current = analysisResults; }, [analysisResults]); const workflowState = useMemo(() => { const hasCad = !!cadModelUrl; const hasPc = pointCloudTaskId != null; const rendered = hasPc && !pcLoading && pcProgress >= 100; const baseline = (!isCreatingTask && selection.taskId != null) ? (analysisBaselines.get(selection.taskId) ?? -1) : -1; const triggered = baseline >= 0; const currentCompleted = (analysisResults as any[]).filter((r: any) => r.status === 'completed').length; const analyzed = triggered && currentCompleted > baseline; function step(cond: boolean, prev: boolean): 'done' | 'active' | 'pending' { if (cond) return 'done'; if (prev) return 'active'; return 'pending'; } // 分析步骤:只在 触发后未完成 时转圈;保存步骤:分析完成即完成,无中间态 const analysisStatus: StepStatus = analyzed ? 'done' : triggered ? 'active' : 'pending'; const saveStatus: StepStatus = analyzed ? 'done' : 'pending'; return { cad: step(hasCad, true), pointcloud: step(hasPc, hasCad), render: step(rendered, hasPc), analysis: analysisStatus, save: saveStatus, }; }, [cadModelUrl, pointCloudTaskId, pcLoading, pcProgress, analysisResults, isCreatingTask, analysisBaselines, selection.taskId]); const rightPanelRef = useRef(null); const handleWorkflowStepClick = useCallback((id: WorkflowStepId) => { if (id === 'cad' || id === 'pointcloud') { setShowLeftPanel(true); } else if (id === 'render') { if (selection.taskId != null && pointCloudTaskId !== selection.taskId) { handleLoadPointClouds(selection.taskId, []); } } else if (id === 'analysis' || id === 'save') { setShowRightPanel(true); } }, [selection.taskId, pointCloudTaskId, handleLoadPointClouds]); // 必须稳定数组引用:若每次 render 都 new [],PointCloudViewer 会以为点云源变了而反复全量请求 const pointCloudUrlsStable = useMemo( () => (pointCloudTaskId != null ? [`__task:${pointCloudTaskId}`] : undefined), [pointCloudTaskId] ); // ── 左侧面板渲染 ─────────────────────────────────────────────────────────── const renderLeftPanel = () => { if (selection.modelId == null) { return ( ); } return ( <>
setSelection(s => ({ ...s, taskId }))} onLoadPointClouds={handleLoadPointClouds} onPcFilesSelectionChange={setSelectedPcFileIds} onCadFilesSelectionChange={setSelectedTaskCadFileIds} onSelectTaskCad={handleSelectCad} onStartCreate={() => setIsCreatingTask(true)} onEndCreate={() => setIsCreatingTask(false)} /> ); }; const renderRightPanel = () => { if (selection.taskId == null) { return (

请从左侧选择检测任务

选中任务后可查看分析结果

); } return (
0} taskPcFileCount={taskPcFiles.length} taskPointcloudFiles={taskPcFiles} selectedPcFileIds={selectedPcFileIds} hasRegisteredCloud={hasRegisteredCloud} selectedCadFileIds={selectedTaskCadFileIds} roiPickedPoint={roiPickedPoint} onStartRoiPick={(taskIdx, roiKey, corner) => { setActiveRoiPickTarget({ taskIdx, roiKey, corner }); setRoiPickMode(true); setRoiPickedPoint(null); }} activeRoiPickTarget={activeRoiPickTarget} />
console.log('[Replay] resultId', resultId)} onViewSmoothHeatmap={(resultId) => { setPluginOverlay(null); setHeatmapViewResultId(resultId); setPointCloudRenderMode('smoothHeatmap'); }} onViewPluginOverlay={({ taskId, resultId, overlay }) => { setHeatmapViewResultId(null); setPluginOverlay({ taskId, resultId, overlay }); if (overlay.kind === 'vertexColors') { setPointCloudRenderMode('smoothHeatmap'); } }} externalPending={runAnalysisJob.isPending} onRunAssembly={() => { if (batchTaskId == null) { toast.error('请先在左侧选择检测任务并加载点云'); return; } runAnalysisJob.mutate({ taskId: batchTaskId, type: 'assembly_inspection', samplePoints: Math.min(200_000, Math.max(20_000, renderPointBudget)), toleranceMm: 0.3, includeVertexHeatmap: false }); }} onRunDeviation={() => { if (batchTaskId == null) { toast.error('请先在左侧选择检测任务并加载点云'); return; } runAnalysisJob.mutate({ taskId: batchTaskId, type: 'obj_deviation', totalPoints: renderPointBudget }); }} />
); }; return (
{/* 顶部工具栏 */}

宏量点云处理系统

{activeModel && ( <>
{activeModel.name} {selection.taskId && ( 任务 #{selection.taskId} )}
)}
{pcLoading ? `加载点云 ${pcProgress}%` : cadLoading ? `加载 CAD ${cadLoadProgress}%` : '待机'}
{loading ? (
) : isAuthenticated ? (
{user?.name || '用户'}
) : ( )}
{/* 主内容区 */}
{/* 左侧面板 */} {showLeftPanel && ( )} {/* 中央 3D 视图 */}
{/* 工作流进度条(仅在选中任务时显示) */} {selection.taskId != null && ( )}
{/* 视图标题栏 */}
3D Viewport
FPS: 60
{/* 3D 渲染区域 */}
0 ? selectedPcFileIds : undefined} onCadProgress={handleCadProgress} onCadLoaded={handleCadLoaded} onCadLoadError={handleCadLoadError} onPointCloudProgress={handlePcProgress} onPointCloudLoaded={handlePcLoaded} onPointSelect={handlePointSelect} enableErrorPicking={errorPickEnabled} renderPointBudget={renderPointBudget} pointCloudRenderMode={pointCloudRenderMode} heatmapAnalysisResultId={heatmapViewResultId} pluginOverlay={pluginOverlay} roiPickMode={roiPickMode} onRoiPointPicked={(pt) => { setRoiPickedPoint(pt); setRoiPickMode(false); }} className="w-full h-full" /> {pcLoading && (
正在加载点云数据
{pcProgress}%
)}
{/* 操作提示 */}
{errorPickEnabled && pointCloudRenderMode === 'smoothHeatmap' && ( 平滑模式不支持选点,请切回「点云」 )} {errorPickEnabled && pointCloudRenderMode === 'points' ? <>左键点击选点误差右键平移滚轮缩放 : <>左键旋转右键平移滚轮缩放 }
{/* 选中点信息 */} {selectedPoint && (
选中点
X
{selectedPoint.position.x.toFixed(2)}
Y
{selectedPoint.position.y.toFixed(2)}
Z
{selectedPoint.position.z.toFixed(2)}
误差 1.5 ? 'text-red-400' : selectedPoint.error > 0.8 ? 'text-amber-400' : 'text-green-400' }`}>{selectedPoint.error.toFixed(4)} mm
)} {/* 坐标轴 */}
X Y Z
{/* 底部视图控制 */}
{heatmapViewResultId != null && pointCloudRenderMode === 'smoothHeatmap' && ( 正在显示分析结果 #{heatmapViewResultId} )}
点大小 setPointSize(parseFloat(e.target.value))} className="w-24 h-1 bg-secondary rounded appearance-none cursor-pointer" /> {pointSize.toFixed(3)}
预览点数 setRenderPointBudget(Number(e.target.value))} className="w-28 h-1 bg-secondary rounded appearance-none cursor-pointer" /> {renderPointBudget >= 1_000_000 ? (renderPointBudget / 1_000_000).toFixed(1) + 'M' : (renderPointBudget / 1000).toFixed(0) + 'k'}
{/* 右侧面板 - 分析结果 */} {showRightPanel && ( )}
); }