feat: improve plugin overlay workflow
This commit is contained in:
@@ -208,6 +208,7 @@ export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _
|
||||
const demoPluginIds = new Set(demoAllPluginIds);
|
||||
const runningDemoResults = analysisResults.filter((r) => (r.status === 'processing' || r.status === 'pending') && r.type === 'plugin' && r.pluginId && demoPluginIds.has(r.pluginId));
|
||||
const demoFlowRunning = runningDemoResults.length > 0;
|
||||
const demoFlowActive = demoFlowRunning || runDemoAll.isPending;
|
||||
|
||||
const selectedPcCount = selectedPcFileIds?.length ?? 0;
|
||||
const selectedCadCount = selectedCadFileIds?.length ?? 0;
|
||||
@@ -490,7 +491,7 @@ export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _
|
||||
</div>
|
||||
|
||||
{/* 未配准提示:有点云文件且选了测量算法,但所选点云均未配准 */}
|
||||
{selectedMeasIds.length > 0 && !hasRegisteredCloud && (taskPcFileCount ?? 0) > 0 && (
|
||||
{selectedMeasIds.length > 0 && !hasRegisteredCloud && (taskPcFileCount ?? 0) > 0 && !demoFlowActive && (
|
||||
<div className="flex items-start gap-1.5 bg-amber-900/20 border border-amber-700/40 rounded p-2">
|
||||
<AlertTriangle className="w-3 h-3 text-amber-400 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-[10px] text-amber-300">
|
||||
|
||||
@@ -55,7 +55,13 @@ interface Props {
|
||||
/** 外部分析任务是否正在提交中 */
|
||||
externalPending?: boolean;
|
||||
/** 插件叠加层:用户点击「在 3D 中查看叠加层」时回调 Home → PointCloudViewer */
|
||||
onViewPluginOverlay?: (args: { taskId: number; resultId: number; overlay: Overlay3D }) => void;
|
||||
onViewPluginOverlay?: (args: {
|
||||
taskId: number;
|
||||
resultId: number;
|
||||
overlay: Overlay3D;
|
||||
inputPointCloudFileIds?: number[];
|
||||
inputCadFileIds?: number[];
|
||||
}) => void;
|
||||
}
|
||||
|
||||
type Measurement = { name: string; value: number; unit: string; tolerance: number; status: string };
|
||||
@@ -226,6 +232,8 @@ type DemoFlowMeta = {
|
||||
total?: number;
|
||||
};
|
||||
|
||||
const SHOW_LEGACY_ANALYSIS_ACTIONS = false;
|
||||
|
||||
type AnalysisListItem =
|
||||
| { kind: 'result'; result: AnalysisResultRow }
|
||||
| { kind: 'demoFlow'; runId: string; results: AnalysisResultRow[] };
|
||||
@@ -752,6 +760,12 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
|
||||
>
|
||||
<Maximize2 className="w-3 h-3" />
|
||||
</button>
|
||||
{/* 产品策略:旧分析入口暂不在标题栏展示,保留实现便于后续恢复。
|
||||
误差分析当前后端仍是模拟统计结果,不作为正式入口展示。
|
||||
装配检测仍有 legacy 后台任务,但暂不作为主流程按钮展示。
|
||||
偏差热力依赖 STL/OBJ,不适合当前以 STEP 为主的流程,暂时隐藏。 */}
|
||||
{SHOW_LEGACY_ANALYSIS_ACTIONS && (
|
||||
<>
|
||||
<button
|
||||
className={`industrial-btn industrial-btn-primary py-0.5 text-[10px] flex items-center gap-0.5 ${running ? 'opacity-60 cursor-not-allowed' : ''}`}
|
||||
onClick={() => !running && runAnalysis.mutate({ taskId, type: 'error_analysis' })}
|
||||
@@ -783,6 +797,8 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
|
||||
偏差热力
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -38,7 +38,11 @@ interface ErrorStats {
|
||||
type Overlay3D =
|
||||
| { kind: 'none' }
|
||||
| { kind: 'vertexColors'; file: string; vertexCount: number; dispMin: number; dispMax: number }
|
||||
| { kind: 'ply'; file: string };
|
||||
| { kind: 'ply'; file: string; files?: string[] };
|
||||
|
||||
type RawOverlay3D =
|
||||
| Overlay3D
|
||||
| { kind: 'vertexColors'; file?: string; url?: string; vertexCount?: number; dispMin?: number; dispMax?: number };
|
||||
|
||||
interface PluginResultLike {
|
||||
pluginId?: string;
|
||||
@@ -46,7 +50,9 @@ interface PluginResultLike {
|
||||
message?: string;
|
||||
measurements?: Measurement[];
|
||||
errorStats?: ErrorStats | null;
|
||||
overlay3d?: Overlay3D;
|
||||
overlay3d?: RawOverlay3D;
|
||||
inputPointCloudFileIds?: unknown;
|
||||
inputCadFileIds?: unknown;
|
||||
images?: string[];
|
||||
/** outDir 下的报表产物(相对路径,如 report.xlsx),前端展示下载入口 */
|
||||
files?: (string | { name?: string; path?: string })[];
|
||||
@@ -61,7 +67,13 @@ interface Props {
|
||||
pluginName?: string;
|
||||
data: PluginResultLike;
|
||||
/** 用户点击「在 3D 中查看」时回调,把叠加层信息传给 Home → PointCloudViewer */
|
||||
onViewOverlay?: (overlay: { taskId: number; resultId: number; overlay: Overlay3D }) => void;
|
||||
onViewOverlay?: (overlay: {
|
||||
taskId: number;
|
||||
resultId: number;
|
||||
overlay: Overlay3D;
|
||||
inputPointCloudFileIds?: number[];
|
||||
inputCadFileIds?: number[];
|
||||
}) => void;
|
||||
}
|
||||
|
||||
function statusClass(s?: Measurement['status']) {
|
||||
@@ -97,6 +109,48 @@ function formatValue(v: number): string {
|
||||
return v.toFixed(3);
|
||||
}
|
||||
|
||||
function numberArrayFromUnknown(value: unknown): number[] | undefined {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
const ids = value.filter((id): id is number => typeof id === 'number' && Number.isInteger(id));
|
||||
return ids.length > 0 ? ids : undefined;
|
||||
}
|
||||
|
||||
function normalizeOverlay3D(overlay?: RawOverlay3D): Overlay3D {
|
||||
if (!overlay || overlay.kind === 'none') return { kind: 'none' };
|
||||
if (overlay.kind === 'ply') {
|
||||
const files = Array.isArray(overlay.files)
|
||||
? overlay.files.filter((f): f is string => typeof f === 'string' && f.length > 0)
|
||||
: undefined;
|
||||
return files && files.length > 0
|
||||
? { kind: 'ply', file: overlay.file, files }
|
||||
: { kind: 'ply', file: overlay.file };
|
||||
}
|
||||
|
||||
const legacyUrl = 'url' in overlay ? overlay.url : undefined;
|
||||
const file = typeof overlay.file === 'string' && overlay.file.length > 0
|
||||
? overlay.file
|
||||
: typeof legacyUrl === 'string' && legacyUrl.length > 0
|
||||
? legacyUrl
|
||||
: undefined;
|
||||
if (!file) return { kind: 'none' };
|
||||
|
||||
if (
|
||||
typeof overlay.vertexCount === 'number' &&
|
||||
typeof overlay.dispMin === 'number' &&
|
||||
typeof overlay.dispMax === 'number'
|
||||
) {
|
||||
return {
|
||||
kind: 'vertexColors',
|
||||
file,
|
||||
vertexCount: overlay.vertexCount,
|
||||
dispMin: overlay.dispMin,
|
||||
dispMax: overlay.dispMax,
|
||||
};
|
||||
}
|
||||
|
||||
return { kind: 'ply', file };
|
||||
}
|
||||
|
||||
export default function GenericResultView({
|
||||
taskId,
|
||||
resultId,
|
||||
@@ -112,7 +166,9 @@ export default function GenericResultView({
|
||||
const files = (data.files ?? []).map((f) =>
|
||||
typeof f === 'string' ? f : (f as { name?: string; path?: string }).name ?? String(f)
|
||||
);
|
||||
const overlay = data.overlay3d ?? { kind: 'none' };
|
||||
const overlay = normalizeOverlay3D(data.overlay3d);
|
||||
const inputPointCloudFileIds = numberArrayFromUnknown(data.inputPointCloudFileIds);
|
||||
const inputCadFileIds = numberArrayFromUnknown(data.inputCadFileIds);
|
||||
const displayPluginName = pluginName ?? pluginId ?? data.pluginId;
|
||||
|
||||
return (
|
||||
@@ -191,7 +247,7 @@ export default function GenericResultView({
|
||||
<button
|
||||
type="button"
|
||||
className="industrial-btn industrial-btn-primary py-1 text-[10px] flex items-center gap-1 w-full justify-center"
|
||||
onClick={() => onViewOverlay({ taskId, resultId, overlay })}
|
||||
onClick={() => onViewOverlay({ taskId, resultId, overlay, inputPointCloudFileIds, inputCadFileIds })}
|
||||
>
|
||||
<Box className="w-3 h-3" />
|
||||
在 3D 中查看叠加层
|
||||
|
||||
@@ -1023,7 +1023,7 @@ const CADModel = memo(function CADModel({
|
||||
}
|
||||
}, [useVertexColors, geometry]);
|
||||
|
||||
if (!visible && !useVertexColors) return null;
|
||||
if (!visible) return null;
|
||||
|
||||
if (!geometry && !loading) return null;
|
||||
|
||||
@@ -1178,6 +1178,7 @@ interface SceneContentProps {
|
||||
pointCloudData?: PointCloudData | null;
|
||||
pointSize: number;
|
||||
showHeatmap: boolean;
|
||||
showPointCloud: boolean;
|
||||
pointCloudRenderMode: PointCloudRenderMode;
|
||||
enablePointPicking: boolean;
|
||||
showCADModel: boolean;
|
||||
@@ -1207,6 +1208,7 @@ const SceneContent = memo(function SceneContent({
|
||||
pointCloudData,
|
||||
pointSize,
|
||||
showHeatmap,
|
||||
showPointCloud,
|
||||
pointCloudRenderMode,
|
||||
enablePointPicking,
|
||||
showCADModel,
|
||||
@@ -1253,9 +1255,15 @@ const SceneContent = memo(function SceneContent({
|
||||
const cadShellHeatmapOn =
|
||||
pointCloudRenderMode === 'smoothHeatmap' &&
|
||||
Boolean(pointCloudData && cadSurfaceGeometry) &&
|
||||
showCADModel &&
|
||||
!cadHeatmapSuppressClient &&
|
||||
!useDirectColoring;
|
||||
const showCadReferenceMesh = (showCADModel && !cadShellHeatmapOn) || useDirectColoring;
|
||||
const showCadReferenceMesh = showCADModel && !cadShellHeatmapOn;
|
||||
const showSmoothPointFallback =
|
||||
pointCloudData &&
|
||||
showPointCloud &&
|
||||
pointCloudRenderMode === 'smoothHeatmap' &&
|
||||
(!cadSurfaceGeometry || cadHeatmapSuppressClient || !showCADModel);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -1292,7 +1300,7 @@ const SceneContent = memo(function SceneContent({
|
||||
{!lightweightScene && <AmbientParticles />}
|
||||
|
||||
{/* Point cloud - 只有当有数据时才渲染,完全禁用事件以提高性能 */}
|
||||
{pointCloudData && pointCloudRenderMode === 'points' && (
|
||||
{pointCloudData && showPointCloud && pointCloudRenderMode === 'points' && (
|
||||
<PointCloudMesh
|
||||
data={pointCloudData}
|
||||
pointSize={pointSize}
|
||||
@@ -1305,6 +1313,7 @@ const SceneContent = memo(function SceneContent({
|
||||
{pointCloudData &&
|
||||
pointCloudRenderMode === 'smoothHeatmap' &&
|
||||
cadSurfaceGeometry &&
|
||||
showCADModel &&
|
||||
!cadHeatmapSuppressClient &&
|
||||
!useDirectColoring && (
|
||||
<CadSurfaceHeatmapMesh
|
||||
@@ -1314,34 +1323,23 @@ const SceneContent = memo(function SceneContent({
|
||||
showWireframe={showWireframe}
|
||||
/>
|
||||
)}
|
||||
{pointCloudData &&
|
||||
pointCloudRenderMode === 'smoothHeatmap' &&
|
||||
cadSurfaceGeometry &&
|
||||
cadHeatmapSuppressClient && (
|
||||
<PointCloudMesh
|
||||
data={pointCloudData}
|
||||
pointSize={Math.min(pointSize * 2.8, 0.12)}
|
||||
showHeatmap={showHeatmap}
|
||||
enablePointPicking={false}
|
||||
errorRangeMode="percentile"
|
||||
pointOpacity={showHeatmap ? 0.38 : 0.55}
|
||||
/>
|
||||
)}
|
||||
{pointCloudData && pointCloudRenderMode === 'smoothHeatmap' && !cadSurfaceGeometry && (
|
||||
{showSmoothPointFallback && (
|
||||
<>
|
||||
<PointCloudMesh
|
||||
data={pointCloudData}
|
||||
pointSize={Math.min(pointSize * 3.2, 0.14)}
|
||||
pointSize={Math.min(pointSize * (cadSurfaceGeometry ? 2.8 : 3.2), cadSurfaceGeometry ? 0.12 : 0.14)}
|
||||
showHeatmap={showHeatmap}
|
||||
enablePointPicking={false}
|
||||
errorRangeMode="percentile"
|
||||
pointOpacity={showHeatmap ? 0.42 : 0.6}
|
||||
pointOpacity={showHeatmap ? (cadSurfaceGeometry ? 0.38 : 0.42) : (cadSurfaceGeometry ? 0.55 : 0.6)}
|
||||
/>
|
||||
{!cadSurfaceGeometry && (
|
||||
<SmoothHeatmapMesh
|
||||
data={pointCloudData}
|
||||
showHeatmap={showHeatmap}
|
||||
showWireframe={showWireframe}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1408,6 +1406,7 @@ const SceneContent = memo(function SceneContent({
|
||||
return prevProps.pointCloudData === nextProps.pointCloudData &&
|
||||
prevProps.pointSize === nextProps.pointSize &&
|
||||
prevProps.showHeatmap === nextProps.showHeatmap &&
|
||||
prevProps.showPointCloud === nextProps.showPointCloud &&
|
||||
prevProps.pointCloudRenderMode === nextProps.pointCloudRenderMode &&
|
||||
prevProps.enablePointPicking === nextProps.enablePointPicking &&
|
||||
prevProps.showCADModel === nextProps.showCADModel &&
|
||||
@@ -1578,6 +1577,7 @@ interface PointCloudViewerProps {
|
||||
*/
|
||||
loadKey?: number;
|
||||
showHeatmap?: boolean;
|
||||
showPointCloud?: boolean;
|
||||
showCADModel?: boolean;
|
||||
showWireframe?: boolean;
|
||||
pointSize?: number;
|
||||
@@ -1614,7 +1614,7 @@ interface PointCloudViewerProps {
|
||||
overlay:
|
||||
| { kind: 'none' }
|
||||
| { kind: 'vertexColors'; file: string; vertexCount: number; dispMin: number; dispMax: number }
|
||||
| { kind: 'ply'; file: string };
|
||||
| { kind: 'ply'; file: string; files?: string[] };
|
||||
} | null;
|
||||
/**
|
||||
* ROI 坐标拾取模式。激活时,用户在 3D 视图中点击任意点,
|
||||
@@ -1630,6 +1630,7 @@ export default function PointCloudViewer({
|
||||
batchTaskId,
|
||||
loadKey,
|
||||
showHeatmap = true,
|
||||
showPointCloud = true,
|
||||
showCADModel = true,
|
||||
showWireframe = false,
|
||||
pointSize = 0.03,
|
||||
@@ -1769,6 +1770,7 @@ export default function PointCloudViewer({
|
||||
|
||||
// PLY 叠加层状态(kind=ply)
|
||||
const [plyOverlayData, setPlyOverlayData] = useState<{ positions: Float32Array; colors: Float32Array } | null>(null);
|
||||
const [plyOverlayStatus, setPlyOverlayStatus] = useState<{ status: 'idle' | 'loading' | 'ready' | 'error'; message?: string }>({ status: 'idle' });
|
||||
// 主点云的坐标变换参数,供 ply 叠加层对齐用
|
||||
const pcTransformRef = useRef<{ cx: number; cy: number; cz: number; scale: number } | null>(null);
|
||||
|
||||
@@ -1776,16 +1778,28 @@ export default function PointCloudViewer({
|
||||
useEffect(() => {
|
||||
if (pluginOverlay == null || pluginOverlay.overlay.kind !== 'ply') {
|
||||
setPlyOverlayData(null);
|
||||
setPlyOverlayStatus({ status: 'idle' });
|
||||
return;
|
||||
}
|
||||
const t = pcTransformRef.current;
|
||||
if (!t || !pointCloudData?.points?.length) {
|
||||
setPlyOverlayData(null);
|
||||
setPlyOverlayStatus({
|
||||
status: 'error',
|
||||
message: '请先加载本算法结果对应的点云文件,再查看 PLY 叠加层',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const { taskId, resultId, overlay } = pluginOverlay;
|
||||
const ac = new AbortController();
|
||||
setPlyOverlayStatus({ status: 'loading', message: '正在加载 PLY 叠加层' });
|
||||
(async () => {
|
||||
try {
|
||||
// 用 /sample/batch 接口按 filePath 采样,最多 30 万点
|
||||
// 支持多文件(各面板 PLY),合并采样
|
||||
const plyFiles: string[] = (overlay as { files?: string[]; file: string }).files
|
||||
?? [overlay.file];
|
||||
const plyFiles: string[] = Array.isArray(overlay.files) && overlay.files.length > 0
|
||||
? overlay.files
|
||||
: [overlay.file];
|
||||
const resp = await fetch('/api/files/sample/batch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -1801,7 +1815,10 @@ export default function PointCloudViewer({
|
||||
const buf = await resp.arrayBuffer();
|
||||
const floats = new Float32Array(buf);
|
||||
const count = Math.floor(floats.length / stride);
|
||||
if (count === 0) return;
|
||||
if (count === 0) {
|
||||
setPlyOverlayStatus({ status: 'error', message: 'PLY 叠加层没有可渲染点' });
|
||||
return;
|
||||
}
|
||||
const positions = new Float32Array(count * 3);
|
||||
const colors = new Float32Array(count * 3);
|
||||
if (hasColor) {
|
||||
@@ -1826,23 +1843,23 @@ export default function PointCloudViewer({
|
||||
}
|
||||
}
|
||||
// 用主点云的坐标变换对齐叠加层坐标
|
||||
const t = pcTransformRef.current;
|
||||
if (t) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
positions[i * 3] = (positions[i * 3] - t.cx) * t.scale;
|
||||
positions[i * 3 + 1] = (positions[i * 3 + 1] - t.cy) * t.scale;
|
||||
positions[i * 3 + 2] = (positions[i * 3 + 2] - t.cz) * t.scale;
|
||||
}
|
||||
}
|
||||
setPlyOverlayData({ positions, colors });
|
||||
setPlyOverlayStatus({ status: 'ready' });
|
||||
console.log(`[PlyOverlay] loaded ${count} pts, hasColor=${hasColor}, transform=${JSON.stringify(t)}`);
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === 'AbortError') return;
|
||||
console.warn('[PlyOverlay] load failed:', e);
|
||||
setPlyOverlayData(null);
|
||||
setPlyOverlayStatus({ status: 'error', message: '加载 PLY 叠加层失败' });
|
||||
}
|
||||
})();
|
||||
return () => ac.abort();
|
||||
}, [pluginOverlay]);
|
||||
}, [pluginOverlay, pointCloudData]);
|
||||
|
||||
useEffect(() => {
|
||||
// 当插件叠加层激活时,跳过 legacy heatmap effect,避免与下面那个 effect 抢 ref
|
||||
@@ -1947,16 +1964,21 @@ export default function PointCloudViewer({
|
||||
|
||||
const heatmapLegendBounds = useMemo(() => {
|
||||
if (!showHeatmap) return null;
|
||||
const hasVisibleCadHeatmap =
|
||||
pointCloudRenderMode === 'smoothHeatmap' &&
|
||||
serverCadHeatmap.status === 'ready' &&
|
||||
showCADModel;
|
||||
const pts = pointCloudData?.points;
|
||||
if (!pts?.length) return null;
|
||||
if (!hasVisibleCadHeatmap && (!showPointCloud || !pts?.length)) return null;
|
||||
if (pointCloudRenderMode === 'smoothHeatmap' && serverCadHeatmap.status === 'ready') {
|
||||
const lo = serverCadHeatmap.dispMin;
|
||||
const hi = serverCadHeatmap.dispMax;
|
||||
const midTick = lo < 0 && hi > 0 ? '0' : ((lo + hi) / 2).toFixed(3);
|
||||
return { min: lo, max: hi, midTick };
|
||||
}
|
||||
if (!pts?.length) return null;
|
||||
return getHeatmapLegendBounds(pts, pointCloudRenderMode);
|
||||
}, [showHeatmap, pointCloudData, pointCloudRenderMode, serverCadHeatmap]);
|
||||
}, [showHeatmap, showPointCloud, showCADModel, pointCloudData, pointCloudRenderMode, serverCadHeatmap]);
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`} style={{ background: 'oklch(0.08 0.006 240)' }}>
|
||||
@@ -2010,6 +2032,29 @@ export default function PointCloudViewer({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pluginOverlay?.overlay.kind === 'ply' && plyOverlayStatus.status !== 'idle' && plyOverlayStatus.status !== 'ready' && (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center pointer-events-none">
|
||||
<div className="rounded-lg border border-border/60 bg-[oklch(0.1_0.01_240)]/90 px-5 py-3 shadow-lg backdrop-blur-sm flex items-center gap-3 max-w-sm">
|
||||
{plyOverlayStatus.status === 'loading' && (
|
||||
<svg className="animate-spin h-5 w-5 text-primary" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
)}
|
||||
<div className="text-sm">
|
||||
<div className="text-foreground/90 font-medium">
|
||||
{plyOverlayStatus.status === 'error' ? 'PLY 叠加层不可用' : '正在加载 PLY 叠加层…'}
|
||||
</div>
|
||||
{plyOverlayStatus.message && (
|
||||
<div className="text-[10px] text-muted-foreground mt-0.5">
|
||||
{plyOverlayStatus.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 热力图点击拾取信息 */}
|
||||
{heatmapPick && (
|
||||
<div className="absolute left-3 bottom-14 z-20 rounded-lg border border-border/60 bg-[oklch(0.1_0.01_240)]/90 px-4 py-2.5 shadow-lg backdrop-blur-sm max-w-[220px]">
|
||||
@@ -2055,6 +2100,7 @@ export default function PointCloudViewer({
|
||||
pointCloudData={pointCloudData}
|
||||
pointSize={pointSize}
|
||||
showHeatmap={showHeatmap}
|
||||
showPointCloud={showPointCloud}
|
||||
pointCloudRenderMode={pointCloudRenderMode}
|
||||
enablePointPicking={enableErrorPicking}
|
||||
showCADModel={showCADModel}
|
||||
|
||||
@@ -10,20 +10,19 @@ import type { Overlay3D } from '@/components/GenericResultView';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import {
|
||||
LogIn,
|
||||
LogOut,
|
||||
Box,
|
||||
Cloud,
|
||||
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 { loading, isAuthenticated } = useAuth();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
// ── 树导航选中状态 ─────────────────────────────────────────────────────────
|
||||
@@ -69,7 +68,8 @@ export default function Home() {
|
||||
|
||||
// ── 视图控制 ───────────────────────────────────────────────────────────────
|
||||
const [showHeatmap, setShowHeatmap] = useState(true);
|
||||
const [showCADModel, setShowCADModel] = useState(true);
|
||||
const [showPointCloud, setShowPointCloud] = useState(true);
|
||||
const [showCADModel] = useState(true);
|
||||
const [showWireframe, setShowWireframe] = useState(false);
|
||||
const [pointSize, setPointSize] = useState(0.03);
|
||||
/** 点云预览采样预算(非全量);调低可明显减轻卡顿 */
|
||||
@@ -88,7 +88,7 @@ export default function Home() {
|
||||
resultId: number;
|
||||
overlay: Overlay3D;
|
||||
} | null>(null);
|
||||
const [errorPickEnabled, setErrorPickEnabled] = useState(false);
|
||||
const [errorPickEnabled] = useState(false);
|
||||
const [showLeftPanel, setShowLeftPanel] = useState(true);
|
||||
const [showRightPanel, setShowRightPanel] = useState(true);
|
||||
|
||||
@@ -307,12 +307,47 @@ export default function Home() {
|
||||
setHeatmapViewResultId(resultId);
|
||||
setPointCloudRenderMode('smoothHeatmap');
|
||||
}}
|
||||
onViewPluginOverlay={({ taskId, resultId, overlay }) => {
|
||||
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 });
|
||||
if (overlay.kind === 'vertexColors') {
|
||||
setPointCloudRenderMode('smoothHeatmap');
|
||||
}
|
||||
setPointCloudRenderMode(overlay.kind === 'vertexColors' ? 'smoothHeatmap' : 'points');
|
||||
}}
|
||||
externalPending={runAnalysisJob.isPending}
|
||||
onRunAssembly={() => {
|
||||
@@ -374,33 +409,22 @@ export default function Home() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{(pcLoading || cadLoading) && (
|
||||
<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="status-led status-led-processing" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{pcLoading ? `加载点云 ${pcProgress}%` :
|
||||
cadLoading ? `加载 CAD ${cadLoadProgress}%` :
|
||||
'待机'}
|
||||
{pcLoading ? `加载点云 ${pcProgress}%` : `加载 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>
|
||||
) : (
|
||||
) : !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>
|
||||
|
||||
@@ -439,6 +463,7 @@ export default function Home() {
|
||||
pointCloudUrls={pointCloudUrlsStable}
|
||||
batchTaskId={batchTaskId ?? undefined}
|
||||
loadKey={pcLoadKey}
|
||||
showPointCloud={showPointCloud}
|
||||
selectedPcFileIds={selectedPcFileIds.length > 0 ? selectedPcFileIds : undefined}
|
||||
onCadProgress={handleCadProgress}
|
||||
onCadLoaded={handleCadLoaded}
|
||||
@@ -474,13 +499,9 @@ export default function Home() {
|
||||
|
||||
{/* 操作提示 */}
|
||||
<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></>
|
||||
}
|
||||
<span>左键旋转</span>
|
||||
<span>右键平移</span>
|
||||
<span>滚轮缩放</span>
|
||||
</div>
|
||||
|
||||
{/* 选中点信息 */}
|
||||
@@ -522,15 +543,21 @@ export default function Home() {
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={`industrial-btn py-1 ${pointCloudRenderMode === 'points' ? 'industrial-btn-primary' : ''}`}
|
||||
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="逐点显示(支持误差拾取)"
|
||||
title={showPointCloud ? '隐藏点云' : '显示点云'}
|
||||
>
|
||||
<span className="text-[10px]">点云</span>
|
||||
<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">
|
||||
@@ -538,26 +565,12 @@ export default function Home() {
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
className={`industrial-btn py-1 ${showHeatmap ? 'industrial-btn-primary' : ''}`}
|
||||
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 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>
|
||||
<span>热力图</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -584,7 +597,7 @@ export default function Home() {
|
||||
<input
|
||||
type="range"
|
||||
min={100_000}
|
||||
max={5_000_000}
|
||||
max={2_000_000}
|
||||
step={100_000}
|
||||
value={renderPointBudget}
|
||||
onChange={e => setRenderPointBudget(Number(e.target.value))}
|
||||
|
||||
@@ -122,6 +122,8 @@ async function writeInputJson(
|
||||
params: Record<string, unknown>,
|
||||
pointCloudPaths: string[],
|
||||
cadPath: string | null,
|
||||
inputPointCloudFileIds: number[],
|
||||
inputCadFileIds: number[],
|
||||
) {
|
||||
const inputJson = {
|
||||
schemaVersion: 1,
|
||||
@@ -133,7 +135,9 @@ async function writeInputJson(
|
||||
outDir: workDir,
|
||||
inputs: {
|
||||
pointCloudPaths,
|
||||
pointCloudFileIds: inputPointCloudFileIds,
|
||||
cadPath,
|
||||
cadFileIds: inputCadFileIds,
|
||||
config: params,
|
||||
},
|
||||
};
|
||||
@@ -331,6 +335,9 @@ export async function runAlgorithmJob(
|
||||
pointCloudFileIds,
|
||||
cadFileIds,
|
||||
);
|
||||
const inputPointCloudFileIds = pointCloudRecords.map((r) => r.id);
|
||||
const inputCadFileIds = Array.isArray(cadFileIds) ? cadFileIds.filter((id) => Number.isInteger(id)) : [];
|
||||
|
||||
const inputJsonPath = await writeInputJson(
|
||||
workDir,
|
||||
taskId,
|
||||
@@ -339,6 +346,8 @@ export async function runAlgorithmJob(
|
||||
params,
|
||||
pointCloudPaths,
|
||||
cadPath,
|
||||
inputPointCloudFileIds,
|
||||
inputCadFileIds,
|
||||
);
|
||||
|
||||
let exitCode: number | null = 0;
|
||||
@@ -363,6 +372,8 @@ export async function runAlgorithmJob(
|
||||
progress: finalStatus === 'completed' ? 1 : undefined,
|
||||
stage: finalStatus === 'completed' ? '完成' : '失败',
|
||||
exitCode,
|
||||
inputPointCloudFileIds,
|
||||
inputCadFileIds,
|
||||
// 把 result.json 内容平展到 resultData 里,方便前端直接使用
|
||||
schemaVersion: result?.schemaVersion ?? manifest.schemaVersion,
|
||||
message: result?.message,
|
||||
|
||||
@@ -394,7 +394,7 @@ def main() -> int:
|
||||
overlay3d: dict = {"kind": "none"}
|
||||
for ply_key in ("contourPly", "curvaturePly"):
|
||||
if ply_key in extra:
|
||||
overlay3d = {"kind": "vertexColors", "url": extra[ply_key]}
|
||||
overlay3d = {"kind": "ply", "file": extra[ply_key]}
|
||||
break
|
||||
|
||||
if skipped:
|
||||
|
||||
@@ -141,6 +141,8 @@ export type PluginOverlay3D =
|
||||
/** 着色 PLY 叠加文件(同样相对 outDir) */
|
||||
kind: 'ply';
|
||||
file: string;
|
||||
/** 批量结果可包含多个 PLY 叠加文件 */
|
||||
files?: string[];
|
||||
};
|
||||
|
||||
/** 算法写出的 result.json(与 INTEGRATION.md 描述对齐) */
|
||||
@@ -177,4 +179,8 @@ export interface PluginResultData extends Partial<PluginResult> {
|
||||
error?: string;
|
||||
/** 子进程已结束时的退出码 */
|
||||
exitCode?: number | null;
|
||||
/** 本次算法运行实际使用的任务点云文件 ID,用于前端叠加层匹配校验 */
|
||||
inputPointCloudFileIds?: number[];
|
||||
/** 本次算法运行前端选择的任务 CAD 文件 ID,用于后续匹配/追溯 */
|
||||
inputCadFileIds?: number[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user