Files
massive-cloud/client/src/components/PointCloudViewer.tsx

2082 lines
71 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useRef, useMemo, useState, useEffect, useLayoutEffect, memo } from 'react';
import { Canvas, useFrame, useThree } from '@react-three/fiber';
import { OrbitControls, PerspectiveCamera } from '@react-three/drei';
import * as THREE from 'three';
import { mergeVertices, mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
import Delaunator from 'delaunator';
import {
buildPCGrid3D,
CAD_HEATMAP_MAX_VERTICES,
CAD_HEATMAP_MEDIAN_NN_SAMPLES,
CAD_HEATMAP_SHELL_OFFSET_FRAC,
displayErrorRangeSmoothHeatmap,
errorToRgb,
medianNearestNeighbor3D,
nearestPointInGrid,
type HeatmapPoint,
type PCGrid3D,
} from '@shared/cadHeatmapMath';
// Types
type Point = HeatmapPoint;
interface PointCloudData {
points: Point[];
boundingBox?: {
minX: number; minY: number; minZ: number;
maxX: number; maxY: number; maxZ: number;
};
}
interface SelectedPoint {
position: THREE.Vector3;
error: number;
index: number;
}
/** 误差 → 色(与 @shared/cadHeatmapMath 一致) */
function errorToColor(error: number, minError: number, maxError: number): THREE.Color {
const [r, g, b] = errorToRgb(error, minError, maxError);
return new THREE.Color(r, g, b);
}
/** 与当前着色一致的图例刻度(点云模式全量;平滑模式与 mesh 相同分位数) */
function getHeatmapLegendBounds(
points: Point[],
mode: 'points' | 'smoothHeatmap',
): { min: number; max: number; midTick: string } {
if (mode === 'smoothHeatmap') {
const { minError, maxError } = displayErrorRangeSmoothHeatmap(points);
const midTick =
minError < 0 && maxError > 0
? '0'
: ((minError + maxError) / 2).toFixed(3);
return { min: minError, max: maxError, midTick };
}
let minE = Infinity;
let maxE = -Infinity;
for (let i = 0; i < points.length; i++) {
const e = points[i].error ?? 0;
if (e < minE) minE = e;
if (e > maxE) maxE = e;
}
if (!(maxE > minE)) {
return { min: minE, max: maxE, midTick: minE.toFixed(3) };
}
const midTick =
minE < 0 && maxE > 0 ? '0' : ((minE + maxE) / 2).toFixed(3);
return { min: minE, max: maxE, midTick };
}
function kNearestIndices(g: PCGrid3D, centerIdx: number, k: number): number[] {
const p = g.points[centerIdx];
const { cell, minX, minY, minZ, map, points } = g;
const cx = Math.floor((p.x - minX) / cell);
const cy = Math.floor((p.y - minY) / cell);
const cz = Math.floor((p.z - minZ) / cell);
const candidates: { i: number; d: number }[] = [];
for (let R = 0; R <= 30; R++) {
for (let dx = -R; dx <= R; dx++) {
for (let dy = -R; dy <= R; dy++) {
for (let dz = -R; dz <= R; dz++) {
if (Math.max(Math.abs(dx), Math.abs(dy), Math.abs(dz)) !== R) continue;
const list = map.get(`${cx + dx},${cy + dy},${cz + dz}`);
if (!list) continue;
for (let j = 0; j < list.length; j++) {
const idx = list[j];
if (idx === centerIdx) continue;
const q = points[idx];
const d = Math.hypot(q.x - p.x, q.y - p.y, q.z - p.z);
candidates.push({ i: idx, d });
}
}
}
}
if (candidates.length >= k + 2) break;
}
candidates.sort((a, b) => a.d - b.d);
return candidates.slice(0, k).map((c) => c.i);
}
function inv3(m: number[][]): number[][] | null {
const a = m[0][0];
const b = m[0][1];
const c = m[0][2];
const d = m[1][0];
const e = m[1][1];
const f = m[1][2];
const g = m[2][0];
const h = m[2][1];
const i = m[2][2];
const det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g);
if (Math.abs(det) < 1e-20) return null;
const id = 1 / det;
return [
[(e * i - f * h) * id, (c * h - b * i) * id, (b * f - c * e) * id],
[(f * g - d * i) * id, (a * i - c * g) * id, (c * d - a * f) * id],
[(d * h - e * g) * id, (b * g - a * h) * id, (a * e - b * d) * id],
];
}
function matVec3(m: number[][], v: number[]): number[] {
return [
m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
];
}
function norm3(v: number[]): number[] {
const len = Math.hypot(v[0], v[1], v[2]) || 1e-12;
return [v[0] / len, v[1] / len, v[2] / len];
}
function cross3(a: number[], b: number[]): number[] {
return [
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
];
}
function covMatrixFromIndices(points: Point[], idxs: number[]): number[][] {
let mx = 0;
let my = 0;
let mz = 0;
for (const i of idxs) {
const p = points[i];
mx += p.x;
my += p.y;
mz += p.z;
}
const n = idxs.length || 1;
mx /= n;
my /= n;
mz /= n;
let cxx = 0;
let cxy = 0;
let cxz = 0;
let cyy = 0;
let cyz = 0;
let czz = 0;
for (const i of idxs) {
const p = points[i];
const dx = p.x - mx;
const dy = p.y - my;
const dz = p.z - mz;
cxx += dx * dx;
cxy += dx * dy;
cxz += dx * dz;
cyy += dy * dy;
cyz += dy * dz;
czz += dz * dz;
}
const f = 1 / n;
return [
[cxx * f, cxy * f, cxz * f],
[cxy * f, cyy * f, cyz * f],
[cxz * f, cyz * f, czz * f],
];
}
/** 协方差最小特征方向 ≈ 局部法向(扫描壳层) */
function smallestEigenvector3(C: number[][]): number[] {
const eps = 1e-7;
const A = [
[C[0][0] + eps, C[0][1], C[0][2]],
[C[0][1], C[1][1] + eps, C[1][2]],
[C[0][2], C[1][2], C[2][2] + eps],
];
const inv = inv3(A);
if (!inv) return [0, 1, 0];
let v = [0.37, 0.51, 0.24];
for (let iter = 0; iter < 48; iter++) {
v = norm3(matVec3(inv, v));
}
return v;
}
function estimateTangentFrame(
points: Point[],
centerIdx: number,
neighIdxs: number[],
centroid: number[],
): { n: number[]; u: number[]; v: number[] } {
const C = covMatrixFromIndices(points, neighIdxs);
let n = smallestEigenvector3(C);
const pc = points[centerIdx];
const toC = [centroid[0] - pc.x, centroid[1] - pc.y, centroid[2] - pc.z];
if (n[0] * toC[0] + n[1] * toC[1] + n[2] * toC[2] < 0) {
n = [-n[0], -n[1], -n[2]];
}
const t = Math.abs(n[0]) < 0.85 ? [1, 0, 0] : [0, 1, 0];
const u = norm3(cross3(t, n));
const v = norm3(cross3(n, u));
return { n, u, v };
}
function cloudCentroid(points: Point[]): number[] {
let sx = 0;
let sy = 0;
let sz = 0;
const n = points.length;
for (let i = 0; i < n; i++) {
const p = points[i];
sx += p.x;
sy += p.y;
sz += p.z;
}
const f = 1 / Math.max(n, 1);
return [sx * f, sy * f, sz * f];
}
function dist3p(a: Point, b: Point): number {
return Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z);
}
function triangleMaxEdge3D(a: Point, b: Point, c: Point): number {
return Math.max(dist3p(a, b), dist3p(b, c), dist3p(c, a));
}
/**
* 仅用点云:邻域 PCA 得切平面 → 局部 2D Delaunay → 拼成外壳三角网(不依赖 CAD)。
* 长边过滤抑制跨门窗空洞的虚假连接。
* localK 越大、patchStride 越小,覆盖越满(主线程耗时越高)。
*/
function buildPointCloudSurfaceTriangles(
sub: Point[],
grid: PCGrid3D,
nnMed: number,
patchStride: number,
maxEdgeFactor: number,
localK: number,
): number[] | null {
const centroid = cloudCentroid(sub);
const triSet = new Set<string>();
const maxEdge = Math.max(nnMed * maxEdgeFactor, 1e-6);
for (let pi = 0; pi < sub.length; pi += patchStride) {
const neigh = kNearestIndices(grid, pi, localK);
if (neigh.length < 8) continue;
const { u, v } = estimateTangentFrame(sub, pi, neigh, centroid);
const p0 = sub[pi];
type Lp = { x: number; y: number; g: number };
const localPts: Lp[] = [{ x: 0, y: 0, g: pi }];
for (let j = 0; j < neigh.length; j++) {
const q = sub[neigh[j]];
const dx = q.x - p0.x;
const dy = q.y - p0.y;
const dz = q.z - p0.z;
const x = dx * u[0] + dy * u[1] + dz * u[2];
const y = dx * v[0] + dy * v[1] + dz * v[2];
localPts.push({ x, y, g: neigh[j] });
}
let del: InstanceType<typeof Delaunator>;
try {
del = Delaunator.from(localPts, (o) => o.x, (o) => o.y);
} catch {
continue;
}
const raw = del.triangles;
for (let t = 0; t < raw.length; t += 3) {
const ia = localPts[raw[t]].g;
const ib = localPts[raw[t + 1]].g;
const ic = localPts[raw[t + 2]].g;
const pa = sub[ia];
const pb = sub[ib];
const pc = sub[ic];
if (triangleMaxEdge3D(pa, pb, pc) > maxEdge) continue;
const s = [ia, ib, ic].sort((x, y) => x - y);
triSet.add(`${s[0]},${s[1]},${s[2]}`);
}
}
if (triSet.size === 0) return null;
const out: number[] = [];
triSet.forEach((key) => {
const parts = key.split(',');
out.push(Number(parts[0]), Number(parts[1]), Number(parts[2]));
});
return out;
}
// Generate demo point cloud data - industrial part shape
export function generateDemoPointCloud(count: number = 100000): PointCloudData {
const points: Point[] = [];
// Generate a complex industrial part shape
for (let i = 0; i < count; i++) {
let x, y, z, error;
const partType = Math.random();
if (partType < 0.4) {
// Main body - box shape
x = (Math.random() - 0.5) * 8;
y = (Math.random() - 0.5) * 4;
z = (Math.random() - 0.5) * 6;
error = Math.abs(Math.sin(x * 0.5) * Math.cos(z * 0.5)) * 1.5 + Math.random() * 0.3;
} else if (partType < 0.6) {
// Cylindrical feature
const angle = Math.random() * Math.PI * 2;
const r = 1.5 + (Math.random() - 0.5) * 0.1;
x = Math.cos(angle) * r + 2;
y = (Math.random() - 0.5) * 3;
z = Math.sin(angle) * r;
error = Math.abs(r - 1.5) * 5 + Math.random() * 0.2;
} else if (partType < 0.8) {
// Another cylindrical hole
const angle = Math.random() * Math.PI * 2;
const r = 1.0 + (Math.random() - 0.5) * 0.08;
x = Math.cos(angle) * r - 2;
y = (Math.random() - 0.5) * 2;
z = Math.sin(angle) * r + 1;
error = Math.abs(r - 1.0) * 8 + Math.random() * 0.15;
} else {
// Surface details
x = (Math.random() - 0.5) * 10;
y = 2 + (Math.random() - 0.5) * 0.2;
z = (Math.random() - 0.5) * 8;
error = Math.abs(y - 2) * 10 + Math.random() * 0.4;
}
// Add measurement noise
x += (Math.random() - 0.5) * 0.02;
y += (Math.random() - 0.5) * 0.02;
z += (Math.random() - 0.5) * 0.02;
points.push({ x, y, z, error: Math.min(error, 2.5) });
}
return {
points,
boundingBox: { minX: -6, minY: -3, minZ: -5, maxX: 6, maxY: 3, maxZ: 5 }
};
}
// Point cloud mesh component
interface PointCloudMeshProps {
data: PointCloudData;
pointSize?: number;
showHeatmap?: boolean;
enablePointPicking?: boolean;
onPointSelect?: (point: SelectedPoint | null) => void;
/** full:全量 min/max;percentile:与平滑热力网格一致(分位数色标) */
errorRangeMode?: 'full' | 'percentile';
/** 平滑热力底层填充时可略透明 */
pointOpacity?: number;
}
// 高性能点云渲染组件 - 完全禁用事件监听以获得最佳性能
const PointCloudMesh = memo(function PointCloudMesh({
data,
pointSize = 0.03,
showHeatmap = true,
enablePointPicking = false,
onPointSelect,
errorRangeMode = 'full',
pointOpacity,
}: PointCloudMeshProps) {
const meshRef = useRef<THREE.Points>(null);
const opacity = pointOpacity ?? 0.95;
// 使用 useMemo 生成顶点数据
const { positions, colors } = useMemo(() => {
console.log('[PointCloudMesh] Building vertex buffers for', data.points.length, 'points');
const startTime = performance.now();
const positions = new Float32Array(data.points.length * 3);
const colors = new Float32Array(data.points.length * 3);
let minError: number;
let maxError: number;
if (errorRangeMode === 'percentile') {
const r = displayErrorRangeSmoothHeatmap(data.points);
minError = r.minError;
maxError = r.maxError;
} else {
minError = Infinity;
maxError = -Infinity;
for (let i = 0; i < data.points.length; i++) {
const err = data.points[i].error ?? 0;
if (err < minError) minError = err;
if (err > maxError) maxError = err;
}
}
// 填充缓冲区
for (let i = 0; i < data.points.length; i++) {
const point = data.points[i];
const i3 = i * 3;
positions[i3] = point.x;
positions[i3 + 1] = point.y;
positions[i3 + 2] = point.z;
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;
colors[i3 + 2] = color.b;
} else {
colors[i3] = 0.6;
colors[i3 + 1] = 0.65;
colors[i3 + 2] = 0.7;
}
}
console.log('[PointCloudMesh] Vertex buffers built in', (performance.now() - startTime).toFixed(2), 'ms');
return { positions, colors };
}, [data, showHeatmap, errorRangeMode]);
// 视锥剔除:仅渲染相机视野内的点(原先 false 会每帧画满屏 80 万点,很卡)
useLayoutEffect(() => {
const mesh = meshRef.current;
if (!mesh) return;
mesh.geometry.computeBoundingSphere();
mesh.frustumCulled = true;
}, [positions]);
const handlePointerDown = (event: any) => {
if (!enablePointPicking || !onPointSelect) return;
if (event.button !== 0) return;
event.stopPropagation();
const pointIndex = typeof event.index === "number" ? event.index : -1;
if (pointIndex < 0 || pointIndex >= data.points.length) {
onPointSelect(null);
return;
}
const pointData = data.points[pointIndex];
onPointSelect({
position: event.point.clone(),
error: pointData.error ?? 0,
index: pointIndex,
});
};
return (
<points
ref={meshRef}
renderOrder={errorRangeMode === 'percentile' ? -1 : 0}
onPointerDown={enablePointPicking ? handlePointerDown : undefined}
>
<bufferGeometry>
<bufferAttribute
attach="attributes-position"
args={[positions, 3]}
/>
<bufferAttribute
attach="attributes-color"
args={[colors, 3]}
/>
</bufferGeometry>
<pointsMaterial
size={pointSize}
vertexColors
sizeAttenuation
transparent
opacity={opacity}
depthWrite={false}
/>
</points>
);
}, (prevProps, nextProps) => {
return prevProps.data === nextProps.data &&
prevProps.pointSize === nextProps.pointSize &&
prevProps.showHeatmap === nextProps.showHeatmap &&
prevProps.enablePointPicking === nextProps.enablePointPicking &&
prevProps.errorRangeMode === nextProps.errorRangeMode &&
prevProps.pointOpacity === nextProps.pointOpacity;
});
/** 平滑热力子采样上限;略提高以更密地包住整车 */
const SMOOTH_HEATMAP_MAX_POINTS = 42_000;
/** 拼壳用中位最近邻距离:抽样即可,不必对子样本全量查询 */
const SMOOTH_HEATMAP_MEDIAN_NN_SAMPLES = 6_000;
interface SmoothHeatmapMeshProps {
data: PointCloudData;
showHeatmap: boolean;
showWireframe?: boolean;
}
const SmoothHeatmapMesh = memo(function SmoothHeatmapMesh({
data,
showHeatmap,
showWireframe = false,
}: SmoothHeatmapMeshProps) {
const geometry = useMemo(() => {
const pts = data.points;
if (pts.length < 3) return null;
const { minError: dispMin, maxError: dispMax } = displayErrorRangeSmoothHeatmap(pts);
const n = pts.length;
const step = n > SMOOTH_HEATMAP_MAX_POINTS ? Math.ceil(n / SMOOTH_HEATMAP_MAX_POINTS) : 1;
const sub: Point[] = [];
for (let i = 0; i < n; i += step) {
sub.push(pts[i]);
}
if (sub.length < 3) return null;
const grid = buildPCGrid3D(sub);
const nnMed = medianNearestNeighbor3D(sub, grid, SMOOTH_HEATMAP_MEDIAN_NN_SAMPLES);
/** 期望三角面数量;略提高以尽量拼满壳层(仍会有缝,由底层点云补满) */
const minTriangleCount = Math.max(200, Math.floor(sub.length * 0.08));
// 子样本 ≤4.2 万时片心步长 1,保证逐点铺网;更大时点稀时再 stride 2
let patchStride = sub.length > 42_000 ? 2 : 1;
let maxEdgeFactor = 9.5;
let localK = 36;
let filtered = buildPointCloudSurfaceTriangles(
sub,
grid,
nnMed,
patchStride,
maxEdgeFactor,
localK,
);
let relax = 0;
while (relax < 14) {
const triCount = filtered ? filtered.length / 3 : 0;
if (filtered !== null && triCount >= minTriangleCount && triCount > 0) break;
if (patchStride > 1) {
patchStride = 1;
} else {
maxEdgeFactor *= 1.12;
localK = Math.min(50, localK + 2);
}
filtered = buildPointCloudSurfaceTriangles(
sub,
grid,
nnMed,
patchStride,
maxEdgeFactor,
localK,
);
relax++;
}
if (filtered === null || filtered.length < 9) {
console.warn('[SmoothHeatmapMesh] Point-cloud surface mesh failed; skip');
return null;
}
const pos = new Float32Array(sub.length * 3);
const col = new Float32Array(sub.length * 3);
for (let i = 0; i < sub.length; i++) {
const p = sub[i];
const i3 = i * 3;
pos[i3] = p.x;
pos[i3 + 1] = p.y;
pos[i3 + 2] = p.z;
if (showHeatmap) {
const c = errorToColor(p.error ?? 0, dispMin, dispMax);
col[i3] = c.r;
col[i3 + 1] = c.g;
col[i3 + 2] = c.b;
} else {
col[i3] = 0.55;
col[i3 + 1] = 0.58;
col[i3 + 2] = 0.62;
}
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
geo.setAttribute('color', new THREE.BufferAttribute(col, 3));
geo.setIndex(filtered);
return geo;
}, [data, showHeatmap]);
useEffect(() => {
return () => {
geometry?.dispose();
};
}, [geometry]);
if (!geometry) return null;
return (
<mesh geometry={geometry} frustumCulled renderOrder={1}>
<meshBasicMaterial
vertexColors
wireframe={showWireframe}
side={THREE.DoubleSide}
transparent
opacity={showWireframe ? 0.88 : 0.98}
depthWrite
/>
</mesh>
);
}, (a, b) =>
a.data === b.data && a.showHeatmap === b.showHeatmap && a.showWireframe === b.showWireframe,
);
/**
* 平滑热力(客户端 NN 路径,仅在无服务端预计算颜色时使用):
* 在 CAD 网格上按最近测点着色。大模型(>1M 顶点)应使用服务端 GPU 路径。
*/
const CadSurfaceHeatmapMesh = memo(function CadSurfaceHeatmapMesh({
cadTemplateGeometry,
data,
showHeatmap,
showWireframe,
}: {
cadTemplateGeometry: THREE.BufferGeometry;
data: PointCloudData;
showHeatmap: boolean;
showWireframe: boolean;
}) {
const geometry = useMemo(() => {
const pts = data.points;
if (pts.length < 1) return null;
const geo = cadTemplateGeometry.clone();
try {
const rawCount = geo.attributes.position?.count ?? 0;
if (rawCount > 450_000) {
mergeVertices(geo, 0.0015);
} else if (rawCount > 220_000) {
mergeVertices(geo, 0.0006);
} else {
mergeVertices(geo);
}
} catch {
/* 非索引几何可忽略 */
}
geo.computeVertexNormals();
let weldTol = 2e-4;
while (
geo.attributes.position &&
geo.attributes.position.count > CAD_HEATMAP_MAX_VERTICES &&
weldTol < 0.12
) {
mergeVertices(geo, weldTol);
weldTol *= 1.85;
geo.computeVertexNormals();
}
const posAttr = geo.attributes.position;
const norAttr = geo.attributes.normal;
if (!posAttr || posAttr.count === 0) return null;
const nV = posAttr.count;
const pos = posAttr.array as Float32Array;
const nor = norAttr?.array as Float32Array | undefined;
geo.computeBoundingBox();
const bb = geo.boundingBox;
const diag = bb
? Math.hypot(bb.max.x - bb.min.x, bb.max.y - bb.min.y, bb.max.z - bb.min.z)
: 1;
const grid = buildPCGrid3D(pts);
const nnMed = medianNearestNeighbor3D(pts, grid, CAD_HEATMAP_MEDIAN_NN_SAMPLES);
const { minError: dispMin, maxError: dispMax } = displayErrorRangeSmoothHeatmap(pts);
const maxBind = Math.max(nnMed * 14, diag * 0.045);
const shellOffset = Math.max(diag * CAD_HEATMAP_SHELL_OFFSET_FRAC, nnMed * 0.35);
const colors = new Float32Array(nV * 3);
const noData = [0.32, 0.34, 0.38] as const;
for (let i = 0; i < nV; i++) {
const i3 = i * 3;
const x = pos[i3];
const y = pos[i3 + 1];
const z = pos[i3 + 2];
const hit = nearestPointInGrid(grid, x, y, z);
if (!hit || hit.dist > maxBind) {
colors[i3] = noData[0];
colors[i3 + 1] = noData[1];
colors[i3 + 2] = noData[2];
} else if (showHeatmap) {
const err = pts[hit.idx].error ?? 0;
const c = errorToColor(err, dispMin, dispMax);
colors[i3] = c.r;
colors[i3 + 1] = c.g;
colors[i3 + 2] = c.b;
} else {
colors[i3] = 0.55;
colors[i3 + 1] = 0.58;
colors[i3 + 2] = 0.62;
}
if (nor && nor.length >= nV * 3) {
pos[i3] = x + nor[i3] * shellOffset;
pos[i3 + 1] = y + nor[i3 + 1] * shellOffset;
pos[i3 + 2] = z + nor[i3 + 2] * shellOffset;
}
}
posAttr.needsUpdate = true;
geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
return geo;
}, [cadTemplateGeometry, data, showHeatmap]);
useEffect(() => {
return () => {
geometry?.dispose();
};
}, [geometry]);
if (!geometry) return null;
return (
<mesh geometry={geometry} frustumCulled renderOrder={2}>
<meshBasicMaterial
vertexColors
wireframe={showWireframe}
side={THREE.DoubleSide}
transparent
opacity={showWireframe ? 0.9 : 0.98}
depthWrite
polygonOffset
polygonOffsetFactor={1}
polygonOffsetUnits={1}
/>
</mesh>
);
}, (a, b) =>
a.cadTemplateGeometry === b.cadTemplateGeometry &&
a.data === b.data &&
a.showHeatmap === b.showHeatmap &&
a.showWireframe === b.showWireframe,
);
// CAD model mesh component - 支持加载实际 CAD 文件
/** 热力图点击拾取结果 */
export interface HeatmapPickResult {
/** 世界坐标 */
position: [number, number, number];
/** 该顶点的偏差值(mm) */
deviation: number;
/** 面索引 */
faceIndex: number;
}
interface CADModelProps {
visible?: boolean;
wireframe?: boolean;
modelUrl?: string;
modelFormat?: string;
onProgress?: (progress: number) => void;
onLoaded?: () => void;
onLoadError?: (message: string) => void;
onGeometryReady?: (geometry: THREE.BufferGeometry | null) => void;
useVertexColors?: boolean;
/** 热力图模式下点击回调 */
onHeatmapPick?: (result: HeatmapPickResult | null) => void;
/** 服务端颜色 ref,用于点击时反查偏差值 */
serverColorsRef?: React.RefObject<Float32Array | null>;
/** 偏差范围 */
dispRange?: { min: number; max: number } | null;
}
const CADModel = memo(function CADModel({
visible = true,
wireframe = false,
modelUrl,
modelFormat,
onProgress,
onLoaded,
onLoadError,
onGeometryReady,
useVertexColors = false,
onHeatmapPick,
serverColorsRef: colorsRefForPick,
dispRange,
}: CADModelProps) {
const groupRef = useRef<THREE.Group>(null);
const meshRef = useRef<THREE.Mesh>(null);
const onHeatmapPickRef = useRef(onHeatmapPick);
onHeatmapPickRef.current = onHeatmapPick;
const [geometry, setGeometry] = useState<THREE.BufferGeometry | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const onProgressRef = useRef(onProgress);
const onLoadedRef = useRef(onLoaded);
const onLoadErrorRef = useRef(onLoadError);
const onGeometryReadyRef = useRef(onGeometryReady);
onProgressRef.current = onProgress;
onLoadedRef.current = onLoaded;
onLoadErrorRef.current = onLoadError;
onGeometryReadyRef.current = onGeometryReady;
// 加载 CAD 文件
useEffect(() => {
console.log('[CADModel] modelUrl changed:', modelUrl, 'format:', modelFormat);
if (!modelUrl) {
setGeometry(null);
onGeometryReadyRef.current?.(null);
return;
}
setLoading(true);
setError(null);
const loadModel = async () => {
try {
const format = modelFormat?.toUpperCase() || modelUrl.split('.').pop()?.toUpperCase() || 'STL';
console.log('[CADModel] Loading model, format:', format, 'url:', modelUrl);
if (format === 'STL') {
const { STLLoader } = await import('three/examples/jsm/loaders/STLLoader.js');
const loader = new STLLoader();
loader.load(
modelUrl,
(geo) => {
// 居中并缩放模型
geo.computeBoundingBox();
geo.center();
const box = geo.boundingBox;
if (box) {
const size = new THREE.Vector3();
box.getSize(size);
const maxDim = Math.max(size.x, size.y, size.z);
if (maxDim > 0) {
const scale = 10 / maxDim; // 归一化到 10 单位
geo.scale(scale, scale, scale);
}
}
console.log('[CADModel] STL loaded successfully, vertices:', geo.attributes.position?.count);
setGeometry(geo);
onGeometryReadyRef.current?.(geo);
setLoading(false);
onProgressRef.current?.(100);
onLoadedRef.current?.();
},
(progress) => {
const percent = progress.total > 0 ? Math.round((progress.loaded / progress.total) * 100) : 0;
console.log('[CADModel] STL loading progress:', progress.loaded, '/', progress.total, `(${percent}%)`);
onProgressRef.current?.(percent);
},
(err) => {
console.error('[CADModel] Failed to load STL:', err);
setGeometry(null);
onGeometryReadyRef.current?.(null);
setError('加载 STL 文件失败');
setLoading(false);
onLoadErrorRef.current?.('加载 STL 失败');
onLoadedRef.current?.();
}
);
} else if (format === 'OBJ') {
const { OBJLoader } = await import('three/examples/jsm/loaders/OBJLoader.js');
const loader = new OBJLoader();
loader.load(
modelUrl,
(obj) => {
console.log('[CADModel] OBJ parsing complete, extracting geometry...');
const geometries: THREE.BufferGeometry[] = [];
obj.traverse((child) => {
if (child instanceof THREE.Mesh && child.geometry) {
geometries.push(child.geometry);
}
});
if (geometries.length === 0) {
console.warn('[CADModel] OBJ contains no mesh geometry');
setGeometry(null);
onGeometryReadyRef.current?.(null);
setError('OBJ 文件中没有可用几何体');
setLoading(false);
onLoadErrorRef.current?.('OBJ 无几何体');
onLoadedRef.current?.();
return;
}
let geo: THREE.BufferGeometry;
if (geometries.length === 1) {
geo = geometries[0].clone();
} else {
const merged = mergeGeometries(geometries, false);
geo = merged ?? geometries[0].clone();
console.log(`[CADModel] merged ${geometries.length} OBJ groups`);
}
geo.computeBoundingBox();
geo.center();
const box = geo.boundingBox;
if (box) {
const size = new THREE.Vector3();
box.getSize(size);
const maxDim = Math.max(size.x, size.y, size.z);
if (maxDim > 0) {
const scale = 10 / maxDim;
geo.scale(scale, scale, scale);
}
}
const vertexCount = geo.attributes.position?.count || 0;
setGeometry(geo);
onGeometryReadyRef.current?.(geo);
console.log('[CADModel] OBJ loaded successfully, vertices:', vertexCount);
setLoading(false);
onProgressRef.current?.(100);
onLoadedRef.current?.();
},
(progress) => {
const percent = progress.total > 0 ? Math.round((progress.loaded / progress.total) * 100) : 0;
console.log(`[CADModel] OBJ loading progress: ${progress.loaded} / ${progress.total} (${percent}%)`);
onProgressRef.current?.(percent);
},
(err) => {
console.error('[CADModel] Failed to load OBJ:', err);
setGeometry(null);
onGeometryReadyRef.current?.(null);
setError('加载 OBJ 文件失败');
setLoading(false);
onLoadErrorRef.current?.('加载 OBJ 失败');
onLoadedRef.current?.();
}
);
} else {
setGeometry(null);
onGeometryReadyRef.current?.(null);
const msg =
format === 'STP' || format === 'STEP' || format === 'IGES' || format === 'IGS'
? '浏览器无法直接解析 STEP/IGES。请先运行「装配检测」生成零件网格后,将自动用首件 STL 预览;或直接选用 STL/OBJ。'
: `不支持的格式: ${format}`;
setError(msg);
setLoading(false);
onProgressRef.current?.(100);
onLoadErrorRef.current?.(msg);
onLoadedRef.current?.();
}
} catch (err) {
console.error('Failed to load model:', err);
setGeometry(null);
onGeometryReadyRef.current?.(null);
setError('加载模型失败');
setLoading(false);
onLoadErrorRef.current?.('加载模型失败');
onLoadedRef.current?.();
}
};
loadModel();
}, [modelUrl, modelFormat]);
// 热力图模式:替换为 MeshBasicMaterial(无光照/无阴影,纯色输出),
// 避免灯光明暗干扰误差判读。普通模式恢复 MeshStandardMaterial。
const stdMatRef = useRef<THREE.MeshStandardMaterial | null>(null);
useEffect(() => {
const mesh = meshRef.current;
if (!mesh) return;
if (useVertexColors) {
if (!stdMatRef.current && mesh.material instanceof THREE.MeshStandardMaterial) {
stdMatRef.current = mesh.material;
}
mesh.renderOrder = 2;
const basic = new THREE.MeshBasicMaterial({
vertexColors: true,
side: THREE.DoubleSide,
wireframe,
depthWrite: true,
polygonOffset: true,
polygonOffsetFactor: 1,
polygonOffsetUnits: 1,
});
const old = mesh.material;
mesh.material = basic;
if (old && old !== stdMatRef.current) (old as THREE.Material).dispose();
} else {
mesh.renderOrder = 0;
if (stdMatRef.current) {
const old = mesh.material;
mesh.material = stdMatRef.current;
stdMatRef.current.wireframe = wireframe;
stdMatRef.current.opacity = wireframe ? 0.18 : 0.72;
stdMatRef.current.needsUpdate = true;
if (old && old !== stdMatRef.current) (old as THREE.Material).dispose();
}
}
}, [geometry, useVertexColors, wireframe]);
const dispRangeRef = useRef(dispRange);
dispRangeRef.current = dispRange;
// 11M 顶点的 mesh 默认会在每次 pointer move 时做全量射线检测(遍历所有三角面)。
// 不在热力拾取模式时完全禁用 raycast,避免严重卡顿。
useEffect(() => {
const mesh = meshRef.current;
if (!mesh) return;
if (useVertexColors) {
// 恢复默认 raycast
delete (mesh as any).raycast;
} else {
// 空函数禁用射线检测
(mesh as any).raycast = () => {};
}
}, [useVertexColors, geometry]);
if (!visible && !useVertexColors) return null;
if (!geometry && !loading) return null;
return (
<group ref={groupRef}>
{geometry && (
<mesh
ref={meshRef}
geometry={geometry}
onClick={useVertexColors ? (e: any) => {
const face: number | undefined = e.faceIndex;
const pt: THREE.Vector3 | undefined = e.point;
if (face == null || !pt) return;
e.stopPropagation();
const colors = colorsRefForPick?.current;
const dr = dispRangeRef.current;
if (!colors || !dr) return;
const baseIdx = face * 3;
let sumR = 0, sumG = 0, sumB = 0;
for (let k = 0; k < 3; k++) {
const ci = (baseIdx + k) * 3;
sumR += colors[ci] ?? 0;
sumG += colors[ci + 1] ?? 0;
sumB += colors[ci + 2] ?? 0;
}
const avgR = sumR / 3;
const avgB = sumB / 3;
const t = Math.max(0, Math.min(1, avgR / Math.max(avgR + avgB, 1e-6)));
const deviation = dr.min + t * (dr.max - dr.min);
onHeatmapPickRef.current?.({
position: [pt.x, pt.y, pt.z],
deviation,
faceIndex: face,
});
} : undefined}
>
<meshStandardMaterial
color="#ffffff"
transparent
wireframe={wireframe}
side={THREE.DoubleSide}
opacity={wireframe ? 0.18 : 0.72}
metalness={0.05}
roughness={0.55}
depthWrite
polygonOffset
polygonOffsetFactor={1}
polygonOffsetUnits={1}
/>
</mesh>
)}
</group>
);
});
// Selection indicator
interface SelectionIndicatorProps {
point: SelectedPoint | null;
}
function SelectionIndicator({ point }: SelectionIndicatorProps) {
const ringRef = useRef<THREE.Mesh>(null);
useFrame((state) => {
if (ringRef.current) {
ringRef.current.rotation.z = state.clock.elapsedTime * 2;
}
});
if (!point) return null;
return (
<group position={point.position}>
<mesh>
<sphereGeometry args={[0.12, 16, 16]} />
<meshBasicMaterial color="#ffffff" transparent opacity={0.9} />
</mesh>
<mesh ref={ringRef}>
<ringGeometry args={[0.18, 0.22, 32]} />
<meshBasicMaterial color="#00ff88" side={THREE.DoubleSide} transparent opacity={0.8} />
</mesh>
<mesh rotation={[Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.18, 0.22, 32]} />
<meshBasicMaterial color="#00ff88" side={THREE.DoubleSide} transparent opacity={0.8} />
</mesh>
</group>
);
}
// Grid helper - industrial style
const IndustrialGrid = memo(function IndustrialGrid() {
return (
<group>
<gridHelper args={[24, 24, '#1a2332', '#141c28']} position={[0, -3.5, 0]} />
<gridHelper args={[24, 24, '#1a2332', '#141c28']} position={[0, -3.5, 0]} rotation={[0, Math.PI / 4, 0]} />
</group>
);
});
// Ambient particles for atmosphere
const AmbientParticles = memo(function AmbientParticles() {
const particlesRef = useRef<THREE.Points>(null);
const { positions, colors } = useMemo(() => {
const count = 500;
const positions = new Float32Array(count * 3);
const colors = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
positions[i * 3] = (Math.random() - 0.5) * 30;
positions[i * 3 + 1] = (Math.random() - 0.5) * 20;
positions[i * 3 + 2] = (Math.random() - 0.5) * 30;
colors[i * 3] = 0.3;
colors[i * 3 + 1] = 0.4;
colors[i * 3 + 2] = 0.5;
}
return { positions, colors };
}, []);
useFrame((state) => {
if (particlesRef.current) {
particlesRef.current.rotation.y = state.clock.elapsedTime * 0.02;
}
});
return (
<points ref={particlesRef}>
<bufferGeometry>
<bufferAttribute attach="attributes-position" args={[positions, 3]} />
<bufferAttribute attach="attributes-color" args={[colors, 3]} />
</bufferGeometry>
<pointsMaterial size={0.05} vertexColors transparent opacity={0.3} />
</points>
);
});
// 独立的 3D 场景组件,使用 memo 防止不必要的重渲染
export type PointCloudRenderMode = 'points' | 'smoothHeatmap';
type ServerCadHeatmapState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'ready'; dispMin: number; dispMax: number; vertexCount: number }
| { status: 'error' };
interface SceneContentProps {
pointCloudData?: PointCloudData | null;
pointSize: number;
showHeatmap: boolean;
pointCloudRenderMode: PointCloudRenderMode;
enablePointPicking: boolean;
showCADModel: boolean;
showWireframe: boolean;
cadModelUrl?: string;
cadModelFormat?: string;
onCadProgress?: (progress: number) => void;
onCadLoaded?: () => void;
onCadLoadError?: (message: string) => void;
onPointSelect?: (point: SelectedPoint | null) => void;
lightweightScene?: boolean;
cadHeatmapColorsReady?: boolean;
cadHeatmapSuppressClient?: boolean;
serverColorsRef?: React.RefObject<Float32Array | null>;
/** 热力图点击回调 */
onHeatmapPick?: (result: HeatmapPickResult | null) => void;
/** 偏差范围 */
dispRange?: { min: number; max: number } | null;
/** PLY 叠加层点云数据(kind=ply 时使用) */
plyOverlayData?: { positions: Float32Array; colors: Float32Array } | null;
/** ROI 坐标拾取模式 */
roiPickMode?: boolean;
onRoiPointPicked?: (point: { x: number; y: number; z: number }) => void;
}
const SceneContent = memo(function SceneContent({
pointCloudData,
pointSize,
showHeatmap,
pointCloudRenderMode,
enablePointPicking,
showCADModel,
showWireframe,
cadModelUrl,
cadModelFormat,
onCadProgress,
onCadLoaded,
onCadLoadError,
onPointSelect,
lightweightScene = false,
cadHeatmapColorsReady = false,
cadHeatmapSuppressClient = false,
serverColorsRef,
onHeatmapPick,
dispRange,
plyOverlayData = null,
roiPickMode = false,
onRoiPointPicked,
}: SceneContentProps) {
const pointPickActive = enablePointPicking && pointCloudRenderMode === 'points';
const [cadSurfaceGeometry, setCadSurfaceGeometry] = useState<THREE.BufferGeometry | null>(null);
const useDirectColoring =
pointCloudRenderMode === 'smoothHeatmap' &&
cadHeatmapColorsReady &&
!cadHeatmapSuppressClient;
// 命令式将颜色设到 geometry 上(通过 ref 读取,不经 React 序列化)
useEffect(() => {
if (!cadSurfaceGeometry) return;
const colors = serverColorsRef?.current;
const count = cadSurfaceGeometry.attributes.position?.count ?? 0;
if (useDirectColoring && colors && colors.length === count * 3) {
cadSurfaceGeometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
cadSurfaceGeometry.attributes.color.needsUpdate = true;
} else {
if (cadSurfaceGeometry.attributes.color) {
cadSurfaceGeometry.deleteAttribute('color');
}
}
}, [cadSurfaceGeometry, useDirectColoring, serverColorsRef]);
const cadShellHeatmapOn =
pointCloudRenderMode === 'smoothHeatmap' &&
Boolean(pointCloudData && cadSurfaceGeometry) &&
!cadHeatmapSuppressClient &&
!useDirectColoring;
const showCadReferenceMesh = (showCADModel && !cadShellHeatmapOn) || useDirectColoring;
return (
<>
<color attach="background" args={['#0d1117']} />
<fog attach="fog" args={['#0d1117', 20, 50]} />
<PerspectiveCamera makeDefault position={[12, 8, 12]} fov={45} />
<OrbitControls
enableDamping
dampingFactor={0.05}
rotateSpeed={0.5}
zoomSpeed={0.8}
panSpeed={0.5}
minDistance={5}
maxDistance={40}
mouseButtons={pointPickActive ? {
LEFT: -1 as any,
MIDDLE: THREE.MOUSE.DOLLY,
RIGHT: THREE.MOUSE.PAN,
} : {
LEFT: THREE.MOUSE.ROTATE,
MIDDLE: THREE.MOUSE.DOLLY,
RIGHT: THREE.MOUSE.PAN,
}}
/>
{/* Lighting - industrial style */}
<ambientLight intensity={0.3} color="#8899aa" />
<directionalLight position={[10, 15, 10]} intensity={0.5} color="#ffffff" />
<directionalLight position={[-10, 5, -10]} intensity={0.2} color="#4488cc" />
<pointLight position={[0, 10, 0]} intensity={0.3} color="#88aacc" />
{/* 大点云时跳过装饰粒子,省 Draw Call / 像素填充 */}
{!lightweightScene && <AmbientParticles />}
{/* Point cloud - 只有当有数据时才渲染,完全禁用事件以提高性能 */}
{pointCloudData && pointCloudRenderMode === 'points' && (
<PointCloudMesh
data={pointCloudData}
pointSize={pointSize}
showHeatmap={showHeatmap}
enablePointPicking={pointPickActive}
onPointSelect={onPointSelect}
/>
)}
{/* 无服务端色时才挂载 CadSurfaceHeatmapMesh(客户端 NN 路径) */}
{pointCloudData &&
pointCloudRenderMode === 'smoothHeatmap' &&
cadSurfaceGeometry &&
!cadHeatmapSuppressClient &&
!useDirectColoring && (
<CadSurfaceHeatmapMesh
cadTemplateGeometry={cadSurfaceGeometry}
data={pointCloudData}
showHeatmap={showHeatmap}
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 && (
<>
<PointCloudMesh
data={pointCloudData}
pointSize={Math.min(pointSize * 3.2, 0.14)}
showHeatmap={showHeatmap}
enablePointPicking={false}
errorRangeMode="percentile"
pointOpacity={showHeatmap ? 0.42 : 0.6}
/>
<SmoothHeatmapMesh
data={pointCloudData}
showHeatmap={showHeatmap}
showWireframe={showWireframe}
/>
</>
)}
<CADModel
visible={showCadReferenceMesh}
wireframe={showWireframe}
modelUrl={cadModelUrl}
modelFormat={cadModelFormat}
onProgress={onCadProgress}
onLoaded={onCadLoaded}
onLoadError={onCadLoadError}
onGeometryReady={setCadSurfaceGeometry}
useVertexColors={useDirectColoring}
onHeatmapPick={onHeatmapPick}
serverColorsRef={serverColorsRef}
dispRange={dispRange}
/>
{/* Selection indicator - 禁用以提高性能 */}
{/* <SelectionIndicator point={null} /> */}
{/* ROI 坐标拾取:透明大平面,激活时捕获点击 */}
{roiPickMode && (
<mesh
position={[0, 0, 0]}
onClick={(e) => {
e.stopPropagation();
onRoiPointPicked?.({ x: e.point.x, y: e.point.y, z: e.point.z });
}}
>
<planeGeometry args={[10000, 10000]} />
<meshBasicMaterial transparent opacity={0} side={THREE.DoubleSide} depthWrite={false} />
</mesh>
)}
{/* Grid */}
<IndustrialGrid />
{/* PLY 叠加层(kind=ply 插件结果) */}
{plyOverlayData && (
<points>
<bufferGeometry>
<bufferAttribute
attach="attributes-position"
args={[plyOverlayData.positions, 3]}
/>
<bufferAttribute
attach="attributes-color"
args={[plyOverlayData.colors, 3]}
/>
</bufferGeometry>
<pointsMaterial
size={pointSize ?? 0.03}
vertexColors
sizeAttenuation
transparent
opacity={0.95}
/>
</points>
)}
</>
);
}, (prevProps, nextProps) => {
return prevProps.pointCloudData === nextProps.pointCloudData &&
prevProps.pointSize === nextProps.pointSize &&
prevProps.showHeatmap === nextProps.showHeatmap &&
prevProps.pointCloudRenderMode === nextProps.pointCloudRenderMode &&
prevProps.enablePointPicking === nextProps.enablePointPicking &&
prevProps.showCADModel === nextProps.showCADModel &&
prevProps.showWireframe === nextProps.showWireframe &&
prevProps.cadModelUrl === nextProps.cadModelUrl &&
prevProps.cadModelFormat === nextProps.cadModelFormat &&
prevProps.lightweightScene === nextProps.lightweightScene &&
prevProps.cadHeatmapColorsReady === nextProps.cadHeatmapColorsReady &&
prevProps.cadHeatmapSuppressClient === nextProps.cadHeatmapSuppressClient &&
prevProps.dispRange === nextProps.dispRange &&
prevProps.plyOverlayData === nextProps.plyOverlayData &&
prevProps.roiPickMode === nextProps.roiPickMode;
});
/** 默认向服务端请求的采样预算;非全量点云,仅用于预览(全量可达数十亿点) */
const DEFAULT_RENDER_POINT_BUDGET = 400_000;
/**
* 解析 ASCII PLY 文件,提取顶点坐标和 RGB 颜色。
* 支持 property float x/y/z 和 property uchar/float red/green/blue。
*/
function parsePlyText(text: string): { positions: Float32Array; colors: Float32Array } | null {
try {
const lines = text.split('\n');
let headerEnd = -1;
let vertexCount = 0;
let hasColor = false;
let colorIsFloat = false;
const propOrder: string[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (line === 'end_header') { headerEnd = i; break; }
if (line.startsWith('element vertex')) vertexCount = parseInt(line.split(' ')[2], 10);
if (line.startsWith('property')) {
const parts = line.split(' ');
const type = parts[1];
const name = parts[2];
propOrder.push(name);
if (name === 'red' || name === 'green' || name === 'blue') {
hasColor = true;
if (type === 'float' || type === 'double') colorIsFloat = true;
}
}
}
if (headerEnd === -1 || vertexCount === 0) return null;
const xi = propOrder.indexOf('x');
const yi = propOrder.indexOf('y');
const zi = propOrder.indexOf('z');
const ri = propOrder.indexOf('red');
const gi = propOrder.indexOf('green');
const bi = propOrder.indexOf('blue');
const positions = new Float32Array(vertexCount * 3);
const colors = new Float32Array(vertexCount * 3);
const scale = colorIsFloat ? 1.0 : 1.0 / 255.0;
for (let i = 0; i < vertexCount; i++) {
const parts = lines[headerEnd + 1 + i].trim().split(/\s+/);
positions[i * 3] = parseFloat(parts[xi]);
positions[i * 3 + 1] = parseFloat(parts[yi]);
positions[i * 3 + 2] = parseFloat(parts[zi]);
if (hasColor && ri >= 0) {
colors[i * 3] = parseFloat(parts[ri]) * scale;
colors[i * 3 + 1] = parseFloat(parts[gi]) * scale;
colors[i * 3 + 2] = parseFloat(parts[bi]) * scale;
} else {
colors[i * 3] = 0.4; colors[i * 3 + 1] = 0.8; colors[i * 3 + 2] = 1.0;
}
}
return { positions, colors };
} catch {
return null;
}
}
/** 从 /api/files/read/pointcloud/xxx.txt URL 中提取文件名 */
function extractFilename(readUrl: string): string | null {
const marker = '/api/files/read/pointcloud/';
const idx = readUrl.indexOf(marker);
if (idx === -1) return null;
return readUrl.slice(idx + marker.length);
}
/**
* 批量采样接口(二进制版本):
* - 当传 taskId 时:服务端按 taskId 查 DB 获取 filePaths,按大小比例分配配额并 seek 采样
* - 当传 readUrls 时(兼容旧接口):提取文件名后同上
* - 返回二进制 Float32Array,格式 [x,y,z,error, ...],每点 16 字节
*/
async function fetchBatchSampled(
readUrlsOrTaskId: string[] | number,
totalPoints: number,
fileIds?: number[],
): Promise<PointCloudData> {
let body: Record<string, unknown>;
if (typeof readUrlsOrTaskId === 'number') {
body = { taskId: readUrlsOrTaskId, totalPoints };
// 传入选中文件 ID 时只采样那些文件
if (fileIds && fileIds.length > 0) body.fileIds = fileIds;
} else {
const filenames = readUrlsOrTaskId.map(extractFilename).filter(Boolean) as string[];
if (filenames.length === 0) return { points: [] };
body = { filenames, totalPoints };
}
const resp = await fetch('/api/files/sample/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!resp.ok) throw new Error(`Batch sample API error: ${resp.status}`);
// 打印服务端各文件采样情况
const statsHeader = resp.headers.get('X-File-Stats');
if (statsHeader) {
try {
const stats = JSON.parse(statsHeader) as { name: string; sizeGB: number; sampled: number }[];
for (const f of stats) {
console.log(`[PointCloudViewer] ${f.name}: ${f.sizeGB} GB → ${f.sampled} pts`);
}
} catch { /* ignore */ }
}
// 接收二进制,根据 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 / stride);
console.log(`[PointCloudViewer] Binary received: ${count} pts, hasColor=${hasColor}, ${(arrayBuffer.byteLength / 1e6).toFixed(1)} MB`);
if (count === 0) return { points: [] };
// 直接从 Float32Array 构建 Point[],同时计算边界盒
const pts: Point[] = new Array(count);
let minX = Infinity, minY = Infinity, minZ = Infinity;
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
for (let i = 0; i < count; i++) {
const base = i * stride;
const x = floats[base], y = floats[base + 1], z = floats[base + 2], error = floats[base + 3];
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;
}
return { points: pts, boundingBox: { minX, minY, minZ, maxX, maxY, maxZ } };
}
// Main viewer component
interface PointCloudViewerProps {
data?: PointCloudData;
pointCloudUrls?: string[]; // 点云文件 URL 列表(旧接口,可传空)
batchTaskId?: number; // 新接口:直接传任务 ID,服务端查 DB 取路径
/**
* 每次用户主动触发「加载」时递增。即使 batchTaskId / selectedPcFileIds 没变,
* loadKey 变化也会强制重新采样(解决重复点击同一任务不刷新的问题)。
*/
loadKey?: number;
showHeatmap?: boolean;
showCADModel?: boolean;
showWireframe?: boolean;
pointSize?: number;
cadModelUrl?: string;
cadModelFormat?: string;
onCadProgress?: (progress: number) => void;
onCadLoaded?: () => void;
onCadLoadError?: (message: string) => void;
onPointCloudProgress?: (progress: number) => void;
onPointCloudLoaded?: () => void;
onPointSelect?: (point: SelectedPoint | null) => void;
enableErrorPicking?: boolean;
className?: string;
/** 服务端随机采样预算,越大越清晰越卡(建议 20万~80万) */
renderPointBudget?: number;
/** 点云:逐点;平滑热力:有 CAD 时在 CAD 壳上着色,否则点云拼壳 */
pointCloudRenderMode?: PointCloudRenderMode;
/** 与 batchTaskId 对应:分析结果「平滑热力」完成后的结果 ID,用于拉取顶点色二进制 */
heatmapAnalysisResultId?: number | null;
/**
* 用户在 TaskManager 中勾选的点云文件 ID 列表。
* 为空/undefined 时加载任务下全部文件;有值时只加载选中的文件。
*/
selectedPcFileIds?: number[];
/**
* 算法插件叠加层(Route B)。
* 设计:让任意插件结果都能挂上 3D 视口,而不需要 PointCloudViewer 知道每种算法。
* 当 kind='vertexColors' 时复用 heatmap 渲染管线;当 kind='ply' 时(后续可扩展)
* 加载点云形式的着色叠加层。当前先支持 vertexColors 链路打通流程。
*/
pluginOverlay?: {
taskId: number;
resultId: number;
overlay:
| { kind: 'none' }
| { kind: 'vertexColors'; file: string; vertexCount: number; dispMin: number; dispMax: number }
| { kind: 'ply'; file: string };
} | null;
/**
* ROI 坐标拾取模式。激活时,用户在 3D 视图中点击任意点,
* 坐标通过 onRoiPointPicked 回调传出,然后自动退出拾取模式。
*/
roiPickMode?: boolean;
onRoiPointPicked?: (point: { x: number; y: number; z: number }) => void;
}
export default function PointCloudViewer({
data,
pointCloudUrls,
batchTaskId,
loadKey,
showHeatmap = true,
showCADModel = true,
showWireframe = false,
pointSize = 0.03,
cadModelUrl,
cadModelFormat,
onCadProgress,
onCadLoaded,
onCadLoadError,
onPointCloudProgress,
onPointCloudLoaded,
onPointSelect,
enableErrorPicking = false,
className = '',
renderPointBudget = DEFAULT_RENDER_POINT_BUDGET,
pointCloudRenderMode = 'points',
heatmapAnalysisResultId = null,
selectedPcFileIds,
pluginOverlay = null,
roiPickMode = false,
onRoiPointPicked,
}: PointCloudViewerProps) {
// 点云数据状态
const [loadedPointCloud, setLoadedPointCloud] = useState<PointCloudData | null>(null);
const [isLoadingPointCloud, setIsLoadingPointCloud] = useState(false);
// 进度回调放 ref,避免父组件传入的新函数引用触发重复拉取(依赖项不变才应加载一次)
const onPcProgressRef = useRef(onPointCloudProgress);
const onPcLoadedRef = useRef(onPointCloudLoaded);
onPcProgressRef.current = onPointCloudProgress;
onPcLoadedRef.current = onPointCloudLoaded;
// 加载点云文件
useEffect(() => {
// batchTaskId 优先;其次 pointCloudUrls
const hasTask = batchTaskId != null;
const hasUrls = pointCloudUrls && pointCloudUrls.length > 0;
if (!hasTask && !hasUrls) {
setLoadedPointCloud(null);
return;
}
let cancelled = false;
const loadPointClouds = async () => {
setIsLoadingPointCloud(true);
onPcProgressRef.current?.(0);
try {
onPcProgressRef.current?.(5);
const source = hasTask ? batchTaskId! : pointCloudUrls!;
const budget = Math.max(50_000, Math.min(2_000_000, renderPointBudget));
const pointCloud = await fetchBatchSampled(
source,
budget,
hasTask && selectedPcFileIds && selectedPcFileIds.length > 0 ? selectedPcFileIds : undefined,
);
if (cancelled) return;
onPcProgressRef.current?.(70);
const pts = pointCloud.points;
if (pts.length === 0) {
onPcProgressRef.current?.(100);
onPcLoadedRef.current?.();
return;
}
// 归一化到 [-5, 5] 范围方便渲染
let minX = Infinity, minY = Infinity, minZ = Infinity;
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
for (let i = 0; i < pts.length; i++) {
const p = pts[i];
if (p.x < minX) minX = p.x; if (p.x > maxX) maxX = p.x;
if (p.y < minY) minY = p.y; if (p.y > maxY) maxY = p.y;
if (p.z < minZ) minZ = p.z; if (p.z > maxZ) maxZ = p.z;
}
const centerX = (minX + maxX) / 2;
const centerY = (minY + maxY) / 2;
const centerZ = (minZ + maxZ) / 2;
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;
p.y = (p.y - centerY) * scale;
p.z = (p.z - centerZ) * scale;
}
setLoadedPointCloud({
points: pts,
boundingBox: {
minX: (minX - centerX) * scale,
minY: (minY - centerY) * scale,
minZ: (minZ - centerZ) * scale,
maxX: (maxX - centerX) * scale,
maxY: (maxY - centerY) * scale,
maxZ: (maxZ - centerZ) * scale,
},
});
onPcProgressRef.current?.(100);
console.log('[PointCloudViewer] Rendering', pts.length, 'pts');
onPcLoadedRef.current?.();
} catch (err) {
console.error('[PointCloudViewer] Error loading point clouds:', err);
} finally {
setIsLoadingPointCloud(false);
}
};
loadPointClouds();
return () => {
cancelled = true;
};
// 仅当「加载源」、文件选中集合、预算或 loadKey 变化时重新拉取;勿依赖回调引用
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [batchTaskId, pointCloudUrls, renderPointBudget, selectedPcFileIds, loadKey]);
// 优先使用加载的点云,其次使用传入的 data
const pointCloudData = loadedPointCloud || data;
const [serverCadHeatmap, setServerCadHeatmap] = useState<ServerCadHeatmapState>({ status: 'idle' });
// 颜色数据存 ref,绝不流经 React props(11M 顶点 ~131MB 会导致 React profiler OOM)
const serverColorsRef = useRef<Float32Array | null>(null);
// 插件叠加层(Route B)优先于 legacy heatmapAnalysisResultId
const pluginOverlayActive =
pluginOverlay != null &&
pluginOverlay.overlay.kind === 'vertexColors' &&
showHeatmap;
// 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 叠加层:走采样接口,避免浏览器加载 15GB 文件
useEffect(() => {
if (pluginOverlay == null || pluginOverlay.overlay.kind !== 'ply') {
setPlyOverlayData(null);
return;
}
const { taskId, resultId, overlay } = pluginOverlay;
const ac = new AbortController();
(async () => {
try {
// 用 /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 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);
}
})();
return () => ac.abort();
}, [pluginOverlay]);
useEffect(() => {
// 当插件叠加层激活时,跳过 legacy heatmap effect,避免与下面那个 effect 抢 ref
if (pluginOverlayActive) return;
if (
batchTaskId == null ||
heatmapAnalysisResultId == null ||
pointCloudRenderMode !== 'smoothHeatmap' ||
!showHeatmap
) {
serverColorsRef.current = null;
setServerCadHeatmap({ status: 'idle' });
return;
}
const ac = new AbortController();
serverColorsRef.current = null;
setServerCadHeatmap({ status: 'loading' });
(async () => {
try {
const resp = await fetch(
`/api/files/read/analysis/heatmap/${batchTaskId}/${heatmapAnalysisResultId}`,
{ signal: ac.signal },
);
if (!resp.ok) throw new Error(`heatmap artifact ${resp.status}`);
const vCount = parseInt(resp.headers.get('X-Vertex-Count') || '0', 10);
const dispMin = parseFloat(resp.headers.get('X-Disp-Min') || '0');
const dispMax = parseFloat(resp.headers.get('X-Disp-Max') || '0');
const buf = await resp.arrayBuffer();
const colors = new Float32Array(buf);
if (colors.length !== vCount * 3 || vCount < 1) {
console.warn('[Heatmap] color count mismatch', colors.length, vCount);
setServerCadHeatmap({ status: 'error' });
return;
}
serverColorsRef.current = colors;
setServerCadHeatmap({ status: 'ready', dispMin, dispMax, vertexCount: vCount });
} catch (e: unknown) {
if (e instanceof DOMException && e.name === 'AbortError') return;
console.warn('[Heatmap] fetch failed:', e);
setServerCadHeatmap({ status: 'error' });
}
})();
return () => ac.abort();
}, [batchTaskId, heatmapAnalysisResultId, pointCloudRenderMode, showHeatmap, pluginOverlayActive]);
// 插件叠加层(vertexColors):从通用产物路由拉取顶点色,复用 CAD heatmap 渲染管线
useEffect(() => {
if (!pluginOverlayActive) return;
const overlay = pluginOverlay!.overlay;
if (overlay.kind !== 'vertexColors') return;
const ac = new AbortController();
serverColorsRef.current = null;
setServerCadHeatmap({ status: 'loading' });
(async () => {
try {
const url = `/api/files/read/analysis/${pluginOverlay!.taskId}/${pluginOverlay!.resultId}/${encodeURI(overlay.file)}`;
const resp = await fetch(url, { signal: ac.signal });
if (!resp.ok) throw new Error(`plugin overlay ${resp.status}`);
const buf = await resp.arrayBuffer();
const colors = new Float32Array(buf);
const expected = overlay.vertexCount * 3;
if (colors.length !== expected || overlay.vertexCount < 1) {
console.warn('[PluginOverlay] color count mismatch', colors.length, expected);
setServerCadHeatmap({ status: 'error' });
return;
}
serverColorsRef.current = colors;
setServerCadHeatmap({
status: 'ready',
dispMin: overlay.dispMin,
dispMax: overlay.dispMax,
vertexCount: overlay.vertexCount,
});
} catch (e: unknown) {
if (e instanceof DOMException && e.name === 'AbortError') return;
console.warn('[PluginOverlay] fetch failed:', e);
setServerCadHeatmap({ status: 'error' });
}
})();
return () => ac.abort();
}, [pluginOverlayActive, pluginOverlay]);
const cadHeatmapSuppressClient =
batchTaskId != null &&
pointCloudRenderMode === 'smoothHeatmap' &&
(heatmapAnalysisResultId != null || pluginOverlayActive) &&
serverCadHeatmap.status !== 'ready';
const cadHeatmapColorsReady = serverCadHeatmap.status === 'ready';
const dispRange = serverCadHeatmap.status === 'ready'
? { min: serverCadHeatmap.dispMin, max: serverCadHeatmap.dispMax }
: null;
const [heatmapPick, setHeatmapPick] = useState<HeatmapPickResult | null>(null);
// 退出热力图模式时清除拾取
useEffect(() => {
if (pointCloudRenderMode !== 'smoothHeatmap' || !cadHeatmapColorsReady) {
setHeatmapPick(null);
}
}, [pointCloudRenderMode, cadHeatmapColorsReady]);
const heatmapLegendBounds = useMemo(() => {
if (!showHeatmap) return null;
const pts = pointCloudData?.points;
if (!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 };
}
return getHeatmapLegendBounds(pts, pointCloudRenderMode);
}, [showHeatmap, pointCloudData, pointCloudRenderMode, serverCadHeatmap]);
return (
<div className={`relative ${className}`} style={{ background: 'oklch(0.08 0.006 240)' }}>
{heatmapLegendBounds && (
<div className="pointer-events-none absolute right-3 bottom-12 z-10 flex flex-col items-end gap-1 pr-1">
<span className="text-[9px] font-medium uppercase tracking-wide text-foreground/80">
误差 mm
</span>
<div className="flex items-stretch gap-2 rounded border border-border/60 bg-[oklch(0.12_0.01_240)]/95 px-2 py-1.5 shadow-sm backdrop-blur-[2px]">
<div className="flex flex-col justify-between py-0.5 text-right font-mono text-[9px] leading-tight text-muted-foreground">
<span title="正偏差大">{heatmapLegendBounds.max.toFixed(3)}</span>
<span className="text-[8px] text-foreground/70" title="名义 / 零">
{heatmapLegendBounds.midTick}
</span>
<span title="负偏差大">{heatmapLegendBounds.min.toFixed(3)}</span>
</div>
<div
className="w-2.5 shrink-0 rounded-[2px] border border-border/50"
style={{
height: 112,
background:
'linear-gradient(to top, #2166ac 0%, #67a9cf 22%, #4daf4a 50%, #fee08b 78%, #d73027 100%)',
}}
title="蓝负 · 绿名义 · 红正"
/>
</div>
<span className="max-w-[5.5rem] text-right text-[8px] leading-tight text-muted-foreground/90">
蓝负 · 绿名义 · 红正
</span>
</div>
)}
{/* 热力图加载状态提示 */}
{pointCloudRenderMode === 'smoothHeatmap' && heatmapAnalysisResultId != null && !cadHeatmapColorsReady && (
<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">
<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">
{serverCadHeatmap.status === 'error' ? '加载热力数据失败' : '正在加载热力数据…'}
</div>
{serverCadHeatmap.status !== 'error' && (
<div className="text-[10px] text-muted-foreground mt-0.5">
请稍候,正在获取偏差颜色
</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]">
<div className="flex items-center justify-between gap-3 mb-1">
<span className="text-[10px] font-medium uppercase tracking-wide text-foreground/70">选点偏差</span>
<button
type="button"
className="text-muted-foreground hover:text-foreground text-xs leading-none"
onClick={() => setHeatmapPick(null)}
>
✕
</button>
</div>
<div className="font-mono text-lg font-semibold" style={{
color: heatmapPick.deviation > (dispRange?.max ?? 0) * 0.6
? '#d73027'
: heatmapPick.deviation < (dispRange?.min ?? 0) + ((dispRange?.max ?? 0) - (dispRange?.min ?? 0)) * 0.3
? '#2166ac'
: '#4daf4a',
}}>
{heatmapPick.deviation.toFixed(4)} <span className="text-xs font-normal text-muted-foreground">mm</span>
</div>
<div className="text-[9px] text-muted-foreground mt-1 space-y-0.5">
<div>坐标: ({heatmapPick.position.map(v => v.toFixed(2)).join(', ')})</div>
<div>面索引: {heatmapPick.faceIndex.toLocaleString()}</div>
</div>
</div>
)}
<Canvas
gl={{
antialias: false,
alpha: false,
powerPreference: 'high-performance',
stencil: false,
depth: true,
}}
dpr={1}
performance={{ min: 0.5 }}
style={{ cursor: roiPickMode ? 'crosshair' : undefined }}
>
<SceneContent
pointCloudData={pointCloudData}
pointSize={pointSize}
showHeatmap={showHeatmap}
pointCloudRenderMode={pointCloudRenderMode}
enablePointPicking={enableErrorPicking}
showCADModel={showCADModel}
showWireframe={showWireframe}
cadModelUrl={cadModelUrl}
cadModelFormat={cadModelFormat}
onCadProgress={onCadProgress}
onCadLoaded={onCadLoaded}
onCadLoadError={onCadLoadError}
onPointSelect={onPointSelect}
lightweightScene={Boolean(pointCloudData?.points?.length && pointCloudData.points.length > 50_000)}
cadHeatmapColorsReady={cadHeatmapColorsReady}
cadHeatmapSuppressClient={cadHeatmapSuppressClient}
serverColorsRef={serverColorsRef}
onHeatmapPick={setHeatmapPick}
dispRange={dispRange}
plyOverlayData={plyOverlayData}
roiPickMode={roiPickMode}
onRoiPointPicked={onRoiPointPicked}
/>
</Canvas>
</div>
);
}