feat: update algorithm integrations and point cloud overlays
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user