621 lines
28 KiB
TypeScript
621 lines
28 KiB
TypeScript
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<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 [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<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] = 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 (
|
||
<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}
|
||
/>
|
||
</>
|
||
);
|
||
};
|
||
|
||
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}
|
||
modelId={selection.modelId}
|
||
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, 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);
|
||
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 });
|
||
}}
|
||
/>
|
||
</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>
|
||
|
||
{(pcLoading || cadLoading) && (
|
||
<div className="flex items-center gap-2 px-3 py-1 industrial-card">
|
||
<span className="status-led status-led-processing" />
|
||
<span className="text-xs text-muted-foreground">
|
||
{pcLoading ? `加载点云 ${pcProgress}%` : `加载 CAD ${cadLoadProgress}%`}
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{loading ? (
|
||
<div className="w-7 h-7 rounded bg-secondary animate-pulse" />
|
||
) : !isAuthenticated ? (
|
||
<a href={getLoginUrl()} className="industrial-btn industrial-btn-primary py-1.5">
|
||
<LogIn className="w-3.5 h-3.5" />
|
||
</a>
|
||
) : null}
|
||
</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">
|
||
<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}
|
||
showPointCloud={showPointCloud}
|
||
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">
|
||
<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 h-7 px-2 py-0 inline-flex items-center gap-1 text-[10px] ${showPointCloud ? 'industrial-btn-primary' : ''}`}
|
||
onClick={() => {
|
||
if (pointCloudRenderMode !== 'points') {
|
||
setPointCloudRenderMode('points');
|
||
setHeatmapViewResultId(null);
|
||
setPluginOverlay(null);
|
||
setShowPointCloud(true);
|
||
return;
|
||
}
|
||
setShowPointCloud(v => !v);
|
||
}}
|
||
title={showPointCloud ? '隐藏点云' : '显示点云'}
|
||
>
|
||
<Cloud className="w-3.5 h-3.5" />
|
||
<span>点云</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
|
||
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)}
|
||
>
|
||
{showHeatmap ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
|
||
<span>热力图</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={2_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 className="w-80 border-l border-border bg-card/30 flex-shrink-0 overflow-y-auto p-3">
|
||
{renderRightPanel()}
|
||
</aside>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|