feat: initial commit - massive-cloud point cloud processing system
This commit is contained in:
305
server/cadHeatmapCompute.ts
Normal file
305
server/cadHeatmapCompute.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
import { BufferGeometry, Group, Mesh, Vector3 } from 'three';
|
||||
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js';
|
||||
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js';
|
||||
import { mergeGeometries, mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
|
||||
import {
|
||||
buildPCGrid3D,
|
||||
CAD_HEATMAP_SHELL_OFFSET_FRAC,
|
||||
displayDistanceRange,
|
||||
displayErrorRangeSmoothHeatmap,
|
||||
distanceToRgb,
|
||||
errorToRgb,
|
||||
medianNearestNeighbor3D,
|
||||
nearestPointInGrid,
|
||||
normalizePointsLikeViewer,
|
||||
type HeatmapPoint,
|
||||
} from '../shared/cadHeatmapMath.js';
|
||||
import type { SampledPoint } from './pointcloudSample.js';
|
||||
|
||||
/**
|
||||
* 服务端平滑热力:默认「先求快、可接受稍粗网格」(与前端实时预览的 shared 常量可不同)。
|
||||
* - CAD_HEATMAP_MAX_VERTICES:焊接后顶点上限(默认 60000;要更细可设 120000)。
|
||||
* - CAD_HEATMAP_MEDIAN_NN_SAMPLES:尺度估计采样(默认 4096;要更稳可设 8192)。
|
||||
*/
|
||||
function parseHeatmapInt(envVal: string | undefined, fallback: number, min: number, max: number): number {
|
||||
if (envVal == null || envVal === '') return fallback;
|
||||
const n = Number(envVal);
|
||||
if (!Number.isFinite(n)) return fallback;
|
||||
return Math.min(max, Math.max(min, Math.floor(n)));
|
||||
}
|
||||
|
||||
/** 默认偏快:约半顶点上限,merge 更早停 */
|
||||
const SERVER_FAST_DEFAULT_MAX_VERTICES = 60_000;
|
||||
const SERVER_FAST_DEFAULT_MEDIAN_NN = 4_096;
|
||||
|
||||
const SERVER_MAX_VERTICES = parseHeatmapInt(
|
||||
process.env.CAD_HEATMAP_MAX_VERTICES,
|
||||
SERVER_FAST_DEFAULT_MAX_VERTICES,
|
||||
8_000,
|
||||
500_000,
|
||||
);
|
||||
const SERVER_MEDIAN_NN_SAMPLES = parseHeatmapInt(
|
||||
process.env.CAD_HEATMAP_MEDIAN_NN_SAMPLES,
|
||||
SERVER_FAST_DEFAULT_MEDIAN_NN,
|
||||
512,
|
||||
32_768,
|
||||
);
|
||||
|
||||
function normalizeCadGeometryLikeViewer(geo: BufferGeometry): void {
|
||||
geo.computeBoundingBox();
|
||||
geo.center();
|
||||
const box = geo.boundingBox;
|
||||
if (box) {
|
||||
const size = new Vector3();
|
||||
box.getSize(size);
|
||||
const maxDim = Math.max(size.x, size.y, size.z);
|
||||
if (maxDim > 0) {
|
||||
const s = 10 / maxDim;
|
||||
geo.scale(s, s, s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildHeatmapPoints(sampledPoints: SampledPoint[]): HeatmapPoint[] {
|
||||
const pts: HeatmapPoint[] = sampledPoints.map((p) => ({
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
z: p.z,
|
||||
error: p.error,
|
||||
}));
|
||||
normalizePointsLikeViewer(pts);
|
||||
return pts;
|
||||
}
|
||||
|
||||
function parseObjBufferToGeometry(buffer: Buffer): BufferGeometry | null {
|
||||
const text = buffer.toString('utf8');
|
||||
if (!text.trim()) return null;
|
||||
const loader = new OBJLoader();
|
||||
const group = loader.parse(text) as Group;
|
||||
const geometries: BufferGeometry[] = [];
|
||||
group.traverse((child) => {
|
||||
if (child instanceof Mesh && child.geometry) {
|
||||
geometries.push(child.geometry);
|
||||
}
|
||||
});
|
||||
if (geometries.length === 0) return null;
|
||||
if (geometries.length === 1) return geometries[0].clone();
|
||||
try {
|
||||
const merged = mergeGeometries(geometries, false);
|
||||
if (merged) return merged;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
return geometries[0].clone();
|
||||
}
|
||||
|
||||
/**
|
||||
* 对已解析的 BufferGeometry(尚未 normalizeCad)计算顶点色。
|
||||
*/
|
||||
function computeCadShellHeatmapCore(
|
||||
geo: BufferGeometry,
|
||||
pts: HeatmapPoint[],
|
||||
): { colors: Float32Array; dispMin: number; dispMax: number; vertexCount: number } | null {
|
||||
normalizeCadGeometryLikeViewer(geo);
|
||||
|
||||
try {
|
||||
mergeVertices(geo);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
geo.computeVertexNormals();
|
||||
|
||||
let weldTol = 2e-4;
|
||||
while (
|
||||
geo.attributes.position &&
|
||||
geo.attributes.position.count > SERVER_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;
|
||||
|
||||
const grid = buildPCGrid3D(pts);
|
||||
const nnMed = medianNearestNeighbor3D(pts, grid, SERVER_MEDIAN_NN_SAMPLES);
|
||||
const { minError: dispMin, maxError: dispMax } = displayErrorRangeSmoothHeatmap(pts);
|
||||
|
||||
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 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 {
|
||||
const err = pts[hit.idx].error ?? 0;
|
||||
const [r, g, b] = errorToRgb(err, dispMin, dispMax);
|
||||
colors[i3] = r;
|
||||
colors[i3 + 1] = g;
|
||||
colors[i3 + 2] = b;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
return {
|
||||
colors,
|
||||
dispMin,
|
||||
dispMax,
|
||||
vertexCount: nV,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 在服务端对 STL 与(已采样的)测点做与前端一致的 CAD 壳热力,返回每顶点 RGB。
|
||||
*/
|
||||
export function computeCadShellHeatmapFromStl(
|
||||
stlBuffer: Buffer,
|
||||
sampledPoints: SampledPoint[],
|
||||
): { colors: Float32Array; dispMin: number; dispMax: number; vertexCount: number } | null {
|
||||
if (sampledPoints.length < 1) return null;
|
||||
const pts = buildHeatmapPoints(sampledPoints);
|
||||
|
||||
const u8 = new Uint8Array(stlBuffer);
|
||||
const ab = u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength) as ArrayBuffer;
|
||||
const loader = new STLLoader();
|
||||
const geo = loader.parse(ab);
|
||||
return computeCadShellHeatmapCore(geo, pts);
|
||||
}
|
||||
|
||||
/** OBJ(多 mesh 会尝试合并) */
|
||||
export function computeCadShellHeatmapFromObj(
|
||||
objBuffer: Buffer,
|
||||
sampledPoints: SampledPoint[],
|
||||
): { colors: Float32Array; dispMin: number; dispMax: number; vertexCount: number } | null {
|
||||
if (sampledPoints.length < 1) return null;
|
||||
const pts = buildHeatmapPoints(sampledPoints);
|
||||
const geo = parseObjBufferToGeometry(objBuffer);
|
||||
if (!geo) return null;
|
||||
return computeCadShellHeatmapCore(geo, pts);
|
||||
}
|
||||
|
||||
// ─── 偏差热力:用顶点到最近点的距离着色 ────────────────────────────────────────
|
||||
|
||||
function computeDeviationHeatmapCore(
|
||||
geo: BufferGeometry,
|
||||
pts: HeatmapPoint[],
|
||||
): { colors: Float32Array; dispMin: number; dispMax: number; vertexCount: number } | null {
|
||||
normalizeCadGeometryLikeViewer(geo);
|
||||
|
||||
// 不做 mergeVertices —— 保持与客户端 Three.js Loader 一致的非索引顶点布局,
|
||||
// 使输出的颜色数组长度 == 客户端 rawCount,触发快速路径而非主线程 merge 卡死。
|
||||
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;
|
||||
|
||||
const grid = buildPCGrid3D(pts);
|
||||
const nnMed = medianNearestNeighbor3D(pts, grid, SERVER_MEDIAN_NN_SAMPLES);
|
||||
|
||||
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 maxBind = Math.max(nnMed * 14, diag * 0.045);
|
||||
const shellOffset = Math.max(diag * CAD_HEATMAP_SHELL_OFFSET_FRAC, nnMed * 0.35);
|
||||
|
||||
const distances: number[] = [];
|
||||
const vertexDists = new Float64Array(nV);
|
||||
|
||||
for (let i = 0; i < nV; i++) {
|
||||
const i3 = i * 3;
|
||||
const hit = nearestPointInGrid(grid, pos[i3], pos[i3 + 1], pos[i3 + 2]);
|
||||
if (hit && hit.dist <= maxBind) {
|
||||
vertexDists[i] = hit.dist;
|
||||
distances.push(hit.dist);
|
||||
} else {
|
||||
vertexDists[i] = -1;
|
||||
}
|
||||
}
|
||||
|
||||
const { minDist: dispMin, maxDist: dispMax } = displayDistanceRange(distances);
|
||||
|
||||
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 d = vertexDists[i];
|
||||
if (d < 0) {
|
||||
colors[i3] = noData[0];
|
||||
colors[i3 + 1] = noData[1];
|
||||
colors[i3 + 2] = noData[2];
|
||||
} else {
|
||||
const [r, g, b] = distanceToRgb(d, dispMin, dispMax);
|
||||
colors[i3] = r;
|
||||
colors[i3 + 1] = g;
|
||||
colors[i3 + 2] = b;
|
||||
}
|
||||
|
||||
if (nor && nor.length >= nV * 3) {
|
||||
pos[i3] = pos[i3] + nor[i3] * shellOffset;
|
||||
pos[i3 + 1] = pos[i3 + 1] + nor[i3 + 1] * shellOffset;
|
||||
pos[i3 + 2] = pos[i3 + 2] + nor[i3 + 2] * shellOffset;
|
||||
}
|
||||
}
|
||||
|
||||
posAttr.needsUpdate = true;
|
||||
|
||||
return { colors, dispMin, dispMax, vertexCount: nV };
|
||||
}
|
||||
|
||||
export function computeDeviationHeatmapFromStl(
|
||||
stlBuffer: Buffer,
|
||||
sampledPoints: SampledPoint[],
|
||||
): { colors: Float32Array; dispMin: number; dispMax: number; vertexCount: number } | null {
|
||||
if (sampledPoints.length < 1) return null;
|
||||
const pts = buildHeatmapPoints(sampledPoints);
|
||||
const u8 = new Uint8Array(stlBuffer);
|
||||
const ab = u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength) as ArrayBuffer;
|
||||
const loader = new STLLoader();
|
||||
const geo = loader.parse(ab);
|
||||
return computeDeviationHeatmapCore(geo, pts);
|
||||
}
|
||||
|
||||
export function computeDeviationHeatmapFromObj(
|
||||
objBuffer: Buffer,
|
||||
sampledPoints: SampledPoint[],
|
||||
): { colors: Float32Array; dispMin: number; dispMax: number; vertexCount: number } | null {
|
||||
if (sampledPoints.length < 1) return null;
|
||||
const pts = buildHeatmapPoints(sampledPoints);
|
||||
const geo = parseObjBufferToGeometry(objBuffer);
|
||||
if (!geo) return null;
|
||||
return computeDeviationHeatmapCore(geo, pts);
|
||||
}
|
||||
Reference in New Issue
Block a user