Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,78 @@ 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.

## 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.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
91 changes: 91 additions & 0 deletions diag-edgekey-collision.mjs
Original file line number Diff line number Diff line change
@@ -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.');
Loading