Compare commits

...

2 Commits

Author SHA1 Message Date
683e4d6c07 feat: streamline workspace controls 2026-07-08 23:18:08 +08:00
c1d4c1e4ce feat: improve plugin overlay workflow 2026-07-08 23:17:59 +08:00
10 changed files with 337 additions and 205 deletions

View File

@@ -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">

View File

@@ -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,36 +760,44 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
>
<Maximize2 className="w-3 h-3" />
</button>
<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' })}
disabled={running}
title="误差统计分析"
>
{running ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Play className="w-3 h-3" />}
{running ? '分析中…' : '误差分析'}
</button>
{onRunAssembly && (
<button
className={`industrial-btn py-0.5 text-[10px] flex items-center gap-0.5 ${externalPending ? 'opacity-60 cursor-wait' : ''}`}
onClick={onRunAssembly}
disabled={externalPending}
title="需车型下已上传 STEP;后台解析零件并做最近零件归属与误差统计"
>
<Layers className="w-3 h-3" />
装配检测
</button>
)}
{onRunDeviation && (
<button
className={`industrial-btn py-0.5 text-[10px] flex items-center gap-0.5 ${externalPending ? 'opacity-60 cursor-wait' : ''}`}
onClick={onRunDeviation}
disabled={externalPending}
title="在 STL/OBJ 网格上按顶点到最近点云点的距离着色"
>
<Crosshair 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' })}
disabled={running}
title="误差统计分析"
>
{running ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Play className="w-3 h-3" />}
{running ? '分析中…' : '误差分析'}
</button>
{onRunAssembly && (
<button
className={`industrial-btn py-0.5 text-[10px] flex items-center gap-0.5 ${externalPending ? 'opacity-60 cursor-wait' : ''}`}
onClick={onRunAssembly}
disabled={externalPending}
title="需车型下已上传 STEP;后台解析零件并做最近零件归属与误差统计"
>
<Layers className="w-3 h-3" />
装配检测
</button>
)}
{onRunDeviation && (
<button
className={`industrial-btn py-0.5 text-[10px] flex items-center gap-0.5 ${externalPending ? 'opacity-60 cursor-wait' : ''}`}
onClick={onRunDeviation}
disabled={externalPending}
title="在 STL/OBJ 网格上按顶点到最近点云点的距离着色"
>
<Crosshair className="w-3 h-3" />
偏差热力
</button>
)}
</>
)}
</div>
</div>

View File

@@ -1,15 +1,7 @@
import { useAuth } from "@/_core/hooks/useAuth";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarInset,
SidebarMenu,
@@ -21,7 +13,7 @@ import {
} from "@/components/ui/sidebar";
import { getLoginUrl } from "@/const";
import { useIsMobile } from "@/hooks/useMobile";
import { LayoutDashboard, LogOut, PanelLeft, Users } from "lucide-react";
import { LayoutDashboard, PanelLeft, Users } from "lucide-react";
import { CSSProperties, useEffect, useRef, useState } from "react";
import { useLocation } from "wouter";
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
@@ -106,7 +98,6 @@ function DashboardLayoutContent({
children,
setSidebarWidth,
}: DashboardLayoutContentProps) {
const { user, logout } = useAuth();
const [location, setLocation] = useLocation();
const { state, toggleSidebar } = useSidebar();
const isCollapsed = state === "collapsed";
@@ -201,36 +192,6 @@ function DashboardLayoutContent({
</SidebarMenu>
</SidebarContent>
<SidebarFooter className="p-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-3 rounded-lg px-1 py-1 hover:bg-accent/50 transition-colors w-full text-left group-data-[collapsible=icon]:justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<Avatar className="h-9 w-9 border shrink-0">
<AvatarFallback className="text-xs font-medium">
{user?.name?.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0 group-data-[collapsible=icon]:hidden">
<p className="text-sm font-medium truncate leading-none">
{user?.name || "-"}
</p>
<p className="text-xs text-muted-foreground truncate mt-1.5">
{user?.email || "-"}
</p>
</div>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
onClick={logout}
className="cursor-pointer text-destructive focus:text-destructive"
>
<LogOut className="mr-2 h-4 w-4" />
<span>Sign out</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarFooter>
</Sidebar>
<div
className={`absolute top-0 right-0 w-1 h-full cursor-col-resize hover:bg-primary/20 transition-colors ${isCollapsed ? "hidden" : ""}`}

View File

@@ -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 中查看叠加层

View File

@@ -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}
/>
<SmoothHeatmapMesh
data={pointCloudData}
showHeatmap={showHeatmap}
showWireframe={showWireframe}
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;
}
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}

View File

@@ -616,20 +616,31 @@ export default function TaskManager({
)}
</div>
<button
className="industrial-btn industrial-btn-primary w-full py-1 text-[10px] flex items-center justify-center gap-1"
disabled={activeTaskId == null || selectedModelPcFileIds.size === 0 || linkPcFilesFromModel.isPending}
onClick={() => {
if (activeTaskId == null || selectedModelPcFileIds.size === 0) return;
linkPcFilesFromModel.mutate({
taskId: activeTaskId,
modelPointcloudFileIds: Array.from(selectedModelPcFileIds),
});
}}
>
<Plus className="w-3 h-3" />
添加到当前任务{selectedModelPcFileIds.size > 0 ? ` (${selectedModelPcFileIds.size})` : ''}
</button>
<div className="flex items-center gap-1">
<button
className="industrial-btn industrial-btn-primary flex-1 min-w-0 py-1 text-[10px] flex items-center justify-center gap-1"
disabled={activeTaskId == null || selectedModelPcFileIds.size === 0 || linkPcFilesFromModel.isPending}
onClick={() => {
if (activeTaskId == null || selectedModelPcFileIds.size === 0) return;
linkPcFilesFromModel.mutate({
taskId: activeTaskId,
modelPointcloudFileIds: Array.from(selectedModelPcFileIds),
});
}}
>
<Plus className="w-3 h-3" />
添加到当前任务{selectedModelPcFileIds.size > 0 ? ` (${selectedModelPcFileIds.size})` : ''}
</button>
<button
type="button"
className="industrial-btn w-7 flex-shrink-0 px-0 py-1 text-[10px] flex items-center justify-center bg-red-900/55 border-red-500/60 text-red-100 hover:bg-red-800/70 hover:border-red-400"
title="关闭车型点云库"
aria-label="关闭车型点云库"
onClick={() => setPcLibraryOpen(false)}
>
<X className="w-3 h-3 flex-shrink-0" />
</button>
</div>
</div>
)}
@@ -848,20 +859,31 @@ export default function TaskManager({
)}
</div>
<button
className="industrial-btn industrial-btn-primary w-full py-1 text-[10px] flex items-center justify-center gap-1"
disabled={activeTaskId == null || selectedModelCadFileIds.size === 0 || linkCadFilesFromModel.isPending}
onClick={() => {
if (activeTaskId == null || selectedModelCadFileIds.size === 0) return;
linkCadFilesFromModel.mutate({
taskId: activeTaskId,
modelCadFileIds: Array.from(selectedModelCadFileIds),
});
}}
>
<Plus className="w-3 h-3" />
添加到当前任务{selectedModelCadFileIds.size > 0 ? ` (${selectedModelCadFileIds.size})` : ''}
</button>
<div className="flex items-center gap-1">
<button
className="industrial-btn industrial-btn-primary flex-1 min-w-0 py-1 text-[10px] flex items-center justify-center gap-1"
disabled={activeTaskId == null || selectedModelCadFileIds.size === 0 || linkCadFilesFromModel.isPending}
onClick={() => {
if (activeTaskId == null || selectedModelCadFileIds.size === 0) return;
linkCadFilesFromModel.mutate({
taskId: activeTaskId,
modelCadFileIds: Array.from(selectedModelCadFileIds),
});
}}
>
<Plus className="w-3 h-3" />
添加到当前任务{selectedModelCadFileIds.size > 0 ? ` (${selectedModelCadFileIds.size})` : ''}
</button>
<button
type="button"
className="industrial-btn w-7 flex-shrink-0 px-0 py-1 text-[10px] flex items-center justify-center bg-red-900/55 border-red-500/60 text-red-100 hover:bg-red-800/70 hover:border-red-400"
title="关闭车型 CAD 库"
aria-label="关闭车型 CAD 库"
onClick={() => setCadLibraryOpen(false)}
>
<X className="w-3 h-3 flex-shrink-0" />
</button>
</div>
</div>
)}

View File

@@ -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>
<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>
{(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 ? (
<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={() => {
setPointCloudRenderMode('points');
setHeatmapViewResultId(null);
setPluginOverlay(null);
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))}

View File

@@ -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,

View File

@@ -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:

View File

@@ -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[];
}