feat: update algorithm integrations and point cloud overlays

This commit is contained in:
2026-06-30 16:46:36 +08:00
parent ddca7e5436
commit 7e2e56a50d
64 changed files with 4356 additions and 2781 deletions

View File

@@ -15,7 +15,14 @@ interface Measurement {
value: number;
unit?: string;
tolerance?: number;
/** 原始公差文本,例如 +8.5/-3;存在时优先展示 */
toleranceLabel?: string;
/** 斜率报表基准长度(mm),存在时以 value/baseline mm 形式展示 */
baseline?: number;
status?: 'pass' | 'warning' | 'fail' | 'info';
/** 自定义通过/不通过文字(覆盖默认的"合格"/"超差") */
passLabel?: string;
failLabel?: string;
}
interface ErrorStats {
@@ -42,7 +49,7 @@ interface PluginResultLike {
overlay3d?: Overlay3D;
images?: string[];
/** outDir 下的报表产物(相对路径,如 report.xlsx),前端展示下载入口 */
files?: string[];
files?: (string | { name?: string; path?: string })[];
extra?: Record<string, unknown>;
error?: string;
}
@@ -70,16 +77,16 @@ function statusClass(s?: Measurement['status']) {
}
}
function statusLabel(s?: Measurement['status']) {
switch (s) {
function statusLabel(m: Measurement) {
switch (m.status) {
case 'pass':
return '合格';
return m.passLabel ?? '合格';
case 'warning':
return '警告';
case 'fail':
return '超差';
return m.failLabel ?? '超差';
default:
return '—';
return '参考';
}
}
@@ -101,7 +108,9 @@ export default function GenericResultView({
const errorStats = data.errorStats ?? null;
const measurements = data.measurements ?? [];
const images = data.images ?? [];
const files = data.files ?? [];
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' };
return (
@@ -152,14 +161,22 @@ export default function GenericResultView({
{m.name}
</td>
<td className="text-right font-mono">
{formatValue(m.value)} {m.unit ?? ''}
{m.baseline != null
? `${formatValue(m.value)}/${m.baseline}mm`
: `${formatValue(m.value)} ${m.unit ?? ''}`}
</td>
<td className="text-right text-muted-foreground">
{m.tolerance != null ? `±${m.tolerance}` : '—'}
{m.toleranceLabel != null
? m.toleranceLabel
: m.tolerance != null
? m.baseline != null
? `${m.tolerance}/${m.baseline}mm`
: `±${m.tolerance}`
: '—'}
</td>
<td className="text-right">
<span className={`px-1 py-0.5 rounded text-[9px] ${statusClass(m.status)}`}>
{m.status === 'info' ? '参考' : statusLabel(m.status)}
{m.status === 'info' ? '参考' : statusLabel(m)}
</span>
</td>
</tr>

View File

@@ -415,7 +415,12 @@ const PointCloudMesh = memo(function PointCloudMesh({
positions[i3 + 1] = point.y;
positions[i3 + 2] = point.z;
if (showHeatmap) {
if (point.r !== undefined && point.g !== undefined && point.b !== undefined) {
// 点云自带颜色(如平整度热力图)直接使用,不走 errorToColor
colors[i3] = point.r;
colors[i3 + 1] = point.g;
colors[i3 + 2] = point.b;
} else if (showHeatmap) {
const color = errorToColor(point.error ?? 0, minError, maxError);
colors[i3] = color.r;
colors[i3 + 1] = color.g;
@@ -1389,7 +1394,7 @@ const SceneContent = memo(function SceneContent({
/>
</bufferGeometry>
<pointsMaterial
size={0.04}
size={pointSize ?? 0.03}
vertexColors
sizeAttenuation
transparent
@@ -1529,11 +1534,13 @@ async function fetchBatchSampled(
} catch { /* ignore */ }
}
// 接收二进制,直接解析为 Float32Array,无 JSON 开销
// 接收二进制,根据 X-Has-Color 决定每点 stride(4 floats 或 7 floats)
const hasColor = resp.headers.get('X-Has-Color') === '1';
const stride = hasColor ? 7 : 4;
const arrayBuffer = await resp.arrayBuffer();
const floats = new Float32Array(arrayBuffer);
const count = Math.floor(floats.length / 4);
console.log(`[PointCloudViewer] Binary received: ${count} pts, ${(arrayBuffer.byteLength / 1e6).toFixed(1)} MB`);
const count = Math.floor(floats.length / stride);
console.log(`[PointCloudViewer] Binary received: ${count} pts, hasColor=${hasColor}, ${(arrayBuffer.byteLength / 1e6).toFixed(1)} MB`);
if (count === 0) return { points: [] };
@@ -1543,9 +1550,15 @@ async function fetchBatchSampled(
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
for (let i = 0; i < count; i++) {
const base = i * 4;
const base = i * stride;
const x = floats[base], y = floats[base + 1], z = floats[base + 2], error = floats[base + 3];
pts[i] = { x, y, z, error };
const pt: Point = { x, y, z, error };
if (hasColor) {
pt.r = floats[base + 4];
pt.g = floats[base + 5];
pt.b = floats[base + 6];
}
pts[i] = pt;
if (x < minX) minX = x; if (x > maxX) maxX = x;
if (y < minY) minY = y; if (y > maxY) maxY = y;
if (z < minZ) minZ = z; if (z > maxZ) maxZ = z;
@@ -1700,6 +1713,9 @@ export default function PointCloudViewer({
const maxDim = Math.max(maxX - minX, maxY - minY, maxZ - minZ);
const scale = maxDim > 0 ? 10 / maxDim : 1;
// 保存变换参数,供 ply 叠加层对齐用
pcTransformRef.current = { cx: centerX, cy: centerY, cz: centerZ, scale };
for (let i = 0; i < pts.length; i++) {
const p = pts[i];
p.x = (p.x - centerX) * scale;
@@ -1753,23 +1769,73 @@ export default function PointCloudViewer({
// PLY 叠加层状态(kind=ply)
const [plyOverlayData, setPlyOverlayData] = useState<{ positions: Float32Array; colors: Float32Array } | null>(null);
// 主点云的坐标变换参数,供 ply 叠加层对齐用
const pcTransformRef = useRef<{ cx: number; cy: number; cz: number; scale: number } | null>(null);
// 加载 PLY 叠加层
// 加载 PLY 叠加层:走采样接口,避免浏览器加载 15GB 文件
useEffect(() => {
if (pluginOverlay == null || pluginOverlay.overlay.kind !== 'ply') {
setPlyOverlayData(null);
return;
}
const { taskId, resultId, overlay } = pluginOverlay;
const url = `/api/files/read/analysis/${taskId}/${resultId}/${overlay.file}`;
const ac = new AbortController();
(async () => {
try {
const resp = await fetch(url, { signal: ac.signal });
// 用 /sample/batch 接口按 filePath 采样,最多 30 万点
// 支持多文件(各面板 PLY),合并采样
const plyFiles: string[] = (overlay as { files?: string[]; file: string }).files
?? [overlay.file];
const resp = await fetch('/api/files/sample/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: ac.signal,
body: JSON.stringify({
filePaths: plyFiles.map(f => `/data/tasks/${taskId}/analysis/${resultId}/${f}`),
totalPoints: 300_000,
}),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const text = await resp.text();
const parsed = parsePlyText(text);
if (parsed) setPlyOverlayData(parsed);
const hasColor = resp.headers.get('X-Has-Color') === '1';
const stride = hasColor ? 7 : 4;
const buf = await resp.arrayBuffer();
const floats = new Float32Array(buf);
const count = Math.floor(floats.length / stride);
if (count === 0) return;
const positions = new Float32Array(count * 3);
const colors = new Float32Array(count * 3);
if (hasColor) {
// stride=7: [x,y,z,error,r,g,b] — 用批量提取代替逐点循环
for (let i = 0; i < count; i++) {
const s = i * 7;
positions[i * 3] = floats[s];
positions[i * 3 + 1] = floats[s + 1];
positions[i * 3 + 2] = floats[s + 2];
colors[i * 3] = floats[s + 4];
colors[i * 3 + 1] = floats[s + 5];
colors[i * 3 + 2] = floats[s + 6];
}
} else {
// stride=4: [x,y,z,error] — 只提取 xyz,颜色统一蓝色
for (let i = 0; i < count; i++) {
const s = i * 4;
positions[i * 3] = floats[s];
positions[i * 3 + 1] = floats[s + 1];
positions[i * 3 + 2] = floats[s + 2];
colors[i * 3] = 0.4; colors[i * 3 + 1] = 0.8; colors[i * 3 + 2] = 1.0;
}
}
// 用主点云的坐标变换对齐叠加层坐标
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 });
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);

View File

@@ -91,6 +91,13 @@ export default function TaskManager({
onSuccess: () => utils.taskCadFile.list.invalidate({ taskId: activeTaskId! }),
});
// CAD 三角化采样缓存状态(key: cadFileId, value: 是否已缓存)
const cadFileIds = taskCadFiles.map((f) => f.id);
const { data: cadSampleCacheMap = {} } = trpc.taskCadFile.hasSampleCache.useQuery(
{ cadFileIds },
{ enabled: cadFileIds.length > 0, staleTime: 30_000 },
);
// 切换任务时清空文件选中状态
useEffect(() => {
setSelectedFileIds(new Set());
@@ -539,6 +546,14 @@ export default function TaskManager({
</button>
<FileBox className={`w-3.5 h-3.5 flex-shrink-0 ${isChecked ? 'text-primary' : 'text-blue-400'}`} />
<span className="flex-1 truncate" title={f.name}>{f.name}</span>
{cadSampleCacheMap[f.id] && (
<span
className="text-[9px] px-1 py-0.5 rounded bg-green-500/15 text-green-400 border border-green-500/30 flex-shrink-0"
title="已完成三角化预处理,下次配准可跳过 CAD 解析"
>
已预处理
</span>
)}
<span className="text-muted-foreground text-[10px] flex-shrink-0">
{formatSize(f.fileSize)}
</span>