Files
massive-cloud/client/src/pages/Home.tsx

681 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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[]>([]);
/** 当前任务下所有点云文件列表(用于判断是否含已配准点云) */
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<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" />
<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}
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}
/>
<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>
);
}