From af432b81dafa0243ae7e8375bd60128dc43539a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 01:22:30 +0200 Subject: [PATCH 1/2] fix: edge keys lose precision above 2.1M vertices, breaking repair and its check meshRepair packs an edge as `a * 4294967296 + b` (a * 2^32 + b) into a JS number. float64 holds that exactly only while it stays inside 2^53, i.e. up to a = 2^21 = 2,097,152 vertices. Above that distinct edges collide onto one key. diag-edgekey-collision.mjs (added) builds a closed torus whose manifoldness follows from its grid topology rather than from any measurement, so a counter that disagrees is wrong by construction: 1.54M vertices -> 0 non-manifold edges (below the threshold) 2.52M vertices -> 210,422 non-manifold edges (mesh is perfect) 3.74M vertices -> 819,608 non-manifold edges (mesh is perfect) In countEdgeDefects the collision is a false alarm on a good file: a 400mm sphere exported at 0.35mm / 5M triangles reported 185,146 non-manifold edges that staged measurement showed were not in the mesh at any pipeline stage. In resolveTJunctions it is a correctness bug. A genuine boundary edge (count 1) colliding with another reads as count 2, so its T-junction is silently left unrepaired, and decoding the key back with `b = k % 4294967296` yields vertex ids that were never on that edge, feeding wrong candidates to the split search. Both now key on exact Int32 pairs via a new IntPairMap in meshIndex.js, over a dense edge table. This also removes a second ceiling in countEdgeDefects, which counted in a JS Map and throws past V8's ~16.7M entry cap (~11M triangles). Below 2.1M vertices the old keys were exact, so this is a no-op there: pipeline fingerprints are unchanged across sphere, cube-with-fillets, cone, holed-plate and sliver-strip inputs at 10 model/texture/resolution combinations. --- CONTEXT.md | 27 +++++++++++ diag-edgekey-collision.mjs | 91 +++++++++++++++++++++++++++++++++++++ js/meshIndex.js | 92 ++++++++++++++++++++++++++++++++++++++ js/meshRepair.js | 84 +++++++++++++++++++++++++++------- 4 files changed, 277 insertions(+), 17 deletions(-) create mode 100644 diag-edgekey-collision.mjs diff --git a/CONTEXT.md b/CONTEXT.md index 6d72724..6f73480 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -32,3 +32,30 @@ decimation/bottom-snap folds; the cross-module grid differences above are a suspected contributor. If unifying grids is ever attempted, it is a behaviour change — verify with the export→import round-trip, not the in-memory mesh. + +## Edge keys must be exact integers (`js/meshIndex.js`, `js/meshRepair.js`) + +Do **not** pack a vertex-id pair into one JS number as `a * 2**32 + b`. float64 +carries 53 bits of integer precision, so that form is exact only up to +`a = 2^21 = 2,097,152` — above it distinct edges collide onto one key, silently, +and only on meshes big enough that nobody verifies by hand. + +`meshRepair.js` used to do this in both `countEdgeDefects` and +`resolveTJunctions`, with different severities: + +* **countEdgeDefects** — colliding edges sum their incidence counts and trip the + `> 2` non-manifold test, so a *good* export is reported as broken. Measured on + a torus that is manifold by grid construction: 2.52 M vertices reported + 210,422 phantom non-manifold edges, 3.74 M reported 819,608. +* **resolveTJunctions** — worse, because it repairs rather than measures. A real + boundary edge (count 1) that collides reads as count 2 and its T-junction is + left unrepaired; and decoding the key back (`b = k % 4294967296`) returns + vertex ids that were never on that edge. + +Both now use `IntPairMap` (Int32 pair keys) over a dense edge table. Below the +2.1 M threshold the old keys were exact, so the change is a no-op there — which +is what the pipeline fingerprints confirm. + +`diag-edgekey-collision.mjs` reproduces the failure and is the regression test: +it builds meshes whose manifoldness is guaranteed by topology, not measured, so +any counter that disagrees is wrong by construction. diff --git a/diag-edgekey-collision.mjs b/diag-edgekey-collision.mjs new file mode 100644 index 0000000..61f8556 --- /dev/null +++ b/diag-edgekey-collision.mjs @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2026 CNCKitchen (Stefan Hermann) and contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +// countEdgeDefects / resolveTJunctions key an edge as `x * 4294967296 + y` +// (x * 2^32 + y) in a JS number. That is exact only while x * 2^32 + y stays +// within float64's 2^53 integer range, i.e. up to x = 2^21 = 2,097,152 +// vertices. Past that, DISTINCT edges collide onto one key, their incidence +// counts add up, and the sum trips the `> 2` test — so a perfectly manifold +// mesh is reported as having non-manifold edges. +// +// This builds a closed torus (manifold by construction: every edge has exactly +// two incident faces, guaranteed by the grid topology, not by measurement) at a +// range of sizes and compares the packed-float key against exact integer keys. +// +// node --max-old-space-size=8000 diag-edgekey-collision.mjs +import * as THREE from 'three'; +import { countEdgeDefects } from './js/meshRepair.js'; +import { IntPairMap } from './js/meshIndex.js'; + +// Exact reference counter: integer pair keys, no float packing, no Map cap. +function exactDefects(geometry, Q = 1e4) { + const p = geometry.attributes.position.array, n = p.length / 9; + const vmap = new IntPairMap(Math.ceil(n * 2)); + // Quantise to the same grid, then id via a 3-key weld built from two pairs. + const idOf = new Map(); + const id = new Int32Array(n * 3); + let next = 0; + for (let i = 0; i < n * 3; i++) { + const k = Math.round(p[i*3]*Q) + '/' + Math.round(p[i*3+1]*Q) + '/' + Math.round(p[i*3+2]*Q); + let v = idOf.get(k); + if (v === undefined) { v = next++; idOf.set(k, v); } + id[i] = v; + } + const edges = new IntPairMap(Math.ceil(n * 1.6)); + let nE = 0; + let counts = new Int32Array(Math.ceil(n * 1.8) + 16); + for (let t = 0; t < n; t++) { + const a = id[t*3], b = id[t*3+1], c = id[t*3+2]; + if (a === b || b === c || a === c) continue; + const tri = [a, b, c]; + for (let e = 0; e < 3; e++) { + const x = tri[e], y = tri[(e+1)%3]; + const lo = x < y ? x : y, hi = x < y ? y : x; + const s = edges.getOrSet(lo, hi, nE); + if (edges.inserted) { + if (nE >= counts.length) { const g = new Int32Array(counts.length*2); g.set(counts); counts = g; } + nE++; + } + counts[s]++; + } + } + let open = 0, nonManifold = 0; + for (let i = 0; i < nE; i++) { if (counts[i] === 1) open++; else if (counts[i] > 2) nonManifold++; } + return { open, nonManifold, verts: next }; +} + +// Closed torus: a full grid wrap in both directions, so EVERY edge borders +// exactly two faces. Manifold by topology, independent of any counter. +function torus(nu, nv, R = 100, r = 30) { + const pos = new Float32Array(nu * nv * 2 * 9); + let o = 0; + const P = (i, j) => { + const u = 2*Math.PI*(i % nu)/nu, v = 2*Math.PI*(j % nv)/nv; + return [(R + r*Math.cos(v))*Math.cos(u), (R + r*Math.cos(v))*Math.sin(u), r*Math.sin(v)]; + }; + for (let i = 0; i < nu; i++) for (let j = 0; j < nv; j++) { + const a = P(i,j), b = P(i+1,j), c = P(i+1,j+1), d = P(i,j+1); + for (const t of [[a,b,c],[a,c,d]]) for (const p of t) { pos[o++]=p[0]; pos[o++]=p[1]; pos[o++]=p[2]; } + } + const g = new THREE.BufferGeometry(); + g.setAttribute('position', new THREE.BufferAttribute(pos, 3)); + return g; +} + +console.log('Closed torus — every edge has exactly 2 incident faces by construction.\n'); +console.log(' vertices tris | upstream countEdgeDefects | exact integer keys'); +console.log(' ' + '-'.repeat(72)); +for (const [nu, nv] of [[400,300],[900,700],[1400,1100],[1800,1400],[2200,1700]]) { + const g = torus(nu, nv); + const tris = g.attributes.position.count / 3; + let up; + try { up = countEdgeDefects(g); } catch (e) { up = { err: e.message }; } + const ex = exactDefects(g); + const upStr = up.err ? `THREW: ${up.err}` : `open=${String(up.open).padStart(6)} nonManifold=${String(up.nonManifold).padStart(9)}`; + console.log(` ${String(ex.verts).padStart(8)} ${String(tris).padStart(8)} | ${upStr.padEnd(37)} | open=${ex.open} nonManifold=${ex.nonManifold}`); + g.dispose(); +} +console.log('\n float64 keeps x*2^32+y exact only to x = 2^21 = 2,097,152 vertices.'); +console.log(' Above that the upstream counter reports defects a manifold mesh does not have.'); diff --git a/js/meshIndex.js b/js/meshIndex.js index 9ec840d..7f87ce0 100644 --- a/js/meshIndex.js +++ b/js/meshIndex.js @@ -108,6 +108,98 @@ export class QuantizedPointMap { } } +/** + * IntPairMap — open-addressing map keyed by an ORDERED PAIR OF INTEGERS. + * + * Several places key on two vertex ids rather than a 3-D point — edge tables, + * above all. The tempting shortcut is to pack the pair into one JS number as + * `a * 2**32 + b` and use it in a Map. **That is only correct up to + * a = 2^21 = 2,097,152.** float64 carries 53 bits of integer precision, so + * beyond that the packed value rounds and distinct pairs collide — silently, + * and only on meshes large enough that nobody checks by hand. meshRepair.js + * did exactly this and reported hundreds of thousands of non-manifold edges on + * meshes that were provably manifold (see diag-edgekey-collision.mjs). + * + * This class keeps the two components as separate Int32s, so the key is exact + * for every id the pipeline can produce (< 2^31), and it has no equivalent of + * a JS Map's ~16.7 M entry cap. + * + * It is also 12 bytes per slot against QuantizedPointMap's 28 (3 × Float64 key + * + Int32 value), which matters wherever an integer-pair table is sized by + * triangle count. + * + * Values must be integers in [0, 2^31-1]; -1 is the "empty" sentinel and is + * what get() returns on a miss — same contract as QuantizedPointMap. + */ +export class IntPairMap { + /** + * @param {number} expected – expected number of unique pairs (sizing hint) + */ + constructor(expected = 256) { + /** true when the last getOrSet() inserted a new key */ + this.inserted = false; + this._size = 0; + let cap = 16; + const target = Math.max(16, Math.ceil(expected / 0.6)); + while (cap < target) cap *= 2; + this._alloc(cap); + } + + get size() { return this._size; } + + _alloc(cap) { + this._cap = cap; + this._mask = cap - 1; + this._k1 = new Int32Array(cap); + this._k2 = new Int32Array(cap); + this._val = new Int32Array(cap).fill(-1); + } + + _slot(a, b) { + let h = Math.imul(a, 0x9E3779B1) ^ Math.imul(b, 0x85EBCA77); + h ^= h >>> 15; + let i = h & this._mask; + const k1 = this._k1, k2 = this._k2, val = this._val, mask = this._mask; + while (val[i] !== -1) { + if (k1[i] === a && k2[i] === b) return i; + i = (i + 1) & mask; + } + return i; + } + + _grow() { + const ok1 = this._k1, ok2 = this._k2, oval = this._val, ocap = this._cap; + this._alloc(ocap * 2); + for (let i = 0; i < ocap; i++) { + if (oval[i] === -1) continue; + const s = this._slot(ok1[i], ok2[i]); + this._k1[s] = ok1[i]; this._k2[s] = ok2[i]; this._val[s] = oval[i]; + } + } + + /** Value stored for the pair (a,b), or -1 if absent. */ + get(a, b) { + return this._val[this._slot(a, b)]; + } + + /** + * Return the value already stored for (a,b); if absent, store `value` and + * return it. `this.inserted` tells which case occurred. + */ + getOrSet(a, b, value) { + const i = this._slot(a, b); + const existing = this._val[i]; + if (existing !== -1) { + this.inserted = false; + return existing; + } + this._k1[i] = a; this._k2[i] = b; this._val[i] = value; + this.inserted = true; + if (++this._size > this._cap * 0.7) this._grow(); + return value; + } +} + /** * Weld a non-indexed position buffer: assign each vertex the sequential id of * its quantised position (first occurrence wins). diff --git a/js/meshRepair.js b/js/meshRepair.js index 3dcf3d7..81fdb67 100644 --- a/js/meshRepair.js +++ b/js/meshRepair.js @@ -32,7 +32,7 @@ * @returns {THREE.BufferGeometry} repaired non-indexed geometry */ import { THREE } from './threeCompat.js'; -import { QuantizedPointMap } from './meshIndex.js'; +import { QuantizedPointMap, IntPairMap } from './meshIndex.js'; /** * Count open (1-face) and non-manifold (3+-face) edges of a non-indexed geometry, @@ -46,19 +46,40 @@ export function countEdgeDefects(geometry, Q = 1e4) { for (let i = 0; i < n * 3; i++) { id[i] = vmap.getOrSet(p[i*3], p[i*3+1], p[i*3+2], vmap.size); } - const ec = new Map(); + // Exact integer pair keys over typed arrays. The previous implementation + // packed each edge as `x * 4294967296 + y` into a JS number and counted in a + // Map, which failed twice on large exports: + // + // * float64 holds x * 2^32 + y exactly only up to x = 2^21 = 2,097,152 + // vertices. Beyond that distinct edges share a key and their incidence + // counts add, so the `> 2` test fires on a perfectly manifold mesh — + // measured on a torus that is manifold by construction, 2.52 M vertices + // reported 210,422 phantom non-manifold edges and 3.74 M reported + // 819,608. Exports were being declared broken when they were fine. + // * a JS Map throws past V8's ~16.7 M entry cap, i.e. above ~11 M + // triangles, which the pipeline can now reach. + const edges = new IntPairMap(Math.max(16, n * 2)); + let nE = 0; + let counts = new Int32Array(n * 3 + 16); for (let t = 0; t < n; t++) { const a = id[t*3], b = id[t*3+1], c = id[t*3+2]; if (a === b || b === c || a === c) continue; - const tri = [a, b, c]; for (let e = 0; e < 3; e++) { - const x = tri[e], y = tri[(e+1)%3]; - const key = x < y ? x * 4294967296 + y : y * 4294967296 + x; - ec.set(key, (ec.get(key) || 0) + 1); + const x = e === 0 ? a : e === 1 ? b : c; + const y = e === 0 ? b : e === 1 ? c : a; + const lo = x < y ? x : y, hi = x < y ? y : x; + const s = edges.getOrSet(lo, hi, nE); + if (edges.inserted) { + if (nE >= counts.length) { + const g = new Int32Array(counts.length * 2); g.set(counts); counts = g; + } + nE++; + } + counts[s]++; } } let open = 0, nonManifold = 0; - for (const c of ec.values()) { if (c === 1) open++; else if (c > 2) nonManifold++; } + for (let i = 0; i < nE; i++) { if (counts[i] === 1) open++; else if (counts[i] > 2) nonManifold++; } return { open, nonManifold, tris: n }; } @@ -133,21 +154,50 @@ export function resolveTJunctions(geometry, opts = {}) { faces.push([a, b, c]); } - const ekey = (a, b) => (a < b ? a * 4294967296 + b : b * 4294967296 + a); - for (let iter = 0; iter < maxIters; iter++) { - // Edge → adjacent-face count + sample. - const eCount = new Map(); + // Edge → adjacent-face count, in a dense table keyed on the EXACT integer + // pair. The previous key packed the pair into one JS number as + // `a * 4294967296 + b`, which float64 represents exactly only while the + // product stays inside 2^53 — i.e. up to a = 2^21 = 2,097,152 vertices. + // Above that distinct edges land on the same key and their counts merge, + // which here is a CORRECTNESS bug and not merely a reporting one: a real + // boundary edge (count 1) colliding with another reads as count 2, so its + // T-junction is never repaired, and decoding the key back into (a, b) — + // `b = k % 4294967296` — yields vertex ids that were never on that edge. + // Measured on a torus that is manifold by construction: 2.52 M vertices + // reported 210,422 phantom non-manifold edges, 3.74 M reported 819,608. + const eMap = new IntPairMap(Math.max(16, faces.length * 2)); + let nE = 0; + let eLo = new Int32Array(faces.length * 3 + 16); + let eHi = new Int32Array(eLo.length); + let eCnt = new Int32Array(eLo.length); + const edgeSlot = (a, b) => { + const lo = a < b ? a : b, hi = a < b ? b : a; + const s = eMap.getOrSet(lo, hi, nE); + if (eMap.inserted) { + if (nE >= eLo.length) { + const grow = (o) => { const g = new Int32Array(o.length * 2); g.set(o); return g; }; + eLo = grow(eLo); eHi = grow(eHi); eCnt = grow(eCnt); + } + eLo[nE] = lo; eHi[nE] = hi; nE++; + } + return s; + }; + const edgeCount = (a, b) => { + const lo = a < b ? a : b, hi = a < b ? b : a; + const s = eMap.get(lo, hi); + return s === -1 ? 0 : eCnt[s]; + }; + for (let fi = 0; fi < faces.length; fi++) { const f = faces[fi]; - for (let e = 0; e < 3; e++) eCount.set(ekey(f[e], f[(e+1)%3]), (eCount.get(ekey(f[e], f[(e+1)%3])) || 0) + 1); + for (let e = 0; e < 3; e++) eCnt[edgeSlot(f[e], f[(e+1)%3])]++; } // Boundary edges (exactly one face) and the set of boundary vertices. const bverts = new Set(); - for (const [k, c] of eCount) { - if (c !== 1) continue; - const b = k % 4294967296, a = (k - b) / 4294967296; - bverts.add(a); bverts.add(b); + for (let s = 0; s < nE; s++) { + if (eCnt[s] !== 1) continue; + bverts.add(eLo[s]); bverts.add(eHi[s]); } if (bverts.size === 0) break; const bvArr = [...bverts]; @@ -159,7 +209,7 @@ export function resolveTJunctions(geometry, opts = {}) { const f = faces[fi]; for (let e = 0; e < 3; e++) { const a = f[e], b = f[(e+1)%3]; - if ((eCount.get(ekey(a, b)) || 0) !== 1) continue; // only boundary edges + if (edgeCount(a, b) !== 1) continue; // only boundary edges const ax = vx[a], ay = vy[a], az = vz[a]; const ex = vx[b]-ax, ey = vy[b]-ay, ez = vz[b]-az; const elen2 = ex*ex + ey*ey + ez*ez; From 9cdf9910f8778c079683fe6c98d699d605d6a9b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 01:27:32 +0200 Subject: [PATCH 2/2] perf: halve pipeline peak memory, with bit-identical output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Profiling the export pipeline with live typed-array accounting (process.memoryUsage().arrayBuffers + .heapUsed sampled every 5ms; RSS overstates by ~30% because V8 does not return freed pages promptly) put the peak at 660 bytes per subdivided triangle, all of it in decimation — not the "~145 B/tri" the subdivision safety-cap comment assumes. Peak per subdivided triangle, sphere at 3.29M triangles: stage before after subdivide 178 147 displace 254 216 decimate 660 327 All of it is allocation sizing and lifetime; none of it changes geometry. - SoAHeap was sized at 3F entries, then rounded up to a power of two. Seeding pushes one entry per UNIQUE edge (1.5F by Euler), so a 4.9M-entry heap was allocated as 16.7M slots x 48B = 805MB. Capacity is only a bound in push() — nothing masks on it — so the pow2 rounding was pure waste too. - buildIndexed allocated `positions` at the corner count and returned a subarray VIEW, keeping a 6x-oversized buffer reachable for the entire run (237MB to store 39MB). Grows on demand, returns a copy. - slotFace[s] is always (s/3)|0 and faceSlot[s] only ever held s or -1; the first is gone and the second is a Uint8Array flag (-24 B/tri). - seedSeen and vertMap sizing hints were 2-6x over, each tipping its table over a power-of-two doubling; seedSeen is also freed after seeding rather than held through the collapse loop. - decimate(..., releaseInput) lets the caller hand over the input geometry, so its 72 B/tri is not held across the collapse loop. dispose() cannot do this: it frees GPU resources, not the JS typed arrays. - subdivision's splitEdges/midCache move to IntPairMap (12 B/slot vs 28). Verified with bench-pipeline.mjs: 10/10 fingerprints identical to before, across sphere, cube-with-fillets, cone, holed-plate and sliver-strip inputs. --- CONTEXT.md | 48 +++++++++++++++++ README.md | 2 + js/decimation.js | 123 +++++++++++++++++++++++++++++++------------ js/exportPipeline.js | 6 ++- js/subdivision.js | 20 ++++--- 5 files changed, 156 insertions(+), 43 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 6f73480..71f003b 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -59,3 +59,51 @@ is what the pipeline fingerprints confirm. `diag-edgekey-collision.mjs` reproduces the failure and is the regression test: it builds meshes whose manifoldness is guaranteed by topology, not measured, so any counter that disagrees is wrong by construction. + +## Integer-pair tables also save memory (`js/meshIndex.js`) + +`IntPairMap` exists for correctness (see above), but it is also 12 bytes per +slot against `QuantizedPointMap`'s 28, which matters wherever such a table is +sized by triangle count: + +| Call site | Table | +|-----------|-------| +| `subdivision.js` | `splitEdges` (marked edges), `midCache` (midpoint ids) | +| `decimation.js` | `seedSeen` (edge-seeding dedup) | + +Do not swap `IntPairMap` in where coordinates are the key: it does no +quantisation. + +## Pipeline peak memory — measure, don't estimate + +Decimation is the peak stage in every configuration measured. Measure with +`process.memoryUsage().arrayBuffers + .heapUsed`, **not RSS** — V8 does not +return freed pages to the OS promptly and RSS overstates the peak by ~30 %. + +Measured peak per subdivided triangle (sphere, 3.29 M triangles): + +| Stage | before | after | +|-------|--------|-------| +| subdivide | 178 | 147 | +| displace | 254 | 216 | +| decimate | **660** | **327** | + +Where the decimation savings came from, all behaviour-preserving: + +* **SoAHeap capacity.** Seeding pushes one entry per *unique* edge — 1.5 F by + Euler, not the 3 F edge slots the face loop visits — and the constructor then + rounded up to a power of two. A 4.9 M-entry heap was allocated as 16.7 M + slots × 48 B = 805 MB. Capacity is only a bound in `push()`; nothing masks on + it, so it need not be a power of two. +* **`buildIndexed` positions.** Allocated at the corner count and returned as a + `subarray` **view**, so a 6× oversized buffer stayed reachable for the whole + run (237 MB holding 39 MB). Grows on demand, returns a copy. +* **`slotFace` / `faceSlot`.** Slots are assigned `s = f*3+k` and never + renumbered, so `slotFace[s]` is always `(s/3)|0`; `faceSlot[s]` only ever held + `s` or `-1`, i.e. one bit. Both gone (−24 B/tri). +* **`decimate(…, releaseInput)`.** `buildIndexed` is the only reader of the + input geometry; when the caller discards it anyway, dropping the attributes + releases 72 B per input triangle for the whole collapse loop. `dispose()` + cannot do this — it frees GPU resources, not the JS typed arrays. + +Verify any change here with `bench-pipeline.mjs` fingerprints, not by eye. diff --git a/README.md b/README.md index a4f2185..57fc359 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Load an STL, OBJ, 3MF, or STEP file, pick a texture, tune the parameters, and ex ## Recent Updates +- Roughly 2× more triangles for the same memory — pipeline peak cut from ~660 to ~330 bytes per subdivided triangle, with bit-identical output - STEP import (`.step` / `.stp`) via [meshStep](https://github.com/CNCKitchen/meshStep) - Save / load project files (`.bumpmesh`) - Undo / redo history @@ -115,6 +116,7 @@ js/ displacement.js # Vertex displacement baking subdivision.js # Adaptive mesh subdivision decimation.js # QEM mesh decimation + meshIndex.js # Shared vertex welding + integer-pair hash maps exclusion.js # Face exclusion / inclusion painting exporter.js # Binary STL export i18n.js # Translations (EN / DE) diff --git a/js/decimation.js b/js/decimation.js index 930fa99..16b02c2 100644 --- a/js/decimation.js +++ b/js/decimation.js @@ -53,7 +53,7 @@ */ import { THREE } from './threeCompat.js'; -import { QuantizedPointMap } from './meshIndex.js'; +import { QuantizedPointMap, IntPairMap } from './meshIndex.js'; // Vertex-weld quantisation for buildIndexed. 1e6 → 1 nm cells, finer than the // float32 resolution of the incoming positions, so it behaves as exact-float @@ -113,9 +113,21 @@ function _yieldFrame() { // ── Public API ─────────────────────────────────────────────────────────────── -export async function decimate(geometry, targetTriangles, onProgress, harvestFlat = true, harvestTol = DEFAULT_HARVEST_TOL, lockedFaces = null) { +export async function decimate(geometry, targetTriangles, onProgress, harvestFlat = true, harvestTol = DEFAULT_HARVEST_TOL, lockedFaces = null, releaseInput = false) { const { positions, faces, vertCount, faceCount } = buildIndexed(geometry); + // buildIndexed is the ONLY reader of `geometry`; everything below works off + // the indexed copy and buildOutput allocates fresh arrays. When the caller + // has no further use for the input (it disposes it the moment we return), + // dropping the attributes here releases the non-indexed position+normal + // buffers — 72 B per input triangle — for the whole collapse loop, which is + // by far the longest-lived phase of the pipeline. dispose() alone cannot do + // this: it frees GPU resources, not the JS typed arrays. + if (releaseInput) { + geometry.deleteAttribute('position'); + geometry.deleteAttribute('normal'); + } + // Already at/under the target: nothing to decimate. But if harvesting is on we // still run — there may be flat faces collapsible for free even below the limit. if (faceCount <= targetTriangles && !harvestFlat) return buildOutput(positions, faces, faceCount); @@ -157,7 +169,7 @@ export async function decimate(geometry, targetTriangles, onProgress, harvestFla addCreaseQuadrics(quadrics, positions, faces, faceCount); // Doubly-linked vertex-face incidence (typed arrays — faster than Set) - const { vfHead, slotFace, slotVert, slotNext, slotPrev, faceSlot } = + const { vfHead, slotVert, slotNext, slotPrev, slotLive } = buildLinkedAdj(faces, faceCount, vertCount); const active = new Uint8Array(vertCount).fill(1); @@ -176,8 +188,22 @@ export async function decimate(geometry, targetTriangles, onProgress, harvestFla // Seed min-heap with one entry per unique edge. Dedup via the integer // pair-keyed hash map (no V8 Set entry cap, no per-key boxing); seeding // order over faces/edges is unchanged. - const heap = new SoAHeap(Math.min(faceCount * 3, 1 << 24)); - const seedSeen = new QuantizedPointMap(1, Math.min(faceCount * 3, 1 << 22)); + // + // Sizing: a closed manifold triangle mesh has exactly 1.5 F unique edges + // (Euler), so seeding pushes ~1.5 F entries, NOT the 3 F edge slots the + // face loop visits. The old `SoAHeap(faceCount * 3)` therefore asked for 2× + // what it needs, and SoAHeap's constructor then rounded that up to the next + // power of two — at 3.3 M triangles a 4.9 M-entry heap was allocated as + // 16.7 M slots × 48 B = 805 MB, over half the whole pipeline's peak. Both + // the 2× and the power-of-two rounding are now gone; 1.6 F leaves headroom + // for open/non-manifold inputs and the heap still grows on demand. + const heap = new SoAHeap(Math.min(Math.ceil(faceCount * 1.6) + 16, 1 << 26)); + // Sizing hint is the Euler edge count exactly (1.5 F). IntPairMap rounds the + // /0.6 load-factor target up to a power of two, so padding the hint the way + // the heap's is padded would tip it over the next doubling and cost 2× the + // slots for nothing — the table grows on its own if an open or non-manifold + // input pushes past the load factor. + let seedSeen = new IntPairMap(Math.min(Math.ceil(faceCount * 1.5), 1 << 23)); for (let f = 0; f < faceCount; f++) { if (faces[f * 3] < 0) continue; for (let e = 0; e < 3; e++) { @@ -185,10 +211,13 @@ export async function decimate(geometry, targetTriangles, onProgress, harvestFla const vb = faces[f * 3 + ((e + 1) % 3)]; if (lockedVert && (lockedVert[va] || lockedVert[vb])) continue; const lo = va < vb ? va : vb, hi = va < vb ? vb : va; - seedSeen.getOrSet(lo, hi, 0, 1); + seedSeen.getOrSet(lo, hi, 1); if (seedSeen.inserted) pushEdge(heap, quadrics, positions, version, va, vb); } } + // Seeding is the only consumer — drop the table before the collapse loop so + // its slots are not held for the (much longer) rest of the run. + seedSeen = null; const initFaces = activeFaces; // Progress denominator: triangles to remove to reach the target. When already @@ -240,14 +269,14 @@ export async function decimate(geometry, targetTriangles, onProgress, harvestFla // Single pass combines the old shareActiveFace + isBoundaryEdge: // 0 → stale entry, 1 → boundary edge (Guard 1), ≥2 → safe to continue - const nsh = sharedFaceCount(faces, vfHead, slotFace, slotNext, v1, v2); + const nsh = sharedFaceCount(faces, vfHead, slotNext, v1, v2); if (nsh < 2) continue; // ── Three safety guards ─────────────────────────────────────────────────── lkEpoch += 2; // +2 so ep and ep+1 never collide with the next call - if (hasLinkViolation(faces, vfHead, slotFace, slotNext, v1, v2, lkStamp, lkEpoch)) continue; // Guard 2 - if (checkFlipped(positions, vfHead, slotFace, slotNext, faces, v1, v2, px, py, pz)) continue; // Guard 3a - if (checkFlipped(positions, vfHead, slotFace, slotNext, faces, v2, v1, px, py, pz)) continue; // Guard 3b + if (hasLinkViolation(faces, vfHead, slotNext, v1, v2, lkStamp, lkEpoch)) continue; // Guard 2 + if (checkFlipped(positions, vfHead, slotNext, faces, v1, v2, px, py, pz)) continue; // Guard 3a + if (checkFlipped(positions, vfHead, slotNext, faces, v2, v1, px, py, pz)) continue; // Guard 3b // ── Collapse: keep v1 at new position, remove v2 ───────────────────────── positions[v1 * 3] = px; @@ -259,7 +288,7 @@ export async function decimate(geometry, targetTriangles, onProgress, harvestFla // Walk v2's face list; read sNext BEFORE modifying the list. let s = vfHead[v2]; while (s >= 0) { - const f = slotFace[s]; + const f = (s / 3) | 0; const sNext = slotNext[s]; // must be read before any list modification if (faces[f * 3] >= 0) { // Remap v2 → v1 in this face @@ -269,8 +298,8 @@ export async function decimate(geometry, targetTriangles, onProgress, harvestFla if (fa === fb || fb === fc || fa === fc) { // Degenerate: unlink all 3 slots from their current vertex lists for (let k = 0; k < 3; k++) { - const sk = faceSlot[f*3+k]; - if (sk >= 0) { _unlinkSlot(sk, vfHead, slotNext, slotPrev, slotVert); faceSlot[f*3+k] = -1; } + const sk = f*3+k; + if (slotLive[sk]) { _unlinkSlot(sk, vfHead, slotNext, slotPrev, slotVert); slotLive[sk] = 0; } } faces[f*3] = faces[f*3+1] = faces[f*3+2] = -1; activeFaces--; @@ -287,7 +316,7 @@ export async function decimate(geometry, targetTriangles, onProgress, harvestFla // Re-push edges for v1's updated neighbourhood (stamp dedup — no new Set) epoch++; for (let sv = vfHead[v1]; sv >= 0; sv = slotNext[sv]) { - const f = slotFace[sv]; + const f = (sv / 3) | 0; if (faces[f*3] < 0) continue; for (let k = 0; k < 3; k++) { const nb = faces[f*3+k]; @@ -313,36 +342,41 @@ export async function decimate(geometry, targetTriangles, onProgress, harvestFla // position k, slot s = f*3+k tracks face f in vertex v = faces[f*3+k]'s list. // // vfHead[v] → first slot for vertex v (-1 = empty) -// slotFace[s] → face tracked by slot s // slotVert[s] → vertex that currently owns slot s // slotNext[s] → next slot in vertex's list (-1 = end) // slotPrev[s] → prev slot in vertex's list (-1 = head) -// faceSlot[f*3+k] → slot for face f's k-th vertex incidence +// slotLive[s] → 1 while slot s is linked into some vertex's list +// +// Two Int32Arrays that used to live here are gone, saving 8 B per face slot +// (24 B/triangle) on top of the reduced allocation churn: +// +// slotFace[s] – redundant: slots are assigned s = f*3+k and never +// renumbered, so the owning face is always (s/3)|0. +// faceSlot[f*3+k] – only ever held `s` (== f*3+k) or -1, i.e. one bit of +// information; it is now the Uint8Array slotLive above. function buildLinkedAdj(faces, faceCount, vertCount) { const S = faceCount * 3; const vfHead = new Int32Array(vertCount).fill(-1); - const slotFace = new Int32Array(S); const slotVert = new Int32Array(S); const slotNext = new Int32Array(S).fill(-1); const slotPrev = new Int32Array(S).fill(-1); - const faceSlot = new Int32Array(S).fill(-1); + const slotLive = new Uint8Array(S); for (let f = 0; f < faceCount; f++) { if (faces[f * 3] < 0) continue; for (let k = 0; k < 3; k++) { const v = faces[f * 3 + k]; const s = f * 3 + k; - slotFace[s] = f; slotVert[s] = v; const h = vfHead[v]; slotNext[s] = h; slotPrev[s] = -1; if (h >= 0) slotPrev[h] = s; vfHead[v] = s; - faceSlot[f * 3 + k] = s; + slotLive[s] = 1; } } - return { vfHead, slotFace, slotVert, slotNext, slotPrev, faceSlot }; + return { vfHead, slotVert, slotNext, slotPrev, slotLive }; } // Remove slot s from its current vertex's list (slotVert[s] identifies the vertex). @@ -366,10 +400,10 @@ function _moveSlot(s, nv, vfHead, slotNext, slotPrev, slotVert) { // ── Guard 0+1: combined shareActiveFace + isBoundaryEdge ───────────────────── // Returns 0 = stale entry, 1 = boundary edge, ≥2 = safe to proceed. -function sharedFaceCount(faces, vfHead, slotFace, slotNext, v1, v2) { +function sharedFaceCount(faces, vfHead, slotNext, v1, v2) { let count = 0; for (let s = vfHead[v1]; s >= 0; s = slotNext[s]) { - const f = slotFace[s]; + const f = (s / 3) | 0; if (faces[f * 3] < 0) continue; const fa = faces[f*3], fb = faces[f*3+1], fc = faces[f*3+2]; if (fa === v2 || fb === v2 || fc === v2) { if (++count >= 2) return 2; } @@ -386,10 +420,10 @@ function sharedFaceCount(faces, vfHead, slotFace, slotNext, v1, v2) { // the subset of these that produce identical triangles. O(valence) via stamps. // lkStamp[w] === ep → w is a one-ring neighbour of v1 // lkStamp[w] === ep + 1 → w is a legal shared-face apex (allowed) -function hasLinkViolation(faces, vfHead, slotFace, slotNext, v1, v2, lkStamp, ep) { +function hasLinkViolation(faces, vfHead, slotNext, v1, v2, lkStamp, ep) { // Pass 1: stamp every one-ring neighbour of v1. for (let s = vfHead[v1]; s >= 0; s = slotNext[s]) { - const f = slotFace[s]; if (faces[f*3] < 0) continue; + const f = (s / 3) | 0; if (faces[f*3] < 0) continue; const a = faces[f*3], b = faces[f*3+1], c = faces[f*3+2]; if (a !== v1) lkStamp[a] = ep; if (b !== v1) lkStamp[b] = ep; @@ -398,7 +432,7 @@ function hasLinkViolation(faces, vfHead, slotFace, slotNext, v1, v2, lkStamp, ep // Pass 2: promote shared-face apexes to ep+1 (legal) and count shared faces. let shared = 0; for (let s = vfHead[v1]; s >= 0; s = slotNext[s]) { - const f = slotFace[s]; if (faces[f*3] < 0) continue; + const f = (s / 3) | 0; if (faces[f*3] < 0) continue; const a = faces[f*3], b = faces[f*3+1], c = faces[f*3+2]; if (a === v2 || b === v2 || c === v2) { shared++; @@ -410,7 +444,7 @@ function hasLinkViolation(faces, vfHead, slotFace, slotNext, v1, v2, lkStamp, ep // Pass 3: a neighbour of v2 that is a v1-neighbour (ep) but not a shared apex // (ep+1) is an illegal common neighbour → collapse would be non-manifold. for (let s = vfHead[v2]; s >= 0; s = slotNext[s]) { - const f = slotFace[s]; if (faces[f*3] < 0) continue; + const f = (s / 3) | 0; if (faces[f*3] < 0) continue; const a = faces[f*3], b = faces[f*3+1], c = faces[f*3+2]; if (a !== v2 && a !== v1 && lkStamp[a] === ep) return true; if (b !== v2 && b !== v1 && lkStamp[b] === ep) return true; @@ -425,9 +459,9 @@ function hasLinkViolation(faces, vfHead, slotFace, slotNext, v1, v2, lkStamp, ep // dot(on_norm, nn_norm) < FLIP_DOT // ⟺ rawDot < 0 OR rawDot² < FLIP_DOT² · |on|² · |nn|² -function checkFlipped(positions, vfHead, slotFace, slotNext, faces, vc, vo, npx, npy, npz) { +function checkFlipped(positions, vfHead, slotNext, faces, vc, vo, npx, npy, npz) { for (let s = vfHead[vc]; s >= 0; s = slotNext[s]) { - const f = slotFace[s]; + const f = (s / 3) | 0; if (faces[f * 3] < 0) continue; const fa = faces[f*3], fb = faces[f*3+1], fc = faces[f*3+2]; if (fa === vo || fb === vo || fc === vo) continue; @@ -679,16 +713,33 @@ function buildIndexed(geometry) { const posAttr = geometry.attributes.position; const n = posAttr.count; - const positions = new Float64Array(n * 3); // over-allocated, trimmed later + // `positions` grows on demand instead of being allocated at the vertex-slot + // count n. A closed manifold welds 3 F corners down to ~F/2 vertices, i.e. + // n/6, so the old `new Float64Array(n * 3)` was ~6× oversized — and because + // the result was returned as a `subarray` VIEW, the whole oversized buffer + // stayed reachable for the entire decimation (237 MB held to store 39 MB at + // 3.3 M triangles). Start at the manifold estimate with slack and grow 1.5×. + let posCap = Math.max(1024, Math.ceil(n / 5)); + let positions = new Float64Array(posCap * 3); const indexRemap = new Int32Array(n); let vertCount = 0; - const vertMap = new QuantizedPointMap(QUANT, Math.min(n, 1 << 22)); + // Hint the welded vertex count, not the corner count. Welding 3 F corners of + // a closed manifold yields ~n/6 unique vertices; hinting `n` sized the table + // 6× too large (235 MB at 3.3 M triangles). n/4 keeps slack for open and + // soup-like inputs, and the table doubles itself if even that is short. + const vertMap = new QuantizedPointMap(QUANT, Math.min(Math.ceil(n / 4), 1 << 22)); for (let i = 0; i < n; i++) { const x = posAttr.getX(i), y = posAttr.getY(i), z = posAttr.getZ(i); const idx = vertMap.getOrSet(x, y, z, vertCount); if (vertMap.inserted) { + if (vertCount >= posCap) { + posCap = Math.ceil(posCap * 1.5) + 16; + const grown = new Float64Array(posCap * 3); + grown.set(positions); + positions = grown; + } vertCount++; positions[idx * 3] = x; positions[idx * 3 + 1] = y; @@ -701,7 +752,8 @@ function buildIndexed(geometry) { const faces = new Int32Array(faceCount * 3); for (let i = 0; i < n; i++) faces[i] = indexRemap[i]; - return { positions: positions.subarray(0, vertCount * 3), faces, vertCount, faceCount }; + // Copy (not subarray) so the growth slack is released with the old buffer. + return { positions: positions.slice(0, vertCount * 3), faces, vertCount, faceCount }; } // (adjacency helpers replaced by buildLinkedAdj and _unlinkSlot/_moveSlot above) @@ -756,8 +808,11 @@ function buildOutput(positions, faces, faceCount) { const SOA_GROW = 1.5; class SoAHeap { constructor(initialCap = 65536) { - let cap = 2; - while (cap <= initialCap) cap <<= 1; + // Capacity is used only as a bound in push() — nothing here masks or + // wraps on it — so it does NOT need to be a power of two. Rounding up to + // one wasted between 0 and 2× the requested slots (48 B each) on every + // run; the caller's estimate is close enough and _grow() covers overruns. + const cap = Math.max(4, (initialCap | 0) + 2); this._cap = cap; this._len = 0; this._cost = new Float64Array(cap); diff --git a/js/exportPipeline.js b/js/exportPipeline.js index dc5b059..6a0c649 100644 --- a/js/exportPipeline.js +++ b/js/exportPipeline.js @@ -302,7 +302,11 @@ export async function runExportPipeline(input, onEvent = () => {}, shouldAbort = (p) => onEvent('decimate', p, { from: dispTriCount, needsDecimation }), settings.harvestFlatFaces, settings.harvestTol, - lockedFaces + lockedFaces, + // releaseInput: `displaced` is disposed on the next line and never read + // again, so decimate may drop its buffers as soon as it has indexed + // them instead of holding them for the whole collapse loop. + true ); // Capture before repair replaces the geometry (userData isn't carried over). lockedOverBudget = !!finalGeometry.userData.lockedOverBudget; diff --git a/js/subdivision.js b/js/subdivision.js index 3a30e92..fdc8aac 100644 --- a/js/subdivision.js +++ b/js/subdivision.js @@ -18,7 +18,7 @@ */ import { THREE } from './threeCompat.js'; -import { QuantizedPointMap } from './meshIndex.js'; +import { QuantizedPointMap, IntPairMap } from './meshIndex.js'; // 10 µm vertex-dedup cells. Below 1e5 (= 100 µm) small-fillet meshes have // distinct fillet vertices that round to the same key and merge incorrectly, @@ -180,7 +180,11 @@ function subdividePass(verts, indices, maxEdgeLength, safetyCap, faceExcluded = // Midpoint cache keyed by the RAW (unordered) parent-vertex pair — sharp-edge // cluster copies of the same position get their own midpoints (different // normals), exactly as before. - const midCache = new QuantizedPointMap(1, 1 << 16); + // Both tables below are keyed on a pair of vertex ids, so they use the + // Int32-keyed IntPairMap (12 B/slot) rather than QuantizedPointMap's + // 3 × Float64 layout (28 B/slot) — on dense passes these are the largest + // structures in the subdivider after the index buffers themselves. + const midCache = new IntPairMap(1 << 16); // verts.pos/canon are safe to cache for reads of pre-pass vertices: growth // reallocates but copies, and steps 1/1.5 only touch pre-pass indices. @@ -193,15 +197,15 @@ function subdividePass(verts, indices, maxEdgeLength, safetyCap, faceExcluded = // Keys are the (lo, hi) id pair fed to an integer-keyed hash set — no V8 // Set/Map entry cap, so very dense passes no longer need a RangeError // bail-out (the predicted-count cap below handles oversized passes). - const splitEdges = new QuantizedPointMap(1, 1 << 16); + const splitEdges = new IntPairMap(1 << 16); const markEdge = (a, b) => { const u = canonIdx ? canonIdx[a] : a, v = canonIdx ? canonIdx[b] : b; - if (u < v) splitEdges.getOrSet(u, v, 0, 1); - else splitEdges.getOrSet(v, u, 0, 1); + if (u < v) splitEdges.getOrSet(u, v, 1); + else splitEdges.getOrSet(v, u, 1); }; const isMarked = (a, b) => { const u = canonIdx ? canonIdx[a] : a, v = canonIdx ? canonIdx[b] : b; - return (u < v ? splitEdges.get(u, v, 0) : splitEdges.get(v, u, 0)) !== -1; + return (u < v ? splitEdges.get(u, v) : splitEdges.get(v, u)) !== -1; }; // ── Step 1: globally mark edges that need splitting ───────────────────── @@ -368,7 +372,7 @@ function edgeLenSq(pos, a, b) { function getMidpoint(verts, cache, a, b, posCanonMap) { const lo = a < b ? a : b, hi = a < b ? b : a; - const cached = cache.get(lo, hi, 0); + const cached = cache.get(lo, hi); if (cached !== -1) return cached; const pos = verts.pos, nrm = verts.nrm; @@ -396,7 +400,7 @@ function getMidpoint(verts, cache, a, b, posCanonMap) { } verts.count = idx + 1; - cache.getOrSet(lo, hi, 0, idx); + cache.getOrSet(lo, hi, idx); return idx; }