import { useState, useCallback, useEffect, 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 { trpc } from '@/lib/trpc'; import { LogIn, Box, Cloud, Eye, EyeOff, Settings, Maximize2, Train, Loader2, } from 'lucide-react'; import { toast } from 'sonner'; export default function Home() { const { loading, isAuthenticated } = 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 [showPointCloud, setShowPointCloud] = useState(true); const [showCADModel] = 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] = useState(false); 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 }); }, 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'); 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; // 必须稳定数组引用:若每次 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} /> ); }; 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, inputPointCloudFileIds }) => { const expectedIds = inputPointCloudFileIds ?? []; const expectedFiles = expectedIds .map((id) => taskPcFiles.find((f) => f.id === id)) .filter((f): f is NonNullable => 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); setPluginOverlay({ taskId, resultId, overlay }); setPointCloudRenderMode(overlay.kind === 'vertexColors' ? 'smoothHeatmap' : 'points'); }} 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 || cadLoading) && (
{pcLoading ? `加载点云 ${pcProgress}%` : `加载 CAD ${cadLoadProgress}%`}
)} {loading ? (
) : !isAuthenticated ? ( ) : null}
{/* 主内容区 */}
{/* 左侧面板 */} {showLeftPanel && ( )} {/* 中央 3D 视图 */}
{/* 视图标题栏 */}
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}%
)}
{/* 操作提示 */}
左键旋转 右键平移 滚轮缩放
{/* 选中点信息 */} {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 && ( )}
); }