2082 lines
71 KiB
TypeScript
2082 lines
71 KiB
TypeScript
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>
|
||
);
|
||
}
|