feat: initial commit - massive-cloud point cloud processing system

This commit is contained in:
2026-05-27 09:51:42 +00:00
commit 16811100d1
391 changed files with 78338 additions and 0 deletions

674
client/src/pages/Home.tsx Normal file
View File

@@ -0,0 +1,674 @@
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 ModelManager from '@/components/ModelManager';
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<TreeSelection>({
seriesId: null,
modelId: null,
taskId: null,
});
// ── 3D 视图数据 ────────────────────────────────────────────────────────────
const [cadModelUrl, setCadModelUrl] = useState<string | undefined>();
const [cadModelFormat, setCadModelFormat] = useState<string | undefined>();
const [activeCadName, setActiveCadName] = useState<string | undefined>();
const [cadLoading, setCadLoading] = useState(false);
const [cadLoadProgress, setCadLoadProgress] = useState(0);
const [pointCloudTaskId, setPointCloudTaskId] = useState<number | null>(null);
const [pointCloudUrls, setPointCloudUrls] = useState<string[]>([]);
const [pcLoading, setPcLoading] = useState(false);
const [pcProgress, setPcProgress] = useState(0);
/** 每次用户主动点「加载」时递增,强制 PointCloudViewer 重新采样 */
const [pcLoadKey, setPcLoadKey] = useState(0);
/** 当前任务下用户勾选的点云文件 ID(空 = 加载全部) */
const [selectedPcFileIds, setSelectedPcFileIds] = useState<number[]>([]);
/** 当前任务下用户勾选的 CAD 文件 ID(空 = 不传 CAD) */
const [selectedTaskCadFileIds, setSelectedTaskCadFileIds] = useState<number[]>([]);
/** 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<number | null>(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<Map<number, number>>(new Map());
// 用 ref 在 onSuccess 闭包中读取最新 analysisResults,避免过时闭包
const analysisResultsRef = useRef<any[]>([]);
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<WorkflowState>(() => {
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<HTMLDivElement | null>(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 (
<VehicleTree selection={selection} onSelect={setSelection} />
);
}
return (
<>
<VehicleTree selection={selection} onSelect={setSelection} />
<div className="h-px bg-border my-1" />
<ModelManager
modelId={selection.modelId}
onSelectCad={handleSelectCad}
activeCadName={activeCadName}
cadLoading={cadLoading}
cadLoadProgress={cadLoadProgress}
/>
<div className="h-px bg-border my-1" />
<TaskManager
modelId={selection.modelId}
activeTaskId={selection.taskId}
onSelectTask={taskId => 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 (
<div className="flex flex-col items-center justify-center h-full text-center py-16 text-xs text-muted-foreground space-y-2">
<Train className="w-8 h-8 opacity-20" />
<p>请从左侧选择检测任务</p>
<p className="text-[10px]">选中任务后可查看分析结果</p>
</div>
);
}
return (
<div className="space-y-4">
<AlgorithmRunPanel
taskId={selection.taskId}
pointCloudLoaded={pointCloudTaskId === selection.taskId && pointCloudUrls.length > 0}
selectedPcFileIds={selectedPcFileIds}
selectedCadFileIds={selectedTaskCadFileIds}
roiPickedPoint={roiPickedPoint}
onStartRoiPick={(taskIdx, roiKey, corner) => {
setActiveRoiPickTarget({ taskIdx, roiKey, corner });
setRoiPickMode(true);
setRoiPickedPoint(null);
}}
activeRoiPickTarget={activeRoiPickTarget}
/>
<div className="h-px bg-border" />
<AnalysisPanel
taskId={selection.taskId}
onReplay={resultId => 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 });
}}
/>
</div>
);
};
return (
<div className="h-screen flex flex-col bg-background">
{/* 顶部工具栏 */}
<header className="h-12 border-b border-border bg-card flex items-center justify-between px-4 flex-shrink-0">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded bg-primary/20 flex items-center justify-center">
<Box className="w-4 h-4 text-primary" />
</div>
<h1 className="text-sm font-semibold">宏量点云处理系统</h1>
</div>
{activeModel && (
<>
<div className="h-6 w-px bg-border" />
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Train className="w-3.5 h-3.5" />
<span>{activeModel.name}</span>
{selection.taskId && (
<span className="text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
任务 #{selection.taskId}
</span>
)}
</div>
</>
)}
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
<button
className={`industrial-btn py-1.5 ${showLeftPanel ? 'industrial-btn-primary' : ''}`}
onClick={() => setShowLeftPanel(v => !v)}
title="左侧面板"
>
<Settings className="w-3.5 h-3.5" />
</button>
<button
className={`industrial-btn py-1.5 ${showRightPanel ? 'industrial-btn-primary' : ''}`}
onClick={() => setShowRightPanel(v => !v)}
title="右侧面板"
>
<Eye className="w-3.5 h-3.5" />
</button>
</div>
<div className="flex items-center gap-2 px-3 py-1 industrial-card">
<span className={`status-led ${
pcLoading ? 'status-led-processing' :
cadLoading ? 'status-led-processing' :
'status-led-idle'
}`} />
<span className="text-xs text-muted-foreground">
{pcLoading ? `加载点云 ${pcProgress}%` :
cadLoading ? `加载 CAD ${cadLoadProgress}%` :
'待机'}
</span>
</div>
{loading ? (
<div className="w-7 h-7 rounded bg-secondary animate-pulse" />
) : 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">
<LogIn className="w-3.5 h-3.5" />
</a>
)}
</div>
</header>
{/* 主内容区 */}
<div className="flex-1 flex overflow-hidden">
{/* 左侧面板 */}
{showLeftPanel && (
<aside className="w-72 border-r border-border bg-card/30 flex-shrink-0 overflow-y-auto p-3 space-y-2">
{renderLeftPanel()}
</aside>
)}
{/* 中央 3D 视图 */}
<main className="flex-1 flex flex-col min-w-0">
{/* 工作流进度条(仅在选中任务时显示) */}
{selection.taskId != null && (
<WorkflowProgress
state={workflowState}
onStepClick={handleWorkflowStepClick}
/>
)}
<div className="flex-1 relative">
<div className="absolute inset-2">
<div className="viewer-container w-full h-full relative">
{/* 视图标题栏 */}
<div className="absolute top-0 left-0 right-0 h-6 bg-[oklch(0.16_0.012_240)] border-b border-border z-10 flex items-center px-3">
<span className="text-[10px] text-muted-foreground uppercase tracking-wider">3D Viewport</span>
<div className="flex-1" />
<div className="flex items-center gap-3 text-[10px]">
<span className="text-muted-foreground">FPS: <span className="text-[oklch(0.62_0.16_145)] font-mono">60</span></span>
</div>
</div>
{/* 3D 渲染区域 */}
<div className="absolute inset-0 pt-6">
<PointCloudViewer
showHeatmap={showHeatmap}
showCADModel={showCADModel}
showWireframe={showWireframe}
pointSize={pointSize}
cadModelUrl={cadModelUrl}
cadModelFormat={cadModelFormat}
pointCloudUrls={pointCloudUrlsStable}
batchTaskId={batchTaskId ?? undefined}
loadKey={pcLoadKey}
selectedPcFileIds={selectedPcFileIds.length > 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 && (
<div className="absolute inset-0 flex items-center justify-center bg-background/70 backdrop-blur-sm pointer-events-none">
<div className="industrial-panel p-6 text-center max-w-xs">
<div className="text-sm font-medium mb-3">正在加载点云数据</div>
<div className="industrial-progress mb-2">
<div className="industrial-progress-bar" style={{ width: `${pcProgress}%` }} />
</div>
<div className="text-xs text-muted-foreground">{pcProgress}%</div>
</div>
</div>
)}
</div>
{/* 操作提示 */}
<div className="absolute bottom-3 left-3 info-overlay flex items-center gap-4 text-[10px] flex-wrap">
{errorPickEnabled && pointCloudRenderMode === 'smoothHeatmap' && (
<span className="text-amber-400/90">平滑模式不支持选点,请切回「点云」</span>
)}
{errorPickEnabled && pointCloudRenderMode === 'points'
? <><span>左键点击选点误差</span><span>右键平移</span><span>滚轮缩放</span></>
: <><span>左键旋转</span><span>右键平移</span><span>滚轮缩放</span></>
}
</div>
{/* 选中点信息 */}
{selectedPoint && (
<div className="absolute top-10 right-2 industrial-panel p-2 text-xs space-y-1 min-w-[140px]">
<div className="industrial-label text-[10px] mb-1">选中点</div>
<div className="grid grid-cols-3 gap-1 font-mono text-[10px]">
<div className="text-center"><div className="text-muted-foreground">X</div>{selectedPoint.position.x.toFixed(2)}</div>
<div className="text-center"><div className="text-muted-foreground">Y</div>{selectedPoint.position.y.toFixed(2)}</div>
<div className="text-center"><div className="text-muted-foreground">Z</div>{selectedPoint.position.z.toFixed(2)}</div>
</div>
<div className="flex justify-between items-center border-t border-border/40 pt-1">
<span className="text-muted-foreground">误差</span>
<span className={`font-mono ${
selectedPoint.error > 1.5 ? 'text-red-400' :
selectedPoint.error > 0.8 ? 'text-amber-400' : 'text-green-400'
}`}>{selectedPoint.error.toFixed(4)} mm</span>
</div>
</div>
)}
{/* 坐标轴 */}
<div className="axis-indicator">
<svg viewBox="0 0 50 50" className="w-full h-full">
<line x1="25" y1="25" x2="45" y2="25" stroke="#ef4444" strokeWidth="2" />
<line x1="25" y1="25" x2="25" y2="5" stroke="#22c55e" strokeWidth="2" />
<line x1="25" y1="25" x2="10" y2="40" stroke="#3b82f6" strokeWidth="2" />
<text x="46" y="28" fill="#ef4444" fontSize="8">X</text>
<text x="22" y="4" fill="#22c55e" fontSize="8">Y</text>
<text x="4" y="44" fill="#3b82f6" fontSize="8">Z</text>
</svg>
</div>
</div>
</div>
</div>
{/* 底部视图控制 */}
<div className="h-10 border-t border-border bg-card/50 flex items-center px-4 gap-4">
<div className="flex items-center gap-2">
<button
type="button"
className={`industrial-btn py-1 ${pointCloudRenderMode === 'points' ? 'industrial-btn-primary' : ''}`}
onClick={() => {
setPointCloudRenderMode('points');
setHeatmapViewResultId(null);
setPluginOverlay(null);
}}
title="逐点显示(支持误差拾取)"
>
<span className="text-[10px]">点云</span>
</button>
{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">
正在显示分析结果 #{heatmapViewResultId}
</span>
)}
<button
className={`industrial-btn py-1 ${showHeatmap ? 'industrial-btn-primary' : ''}`}
onClick={() => setShowHeatmap(v => !v)}
>
{showHeatmap ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
<span className="ml-1">热力图</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>
</div>
<div className="h-5 w-px bg-border" />
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">点大小</span>
<input
type="range"
min="0.01"
max="0.1"
step="0.005"
value={pointSize}
onChange={e => setPointSize(parseFloat(e.target.value))}
className="w-24 h-1 bg-secondary rounded appearance-none cursor-pointer"
/>
<span className="text-xs font-mono w-10">{pointSize.toFixed(3)}</span>
</div>
<div className="h-5 w-px bg-border" />
<div className="flex items-center gap-2" title="服务端随机采样点数,非磁盘全量。调低更流畅;偏差热力 GPU 可设更高。">
<span className="text-xs text-muted-foreground whitespace-nowrap">预览点数</span>
<input
type="range"
min={100_000}
max={5_000_000}
step={100_000}
value={renderPointBudget}
onChange={e => setRenderPointBudget(Number(e.target.value))}
className="w-28 h-1 bg-secondary rounded appearance-none cursor-pointer"
/>
<span className="text-xs font-mono w-14">{renderPointBudget >= 1_000_000 ? (renderPointBudget / 1_000_000).toFixed(1) + 'M' : (renderPointBudget / 1000).toFixed(0) + 'k'}</span>
</div>
</div>
</main>
{/* 右侧面板 - 分析结果 */}
{showRightPanel && (
<aside ref={rightPanelRef} className="w-80 border-l border-border bg-card/30 flex-shrink-0 overflow-y-auto p-3">
{renderRightPanel()}
</aside>
)}
</div>
</div>
);
}