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(); 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; 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(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 ( ); }, (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 ( ); }, (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 ( ); }, (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; /** 偏差范围 */ 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(null); const meshRef = useRef(null); const onHeatmapPickRef = useRef(onHeatmapPick); onHeatmapPickRef.current = onHeatmapPick; const [geometry, setGeometry] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(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(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 ( {geometry && ( { 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} > )} ); }); // Selection indicator interface SelectionIndicatorProps { point: SelectedPoint | null; } function SelectionIndicator({ point }: SelectionIndicatorProps) { const ringRef = useRef(null); useFrame((state) => { if (ringRef.current) { ringRef.current.rotation.z = state.clock.elapsedTime * 2; } }); if (!point) return null; return ( ); } // Grid helper - industrial style const IndustrialGrid = memo(function IndustrialGrid() { return ( ); }); // Ambient particles for atmosphere const AmbientParticles = memo(function AmbientParticles() { const particlesRef = useRef(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 ( ); }); // 独立的 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; /** 热力图点击回调 */ 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(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 ( <> {/* Lighting - industrial style */} {/* 大点云时跳过装饰粒子,省 Draw Call / 像素填充 */} {!lightweightScene && } {/* Point cloud - 只有当有数据时才渲染,完全禁用事件以提高性能 */} {pointCloudData && pointCloudRenderMode === 'points' && ( )} {/* 无服务端色时才挂载 CadSurfaceHeatmapMesh(客户端 NN 路径) */} {pointCloudData && pointCloudRenderMode === 'smoothHeatmap' && cadSurfaceGeometry && !cadHeatmapSuppressClient && !useDirectColoring && ( )} {pointCloudData && pointCloudRenderMode === 'smoothHeatmap' && cadSurfaceGeometry && cadHeatmapSuppressClient && ( )} {pointCloudData && pointCloudRenderMode === 'smoothHeatmap' && !cadSurfaceGeometry && ( <> )} {/* Selection indicator - 禁用以提高性能 */} {/* */} {/* ROI 坐标拾取:透明大平面,激活时捕获点击 */} {roiPickMode && ( { e.stopPropagation(); onRoiPointPicked?.({ x: e.point.x, y: e.point.y, z: e.point.z }); }} > )} {/* Grid */} {/* PLY 叠加层(kind=ply 插件结果) */} {plyOverlayData && ( )} ); }, (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 { let body: Record; 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(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({ status: 'idle' }); // 颜色数据存 ref,绝不流经 React props(11M 顶点 ~131MB 会导致 React profiler OOM) const serverColorsRef = useRef(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(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 (
{heatmapLegendBounds && (
误差 mm
{heatmapLegendBounds.max.toFixed(3)} {heatmapLegendBounds.midTick} {heatmapLegendBounds.min.toFixed(3)}
蓝负 · 绿名义 · 红正
)} {/* 热力图加载状态提示 */} {pointCloudRenderMode === 'smoothHeatmap' && heatmapAnalysisResultId != null && !cadHeatmapColorsReady && (
{serverCadHeatmap.status === 'error' ? '加载热力数据失败' : '正在加载热力数据…'}
{serverCadHeatmap.status !== 'error' && (
请稍候,正在获取偏差颜色
)}
)} {/* 热力图点击拾取信息 */} {heatmapPick && (
选点偏差
(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)} mm
坐标: ({heatmapPick.position.map(v => v.toFixed(2)).join(', ')})
面索引: {heatmapPick.faceIndex.toLocaleString()}
)} 50_000)} cadHeatmapColorsReady={cadHeatmapColorsReady} cadHeatmapSuppressClient={cadHeatmapSuppressClient} serverColorsRef={serverColorsRef} onHeatmapPick={setHeatmapPick} dispRange={dispRange} plyOverlayData={plyOverlayData} roiPickMode={roiPickMode} onRoiPointPicked={onRoiPointPicked} />
); }