681 lines
31 KiB
TypeScript
681 lines
31 KiB
TypeScript
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>
|
||
);
|
||
}
|