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
133 changes: 133 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,136 @@ 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.

## The quality ceiling is a memory budget (`js/memoryBudget.js`)

The subdivision safety cap is **derived**, not hardcoded:
`cap = budgetBytes / PIPELINE_BYTES_PER_TRIANGLE`, bounded by a second,
independent ceiling. It replaced a fixed 16M/32M pair whose justifying comment
assumed 145 B/tri when the real figure was 660 — so those caps were
unreachable, and an export hit the allocator instead of the "cap".

`PIPELINE_BYTES_PER_TRIANGLE` is 384: the **browser** figure (RSS of every
Chromium process during a real export, minus the idle baseline — 15.0 M
subdivided triangles, 665 MB to 6165 MB). Node's live typed-array accounting
gives 327; the browser number is deliberately preferred, because this guard
exists to stop an export before the allocator does and the allocator charges
for the footprint, not for the subset V8 calls live.

**If you change an allocation in `decimation.js`, `subdivision.js` or
`displacement.js`, re-measure and update `PIPELINE_BYTES_PER_TRIANGLE`.** It is
the only thing standing between the user and an out-of-memory tab.

### Two ceilings — total memory AND per-allocation

| Ceiling | Constant | Binds at |
|---------|----------|----------|
| Total footprint | `PIPELINE_BYTES_PER_TRIANGLE` (384) | budget ÷ 384 |
| One typed array | `MAX_SINGLE_BUFFER_BYTES_PER_TRIANGLE` (48) vs 2^31-1 B | ~44.7 M triangles |

The second is **not about how much RAM the machine has**. V8 caps a single
typed array at 2^31-1 bytes (measured: the largest allocatable Float32Array is
2046 MB, page and worker alike). The pipeline's biggest single allocations are
decimation's `quadrics` (Float64Array(V*10) = 40 B/tri) and `toNonIndexed`'s
position/normal buffers (Float32Array(T*9) = 36 B/tri each).

Found the hard way: a 32 GB budget nominally allowed 101 M triangles,
subdivision reached 65 M, and `toNonIndexed` then asked for a 2.34 GB
Float32Array and threw `Array buffer allocation failed` — killing the export
after minutes of work, on a machine with 30 GB free. Going out-of-core is the
only way past ~45 M triangles.

`navigator.deviceMemory` is clamped to 8 by its own specification, so it cannot
distinguish an 8 GB laptop from a 512 GB workstation. The budget is therefore a
user setting with a conservative automatic default, not a detection.

### Allocation failures degrade, they do not throw

`subdivide()` rolls back to the last complete level on an allocation failure
(`isAllocationFailure` matches the V8/Spidermonkey/JSC wordings; anything else
re-throws) and reports it as `safetyCapHit`, which the caller already surfaces
as "coarser than requested". With the structural cap in place this path should
be unreachable in Chromium — **it is defensive and currently unexercised by any
test**; it exists because engine limits differ and losing a finished
multi-minute subdivision to one failed allocation is not acceptable.

The page owns the budget (localStorage); the pipeline may run in a worker with
no localStorage, so the resolved cap travels as `settings.subdivisionCap`, where
`0` means "use the auto-detected default". `diag-quality-ceiling.mjs` shows
which ceiling binds at a given part size; `diag-manifold-stages.mjs` reports
edge defects after each stage.
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ Load an STL, OBJ, 3MF, or STEP file, pick a texture, tune the parameters, and ex

## Recent Updates

- Adjustable quality ceiling: the subdivision cap is now a memory budget you can raise (Advanced → Quality Ceiling)
- "Suggest values" no longer caps output at a flat 2 M triangles regardless of part size
- 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 @@ -62,7 +65,7 @@ Load an STL, OBJ, 3MF, or STEP file, pick a texture, tune the parameters, and ex
- **Adaptive subdivision** — subdivides edges until they are ≤ a target length; respects sharp creases (>30° dihedral)
- **QEM decimation** — simplifies the result to a target triangle count using Quadric Error Metrics with boundary protection, link-condition checks, normal-flip rejection, and crease preservation
- **Mesh diagnostics** — automatic checks for open edges and shell count, with advanced diagnostics and overlay highlights for problem areas
- **Safety cap** — hard limit of 10 M triangles during subdivision to prevent out-of-memory
- **Memory budget (quality ceiling)** — the subdivision triangle cap is derived from a memory budget (`js/memoryBudget.js`) and adjustable under **Advanced**, rather than being a hardcoded triangle count. Browsers cannot report free RAM — `navigator.deviceMemory` never returns more than 8 — so the automatic value is a conservative guess that machines with more memory can raise. Two ceilings apply: total memory, and the engine's 2 GB limit on any single typed array, which caps subdivision near 45 M triangles no matter how much RAM is free. Allocation failures degrade to a coarser mesh with a warning instead of failing the export

### 3D Viewer
- **Orbit / pan / zoom** controls
Expand Down Expand Up @@ -115,6 +118,8 @@ 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
memoryBudget.js # Memory budget -> subdivision/output triangle caps
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