306 lines
9.7 KiB
TypeScript
306 lines
9.7 KiB
TypeScript
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);
|
||
}
|