/** * 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; 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(); 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(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; } }