feat: initial commit - massive-cloud point cloud processing system
This commit is contained in:
290
shared/cadHeatmapMath.ts
Normal file
290
shared/cadHeatmapMath.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* CAD 壳平滑热力:纯数学与色标(前后端共用,避免浏览器主线程重复实现)。
|
||||
*/
|
||||
|
||||
export interface HeatmapPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
error?: number;
|
||||
}
|
||||
|
||||
/** 平滑热力专用:用采样分位数做色标,减弱离群点 */
|
||||
export function displayErrorRangeSmoothHeatmap(points: HeatmapPoint[]): {
|
||||
minError: number;
|
||||
maxError: number;
|
||||
} {
|
||||
const cap = 12_000;
|
||||
const errs: number[] = [];
|
||||
const step = Math.max(1, Math.floor(points.length / cap));
|
||||
for (let i = 0; i < points.length; i += step) {
|
||||
errs.push(points[i].error ?? 0);
|
||||
}
|
||||
errs.sort((a, b) => a - b);
|
||||
const m = errs.length;
|
||||
const rawMin = errs[0] ?? 0;
|
||||
const rawMax = errs[m - 1] ?? rawMin;
|
||||
let lo = errs[Math.max(0, Math.floor(m * 0.04))] ?? 0;
|
||||
let hi = errs[Math.min(m - 1, Math.ceil(m * 0.96) - 1)] ?? lo;
|
||||
if (rawMin < 0 && lo > 0) {
|
||||
lo = rawMin;
|
||||
}
|
||||
if (rawMax > 0 && hi < 0) {
|
||||
hi = rawMax;
|
||||
}
|
||||
if (hi <= lo) return { minError: lo, maxError: lo + 1e-9 };
|
||||
return { minError: lo, maxError: hi };
|
||||
}
|
||||
|
||||
/** 偏差热力:距离数组的 4%–96% 分位数做色标 */
|
||||
export function displayDistanceRange(distances: number[]): {
|
||||
minDist: number;
|
||||
maxDist: number;
|
||||
} {
|
||||
if (distances.length === 0) return { minDist: 0, maxDist: 1e-9 };
|
||||
const sorted = distances.slice().sort((a, b) => a - b);
|
||||
const m = sorted.length;
|
||||
const lo = sorted[Math.max(0, Math.floor(m * 0.04))] ?? 0;
|
||||
let hi = sorted[Math.min(m - 1, Math.ceil(m * 0.96) - 1)] ?? lo;
|
||||
if (hi <= lo) hi = lo + 1e-9;
|
||||
return { minDist: lo, maxDist: hi };
|
||||
}
|
||||
|
||||
/** 偏差距离 → 冷暖色(蓝 → 绿 → 黄 → 红),t ∈ [0,1] */
|
||||
export function distanceToRgb(
|
||||
dist: number,
|
||||
minDist: number,
|
||||
maxDist: number,
|
||||
): [number, number, number] {
|
||||
const range = maxDist - minDist;
|
||||
if (!(range > 0)) return [0.4, 0.74, 0.39];
|
||||
const t = Math.max(0, Math.min(1, (dist - minDist) / range));
|
||||
return sequentialRampRgb(t);
|
||||
}
|
||||
|
||||
/** 顺序色标 t∈[0,1]:蓝(低) → 绿 → 黄 → 红(高) */
|
||||
export function sequentialRampRgb(t: number): [number, number, number] {
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
const stops: [number, number][] = [
|
||||
[0, 0x2166ac],
|
||||
[0.33, 0x4daf4a],
|
||||
[0.66, 0xfee08b],
|
||||
[1, 0xd73027],
|
||||
];
|
||||
if (t <= stops[0][0]) return hexToRgb(stops[0][1]);
|
||||
for (let i = 0; i < stops.length - 1; i++) {
|
||||
const [t0, h0] = stops[i];
|
||||
const [t1, h1] = stops[i + 1];
|
||||
if (t <= t1) {
|
||||
const s = (t - t0) / (t1 - t0);
|
||||
const [r0, g0, b0] = hexToRgb(h0);
|
||||
const [r1, g1, b1] = hexToRgb(h1);
|
||||
return [lerp(r0, r1, s), lerp(g0, g1, s), lerp(b0, b1, s)];
|
||||
}
|
||||
}
|
||||
return hexToRgb(stops[stops.length - 1][1]);
|
||||
}
|
||||
|
||||
function hexToRgb(h: number): [number, number, number] {
|
||||
return [((h >> 16) & 255) / 255, ((h >> 8) & 255) / 255, (h & 255) / 255];
|
||||
}
|
||||
|
||||
function lerp(a: number, b: number, t: number): number {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
/** 发散色标 t∈[-1,1] */
|
||||
export function divergingRampRgb(t: number): [number, number, number] {
|
||||
t = Math.max(-1, Math.min(1, t));
|
||||
const stops: [number, number][] = [
|
||||
[-1, 0x2166ac],
|
||||
[-0.45, 0x67a9cf],
|
||||
[0, 0x4daf4a],
|
||||
[0.45, 0xfee08b],
|
||||
[1, 0xd73027],
|
||||
];
|
||||
if (t <= stops[0][0]) {
|
||||
return hexToRgb(stops[0][1]);
|
||||
}
|
||||
for (let i = 0; i < stops.length - 1; i++) {
|
||||
const [t0, h0] = stops[i];
|
||||
const [t1, h1] = stops[i + 1];
|
||||
if (t <= t1) {
|
||||
const s = (t - t0) / (t1 - t0);
|
||||
const [r0, g0, b0] = hexToRgb(h0);
|
||||
const [r1, g1, b1] = hexToRgb(h1);
|
||||
return [lerp(r0, r1, s), lerp(g0, g1, s), lerp(b0, b1, s)];
|
||||
}
|
||||
}
|
||||
return hexToRgb(stops[stops.length - 1][1]);
|
||||
}
|
||||
|
||||
export function errorToRgb(
|
||||
error: number,
|
||||
minError: number,
|
||||
maxError: number,
|
||||
): [number, number, number] {
|
||||
const lo = minError;
|
||||
const hi = maxError;
|
||||
if (!(hi > lo)) {
|
||||
return [0.4, 0.74, 0.39];
|
||||
}
|
||||
let mid: number;
|
||||
let scale: number;
|
||||
if (lo < 0 && hi > 0) {
|
||||
mid = 0;
|
||||
scale = Math.max(-lo, hi, 1e-12);
|
||||
} else {
|
||||
mid = (lo + hi) / 2;
|
||||
scale = Math.max((hi - lo) / 2, 1e-12);
|
||||
}
|
||||
return divergingRampRgb((error - mid) / scale);
|
||||
}
|
||||
|
||||
/** 点云 3D 均匀网格,用于最近邻查询 */
|
||||
export interface PCGrid3D {
|
||||
cell: number;
|
||||
minX: number;
|
||||
minY: number;
|
||||
minZ: number;
|
||||
map: Map<string, number[]>;
|
||||
points: HeatmapPoint[];
|
||||
}
|
||||
|
||||
export function buildPCGrid3D(points: HeatmapPoint[]): PCGrid3D {
|
||||
let minX = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let minY = Infinity;
|
||||
let maxY = -Infinity;
|
||||
let minZ = Infinity;
|
||||
let maxZ = -Infinity;
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const p = points[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 n = points.length;
|
||||
const vol = (maxX - minX) * (maxY - minY) * (maxZ - minZ) || 1e-9;
|
||||
const cell = Math.max(1e-9, Math.cbrt(vol / Math.max(n, 1)) * 1.75);
|
||||
const invCell = 1 / cell;
|
||||
const map = new Map<string, number[]>();
|
||||
for (let i = 0; i < n; i++) {
|
||||
const p = points[i];
|
||||
const cx = Math.floor((p.x - minX) * invCell);
|
||||
const cy = Math.floor((p.y - minY) * invCell);
|
||||
const cz = Math.floor((p.z - minZ) * invCell);
|
||||
const k = `${cx},${cy},${cz}`;
|
||||
let arr = map.get(k);
|
||||
if (!arr) {
|
||||
arr = [];
|
||||
map.set(k, arr);
|
||||
}
|
||||
arr.push(i);
|
||||
}
|
||||
return { cell, minX, minY, minZ, map, points };
|
||||
}
|
||||
|
||||
export function nearestPointInGrid(
|
||||
g: PCGrid3D,
|
||||
x: number,
|
||||
y: number,
|
||||
z: number,
|
||||
excludeIdx: number = -1,
|
||||
): { idx: number; dist: number } | null {
|
||||
const { cell, minX, minY, minZ, map, points } = g;
|
||||
const cx = Math.floor((x - minX) / cell);
|
||||
const cy = Math.floor((y - minY) / cell);
|
||||
const cz = Math.floor((z - minZ) / cell);
|
||||
let bestD = Infinity;
|
||||
let bestIdx = -1;
|
||||
for (let R = 0; R <= 18; 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 === excludeIdx) continue;
|
||||
const p = points[idx];
|
||||
const d = Math.hypot(p.x - x, p.y - y, p.z - z);
|
||||
if (d < bestD) {
|
||||
bestD = d;
|
||||
bestIdx = idx;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (bestD < Infinity && R > 0 && R * cell > bestD + cell) break;
|
||||
}
|
||||
if (bestIdx < 0) return null;
|
||||
return { idx: bestIdx, dist: bestD };
|
||||
}
|
||||
|
||||
export function medianNearestNeighbor3D(
|
||||
points: HeatmapPoint[],
|
||||
grid: PCGrid3D,
|
||||
maxSamples?: number,
|
||||
): number {
|
||||
const n = points.length;
|
||||
if (n === 0) return grid.cell;
|
||||
const cap = maxSamples !== undefined ? Math.min(maxSamples, n) : n;
|
||||
const dists: number[] = [];
|
||||
if (cap >= n) {
|
||||
for (let i = 0; i < n; i++) {
|
||||
const p = points[i];
|
||||
const nn = nearestPointInGrid(grid, p.x, p.y, p.z, i);
|
||||
if (nn) dists.push(nn.dist);
|
||||
}
|
||||
} else {
|
||||
for (let s = 0; s < cap; s++) {
|
||||
const i = Math.min(n - 1, Math.floor(((s + 0.5) / cap) * n));
|
||||
const p = points[i];
|
||||
const nn = nearestPointInGrid(grid, p.x, p.y, p.z, i);
|
||||
if (nn) dists.push(nn.dist);
|
||||
}
|
||||
}
|
||||
if (dists.length === 0) return grid.cell;
|
||||
dists.sort((a, b) => a - b);
|
||||
return dists[Math.floor(dists.length / 2)] ?? grid.cell;
|
||||
}
|
||||
|
||||
export const CAD_HEATMAP_SHELL_OFFSET_FRAC = 0.0022;
|
||||
export const CAD_HEATMAP_MEDIAN_NN_SAMPLES = 8_192;
|
||||
export const CAD_HEATMAP_MAX_VERTICES = 120_000;
|
||||
|
||||
/** 与前端 PointCloudViewer 加载点云后一致的归一化(居中 + 缩放到约 10 单位) */
|
||||
export function normalizePointsLikeViewer<T extends { x: number; y: number; z: number }>(pts: T[]): void {
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let minZ = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
let 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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user