feat: initial commit - massive-cloud point cloud processing system
This commit is contained in:
483
client/src/components/AnalysisPanel.tsx
Normal file
483
client/src/components/AnalysisPanel.tsx
Normal file
@@ -0,0 +1,483 @@
|
||||
import { useState } from 'react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { BarChart2, Ruler, Zap, Play, Trash2, ChevronDown, ChevronRight, RefreshCw, Flame, Box, Layers, Crosshair, Cpu } from 'lucide-react';
|
||||
import GenericResultView, { type Overlay3D } from './GenericResultView';
|
||||
|
||||
interface Props {
|
||||
taskId: number;
|
||||
onReplay?: (resultId: number) => void;
|
||||
/** 在 3D 视口显示该条平滑热力分析结果 */
|
||||
onViewSmoothHeatmap?: (resultId: number) => void;
|
||||
/** 触发装配检测(来自 Home.tsx 的 runAnalysisJob) */
|
||||
onRunAssembly?: () => void;
|
||||
/** 触发偏差热力(来自 Home.tsx 的 runAnalysisJob) */
|
||||
onRunDeviation?: () => void;
|
||||
/** 外部分析任务是否正在提交中 */
|
||||
externalPending?: boolean;
|
||||
/** 插件叠加层:用户点击「在 3D 中查看叠加层」时回调 Home → PointCloudViewer */
|
||||
onViewPluginOverlay?: (args: { taskId: number; resultId: number; overlay: Overlay3D }) => void;
|
||||
}
|
||||
|
||||
type Measurement = { name: string; value: number; unit: string; tolerance: number; status: string };
|
||||
type ErrorStats = { min: number; max: number; mean: number; stdDev: number; totalPoints: number; withinTolerance: number; outOfTolerance: number };
|
||||
|
||||
function ErrorStatCard({ stats }: { stats: ErrorStats }) {
|
||||
const pct = stats.totalPoints > 0
|
||||
? ((stats.withinTolerance / stats.totalPoints) * 100).toFixed(1)
|
||||
: '0';
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{[
|
||||
{ label: '最小误差', value: stats.min.toFixed(4) + ' mm' },
|
||||
{ label: '最大误差', value: stats.max.toFixed(4) + ' mm' },
|
||||
{ label: '平均误差', value: stats.mean.toFixed(4) + ' mm' },
|
||||
{ label: '标准差', value: stats.stdDev.toFixed(4) + ' mm' },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label} className="industrial-card p-2">
|
||||
<div className="text-[10px] text-muted-foreground">{label}</div>
|
||||
<div className="text-xs font-mono mt-0.5">{value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="industrial-card p-2">
|
||||
<div className="flex items-center justify-between text-[10px] mb-1">
|
||||
<span className="text-muted-foreground">合格率</span>
|
||||
<span className={parseFloat(pct) >= 95 ? 'text-green-400' : parseFloat(pct) >= 85 ? 'text-amber-400' : 'text-red-400'}>
|
||||
{pct}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="industrial-progress">
|
||||
<div
|
||||
className="industrial-progress-bar"
|
||||
style={{ width: `${pct}%`, background: parseFloat(pct) >= 95 ? 'oklch(0.62 0.16 145)' : parseFloat(pct) >= 85 ? 'oklch(0.72 0.16 85)' : 'oklch(0.55 0.20 28)' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] text-muted-foreground mt-1">
|
||||
<span>合格 {stats.withinTolerance.toLocaleString()}</span>
|
||||
<span>超差 {stats.outOfTolerance.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MeasurementTable({ items }: { items: Measurement[] }) {
|
||||
return (
|
||||
<table className="w-full text-[10px] border-collapse">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground">
|
||||
<th className="text-left py-1 font-normal">项目</th>
|
||||
<th className="text-right py-1 font-normal">值</th>
|
||||
<th className="text-right py-1 font-normal">公差</th>
|
||||
<th className="text-right py-1 font-normal">结果</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((m, i) => (
|
||||
<tr key={i} className="border-t border-border/40">
|
||||
<td className="py-1">{m.name}</td>
|
||||
<td className="text-right font-mono">{m.value.toFixed(3)} {m.unit}</td>
|
||||
<td className="text-right text-muted-foreground">±{m.tolerance}</td>
|
||||
<td className="text-right">
|
||||
<span className={`px-1 py-0.5 rounded text-[9px] ${
|
||||
m.status === 'pass' ? 'bg-green-900/40 text-green-400' :
|
||||
m.status === 'warning' ? 'bg-amber-900/40 text-amber-400' :
|
||||
'bg-red-900/40 text-red-400'
|
||||
}`}>
|
||||
{m.status === 'pass' ? '合格' : m.status === 'warning' ? '警告' : '超差'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
type SmoothHeatmapMeta = {
|
||||
kind?: string;
|
||||
/** 0–1,processing 时子进程写入 */
|
||||
progress?: number;
|
||||
stage?: string;
|
||||
vertexCount?: number;
|
||||
dispMin?: number;
|
||||
dispMax?: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type AssemblyPartRow = {
|
||||
partId: string;
|
||||
name: string;
|
||||
min: number;
|
||||
max: number;
|
||||
mean: number;
|
||||
stdDev: number;
|
||||
passRate: number;
|
||||
pointCount: number;
|
||||
vertexHeatmapBin?: string;
|
||||
};
|
||||
|
||||
type AssemblyMeta = SmoothHeatmapMeta & {
|
||||
toleranceMm?: number;
|
||||
samplePoints?: number;
|
||||
parts?: AssemblyPartRow[];
|
||||
};
|
||||
|
||||
export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, onRunAssembly, onRunDeviation, externalPending, onViewPluginOverlay }: Props) {
|
||||
const utils = trpc.useUtils();
|
||||
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
|
||||
|
||||
const { data: results = [], isLoading } = trpc.analysisResult.list.useQuery(
|
||||
{ taskId },
|
||||
{ refetchInterval: 3000 },
|
||||
);
|
||||
const runAnalysis = trpc.analysisResult.runAnalysis.useMutation({
|
||||
onSuccess: () => utils.analysisResult.list.invalidate({ taskId }),
|
||||
});
|
||||
const deleteResult = trpc.analysisResult.delete.useMutation({
|
||||
onSuccess: () => utils.analysisResult.list.invalidate({ taskId }),
|
||||
});
|
||||
|
||||
function toggleExpand(id: number) {
|
||||
setExpandedIds(prev => {
|
||||
const n = new Set(prev);
|
||||
n.has(id) ? n.delete(id) : n.add(id);
|
||||
return n;
|
||||
});
|
||||
}
|
||||
|
||||
const running = runAnalysis.isPending;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-1 flex-wrap">
|
||||
<span className="industrial-label flex items-center gap-1.5">
|
||||
<BarChart2 className="w-3.5 h-3.5" /> 分析结果
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<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>
|
||||
|
||||
{isLoading && (
|
||||
<div className="text-xs text-muted-foreground text-center py-4">加载中…</div>
|
||||
)}
|
||||
|
||||
{!isLoading && results.length === 0 && (
|
||||
<div className="text-xs text-muted-foreground text-center py-4">
|
||||
暂无分析结果,点击运行分析
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{results.map(r => {
|
||||
const expanded = expandedIds.has(r.id);
|
||||
const errorStats = r.errorStats as ErrorStats | null;
|
||||
const measurements = r.measurements as Measurement[] | null;
|
||||
|
||||
return (
|
||||
<div key={r.id} className="industrial-card rounded overflow-hidden">
|
||||
{/* 标题行 */}
|
||||
<div
|
||||
className="flex items-center gap-2 px-2 py-1.5 cursor-pointer hover:bg-secondary/40 group"
|
||||
onClick={() => toggleExpand(r.id)}
|
||||
>
|
||||
{expanded ? <ChevronDown className="w-3 h-3 text-muted-foreground" /> : <ChevronRight className="w-3 h-3 text-muted-foreground" />}
|
||||
{r.type === 'error_analysis' && <BarChart2 className="w-3.5 h-3.5 text-blue-400 flex-shrink-0" />}
|
||||
{r.type === 'measurement' && <Ruler className="w-3.5 h-3.5 text-green-400 flex-shrink-0" />}
|
||||
{r.type === 'compression' && <Zap className="w-3.5 h-3.5 text-amber-400 flex-shrink-0" />}
|
||||
{r.type === 'smooth_heatmap' && <Flame className="w-3.5 h-3.5 text-orange-400 flex-shrink-0" />}
|
||||
{r.type === 'assembly_inspection' && <Layers className="w-3.5 h-3.5 text-cyan-400 flex-shrink-0" />}
|
||||
{r.type === 'obj_deviation' && <Crosshair className="w-3.5 h-3.5 text-rose-400 flex-shrink-0" />}
|
||||
{r.type === 'plugin' && <Cpu className="w-3.5 h-3.5 text-violet-400 flex-shrink-0" />}
|
||||
<span className="text-xs flex-1 truncate">
|
||||
{r.type === 'error_analysis'
|
||||
? '误差分析'
|
||||
: r.type === 'measurement'
|
||||
? '尺寸测量'
|
||||
: r.type === 'smooth_heatmap'
|
||||
? '平滑热力'
|
||||
: r.type === 'assembly_inspection'
|
||||
? '装配检测'
|
||||
: r.type === 'obj_deviation'
|
||||
? '偏差热力'
|
||||
: r.type === 'plugin'
|
||||
? `算法 · ${(r as { pluginId?: string }).pluginId ?? '插件'}`
|
||||
: '压缩'}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{new Date(r.createdAt).toLocaleDateString('zh-CN')}
|
||||
</span>
|
||||
<span className={`text-[9px] px-1.5 py-0.5 rounded flex-shrink-0 ${
|
||||
r.status === 'completed' ? 'bg-green-900/40 text-green-400' :
|
||||
r.status === 'processing' ? 'bg-blue-900/40 text-blue-400 animate-pulse' :
|
||||
r.status === 'error' ? 'bg-red-900/40 text-red-400' :
|
||||
'bg-secondary text-muted-foreground'
|
||||
}`}>{r.status}</span>
|
||||
{r.status === 'processing' &&
|
||||
(r.type === 'smooth_heatmap' || r.type === 'assembly_inspection' || r.type === 'obj_deviation' || r.type === 'plugin') &&
|
||||
(() => {
|
||||
const hm = r.resultData as SmoothHeatmapMeta | null;
|
||||
const pct = hm?.progress != null ? Math.round(hm.progress * 100) : null;
|
||||
return pct != null ? (
|
||||
<span className="text-[9px] font-mono text-primary tabular-nums">{pct}%</span>
|
||||
) : null;
|
||||
})()}
|
||||
|
||||
<span className="hidden group-hover:flex items-center gap-0.5">
|
||||
{onReplay && r.status === 'completed' && (
|
||||
<button
|
||||
className="p-0.5 rounded hover:bg-secondary"
|
||||
title="回放此结果"
|
||||
onClick={e => { e.stopPropagation(); onReplay(r.id); }}
|
||||
>
|
||||
<Play className="w-3 h-3 text-primary" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="p-0.5 rounded hover:bg-secondary text-destructive"
|
||||
title="删除"
|
||||
onClick={e => { e.stopPropagation(); deleteResult.mutate({ id: r.id }); }}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 展开内容 */}
|
||||
{expanded && r.status === 'completed' && r.type !== 'smooth_heatmap' && r.type !== 'assembly_inspection' && r.type !== 'obj_deviation' && r.type !== 'plugin' && (
|
||||
<div className="px-3 pb-3 pt-1 border-t border-border/40 space-y-3">
|
||||
{errorStats && <ErrorStatCard stats={errorStats} />}
|
||||
{measurements && measurements.length > 0 && (
|
||||
<div>
|
||||
<div className="text-[10px] text-muted-foreground mb-1">尺寸测量</div>
|
||||
<MeasurementTable items={measurements} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expanded &&
|
||||
(r.type === 'smooth_heatmap' || r.type === 'assembly_inspection' || r.type === 'obj_deviation' || r.type === 'plugin') &&
|
||||
r.status === 'processing' &&
|
||||
(() => {
|
||||
const hm = r.resultData as SmoothHeatmapMeta | null;
|
||||
const p = hm?.progress ?? 0;
|
||||
const stage = hm?.stage ?? '排队或启动中…';
|
||||
return (
|
||||
<div className="px-3 pb-3 pt-1 border-t border-border/40 space-y-2">
|
||||
<div className="text-[10px] text-muted-foreground">{stage}</div>
|
||||
<div className="industrial-progress h-1.5">
|
||||
<div
|
||||
className="industrial-progress-bar transition-[width] duration-300"
|
||||
style={{
|
||||
width: `${Math.min(100, Math.max(0, p * 100))}%`,
|
||||
background: 'oklch(0.55 0.14 250)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[9px] text-muted-foreground/80">
|
||||
后台子进程执行,页面可继续操作;进度约每 1.2s 刷新。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{expanded && r.type === 'smooth_heatmap' && r.status === 'completed' && (
|
||||
<div className="px-3 pb-3 pt-1 border-t border-border/40 space-y-2">
|
||||
{(() => {
|
||||
const hm = r.resultData as SmoothHeatmapMeta | null;
|
||||
return (
|
||||
<>
|
||||
<div className="text-[10px] text-muted-foreground space-y-0.5">
|
||||
{hm?.vertexCount != null && (
|
||||
<div>网格顶点约 {hm.vertexCount.toLocaleString()}</div>
|
||||
)}
|
||||
{hm?.dispMin != null && hm?.dispMax != null && (
|
||||
<div>
|
||||
色标误差约 {hm.dispMin.toFixed(4)} ~ {hm.dispMax.toFixed(4)} mm
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="industrial-btn industrial-btn-primary py-1 text-[10px] flex items-center gap-1 w-full justify-center"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewSmoothHeatmap?.(r.id);
|
||||
}}
|
||||
>
|
||||
<Box className="w-3 h-3" />
|
||||
在 3D 中查看
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expanded && r.type === 'smooth_heatmap' && r.status === 'error' && (
|
||||
<div className="px-3 pb-3 pt-1 border-t border-border/40">
|
||||
<p className="text-[10px] text-red-400/90">
|
||||
{(r.resultData as SmoothHeatmapMeta | null)?.error ?? '分析失败'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expanded && r.type === 'assembly_inspection' && r.status === 'completed' && (
|
||||
<div className="px-3 pb-3 pt-1 border-t border-border/40 space-y-2">
|
||||
{(() => {
|
||||
const am = r.resultData as AssemblyMeta | null;
|
||||
const tol = am?.toleranceMm ?? 0.3;
|
||||
const parts = am?.parts ?? [];
|
||||
return (
|
||||
<>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
阈值 < {tol} mm 为合格;测点归属为「到各零件网格距离最小」的零件。
|
||||
</div>
|
||||
<div className="overflow-x-auto max-h-48 overflow-y-auto">
|
||||
<table className="w-full text-[9px] border-collapse">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground text-left">
|
||||
<th className="py-0.5 pr-1">零件</th>
|
||||
<th className="py-0.5 pr-1 text-right">点数</th>
|
||||
<th className="py-0.5 pr-1 text-right">min</th>
|
||||
<th className="py-0.5 pr-1 text-right">max</th>
|
||||
<th className="py-0.5 pr-1 text-right">mean</th>
|
||||
<th className="py-0.5 text-right">合格率</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{parts.map((p) => (
|
||||
<tr key={p.partId} className="border-t border-border/30">
|
||||
<td className="py-0.5 pr-1 font-mono truncate max-w-[100px]" title={p.name}>
|
||||
{p.name}
|
||||
</td>
|
||||
<td className="py-0.5 pr-1 text-right">{p.pointCount}</td>
|
||||
<td className="py-0.5 pr-1 text-right">{p.min.toFixed(4)}</td>
|
||||
<td className="py-0.5 pr-1 text-right">{p.max.toFixed(4)}</td>
|
||||
<td className="py-0.5 pr-1 text-right">{p.mean.toFixed(4)}</td>
|
||||
<td className="py-0.5 text-right">
|
||||
<span
|
||||
className={
|
||||
p.passRate >= 0.95
|
||||
? 'text-green-400'
|
||||
: p.passRate >= 0.85
|
||||
? 'text-amber-400'
|
||||
: 'text-red-400'
|
||||
}
|
||||
>
|
||||
{(p.passRate * 100).toFixed(1)}%
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expanded && r.type === 'assembly_inspection' && r.status === 'error' && (
|
||||
<div className="px-3 pb-3 pt-1 border-t border-border/40">
|
||||
<p className="text-[10px] text-red-400/90">
|
||||
{(r.resultData as AssemblyMeta | null)?.error ?? '装配检测失败'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expanded && r.type === 'obj_deviation' && r.status === 'completed' && (
|
||||
<div className="px-3 pb-3 pt-1 border-t border-border/40 space-y-2">
|
||||
{(() => {
|
||||
const hm = r.resultData as SmoothHeatmapMeta | null;
|
||||
return (
|
||||
<>
|
||||
<div className="text-[10px] text-muted-foreground space-y-0.5">
|
||||
<div>按顶点到最近点云点的距离着色(蓝=近 → 红=远)</div>
|
||||
{hm?.vertexCount != null && (
|
||||
<div>网格顶点约 {hm.vertexCount.toLocaleString()}</div>
|
||||
)}
|
||||
{hm?.dispMin != null && hm?.dispMax != null && (
|
||||
<div>
|
||||
偏差范围约 {hm.dispMin.toFixed(4)} ~ {hm.dispMax.toFixed(4)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="industrial-btn industrial-btn-primary py-1 text-[10px] flex items-center gap-1 w-full justify-center"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewSmoothHeatmap?.(r.id);
|
||||
}}
|
||||
>
|
||||
<Box className="w-3 h-3" />
|
||||
在 3D 中查看
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expanded && r.type === 'obj_deviation' && r.status === 'error' && (
|
||||
<div className="px-3 pb-3 pt-1 border-t border-border/40">
|
||||
<p className="text-[10px] text-red-400/90">
|
||||
{(r.resultData as SmoothHeatmapMeta | null)?.error ?? '偏差分析失败'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expanded && r.type === 'plugin' && (r.status === 'completed' || r.status === 'error') && (
|
||||
<div className="px-3 pb-3 pt-1 border-t border-border/40">
|
||||
<GenericResultView
|
||||
taskId={taskId}
|
||||
resultId={r.id}
|
||||
pluginId={(r as { pluginId?: string }).pluginId ?? undefined}
|
||||
pluginName={(r as { pluginId?: string }).pluginId ?? '算法插件'}
|
||||
data={(r.resultData as Parameters<typeof GenericResultView>[0]['data']) ?? {}}
|
||||
onViewOverlay={onViewPluginOverlay}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user