Skip to content

feat: derive the subdivision cap from an adjustable memory budget (proposal) - #115

Open
Dgmtnz wants to merge 3 commits into
CNCKitchen:mainfrom
Dgmtnz:feat/memory-budget-ceiling
Open

feat: derive the subdivision cap from an adjustable memory budget (proposal)#115
Dgmtnz wants to merge 3 commits into
CNCKitchen:mainfrom
Dgmtnz:feat/memory-budget-ceiling

Conversation

@Dgmtnz

@Dgmtnz Dgmtnz commented Aug 7, 2026

Copy link
Copy Markdown

This is the one the other two were for: it is what actually lets a large
model export at a resolution the machine can afford, instead of failing with
an out-of-memory alert.

Context first — this one is a proposal, not a finished call

It changes default behaviour and adds a user-facing control, so it's a product decision. Happy to drop, reshape or split it. The two PRs it sits on stand on their own.

Depends on #114.

The problem

SAFETY_CAP is a hardcoded 16M/32M pair, justified by "~145 bytes per subdivided triangle". Measured, the pipeline peaks at 660 B/tri (327 after the memory PR). So those caps stood for 10.6 GB and 21 GB. No tab ever reached them — it hit the allocator first, and the export died with an out-of-memory alert instead of the "coarser than requested" warning the cap exists to produce.

navigator.deviceMemory is clamped to 8 by its own specification, so a 64 GB workstation and an 8 GB laptop got the same ceiling, and "I have the RAM, let me use it" was unexpressible.

Separately, computeRecommendedMaxTri clamps at a flat 2 M. diag-quality-ceiling.mjs (added) sweeps one model across physical sizes with a fine texture:

  size(mm)   suggested   detail-limited   budget-limited   suggested   binding
   (diag)     edge mm       edge mm          edge mm        out-tris    ceiling
       50      0.050         0.025            0.043          2.00M    SLIDER CLAMP
      100      0.090         0.025            0.086          2.00M    TRIANGLE BUDGET
      200      0.180         0.025            0.172          2.00M    TRIANGLE BUDGET
     1200      1.040         0.025            1.030          2.00M    TRIANGLE BUDGET

Every size gets exactly 2.00 M output triangles — the same count for a 50 mm part and a 1200 mm one, i.e. a ~24× coarser surface on the large one, silently.

The change

cap = budgetBytes / PIPELINE_BYTES_PER_TRIANGLE, with the budget a user setting (Advanced → Quality Ceiling), persisted, resolved on the page (the worker has no localStorage) and passed as settings.subdivisionCap.

A second ceiling is independent of RAM and I only found it by hitting it: V8 caps a single typed array at 2^31−1 bytes (measured — largest allocatable Float32Array is 2046 MB, page and worker alike). At a 32 GB budget the cap was nominally 101 M triangles; subdivision reached 65 M, then toNonIndexed asked for a 2.34 GB Float32Array and threw, on a machine with 30 GB free. So the cap is also bounded by 2^31 / 48 B/tri ≈ 44.7 M triangles, and the UI says when that, not the setting, is binding.

Allocation failures now degrade instead of throwing: subdivide() rolls back to the last complete level and reports safetyCapHit. Losing a finished multi-minute subdivision to one failed allocation isn't an acceptable failure mode.

Smart budgets against the same cap the export enforces, and the 2 M output clamp becomes proportional to the budget (floored at 2 M so it can never recommend less than today).

Effect

Budget Cap Suggested edge vs. today
2 GB 5.6 M 0.59× (coarser)
4 GB 11.2 M 0.84× (coarser)
8 GB 22.4 M 1.18× finer
16 GB+ 44.7 M (structural) 1.67× finer

The rows below 8 GB are coarser than today's nominal setting — deliberately. Today's 12 M Smart budget needs ~7.9 GB of real memory at the current 660 B/tri, so on a machine that only has a few GB to spare it mostly ended in an out-of-memory alert. These numbers are what actually completes.

Caveats

  • PIPELINE_BYTES_PER_TRIANGLE = 384 is measured two ways: 327 B/tri under Node with live typed-array accounting, and 384 B/tri in Chromium as the RSS rise of the browser processes over an idle baseline during a real export (15.0 M subdivided triangles, 665 MB → 6165 MB). The constant takes the browser figure, since the allocator charges for footprint, not for the subset V8 calls live. Both were measured on generated models on one Linux machine — worth re-checking against your reference models.
  • The rollback path is defensive and unexercised by any test — with the structural cap in place it should be unreachable in Chromium.
  • i18n: EN and ES only; the other 12 fall back to English.
  • One machine, Linux + Chromium 131. The 2 GB per-allocation limit is unverified on Safari/Firefox.
  • Pipeline fingerprints unchanged (10/10) — the cap doesn't bind at test sizes.

claude added 3 commits August 7, 2026 01:22
…d 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.
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.
The subdivision safety cap was a hardcoded 16M/32M pair, justified in a comment
by "~145 bytes per subdivided triangle". Measured, the pipeline peaked at 660
B/tri (327 after the preceding commit), so those caps stood for 10.6 GB and
21 GB — no tab reached them, it hit the allocator first and the export died
with an out-of-memory error instead of the "coarser than requested" warning the
cap exists to produce.

navigator.deviceMemory is clamped to 8 by its specification, so a 64 GB
workstation and an 8 GB laptop got the same ceiling and there was no way to say
"I have the RAM". The cap is now budget / measured-bytes-per-triangle, with the
budget a user setting (Advanced -> Quality Ceiling), persisted, resolved on the
page because the worker has no localStorage and passed as
settings.subdivisionCap.

PIPELINE_BYTES_PER_TRIANGLE is 384, measured two ways:

  327 B/tri  Node, live typed-array accounting (arrayBuffers + heapUsed)
  384 B/tri  Chromium, RSS rise of the browser processes over an idle baseline
             during a real export (15.0M subdivided triangles, 665 -> 6165 MB)

The constant takes the browser figure. This guard exists to stop an export
before the allocator does, and the allocator charges for footprint, not for the
subset V8 attributes to live buffers.

A second ceiling is independent of RAM and binds above ~16 GB of budget: V8
caps a single typed array at 2^31-1 bytes (measured: largest allocatable
Float32Array is 2046 MB, page and worker alike). The pipeline's biggest single
allocations are decimation's quadrics (40 B/tri) and toNonIndexed's position
and normal buffers (36 B/tri each), so subdivision cannot exceed ~44.7M
triangles however much memory is free. The UI says when that, and not the
setting, is binding.

Allocation failures now degrade instead of throwing: subdivide() rolls back to
the last complete level and reports safetyCapHit, which the caller already
surfaces as "coarser than requested". Losing a finished multi-minute
subdivision to one failed allocation is not an acceptable failure mode. This
path is defensive and currently unexercised by any test — with the structural
cap in place it should be unreachable in Chromium; it exists because engine
limits differ.

Smart resolution budgets against the same cap the export enforces instead of a
fixed 16M chosen for cross-machine reproducibility, and computeRecommendedMaxTri
loses its flat 2M ceiling, which recommended the same output count for a 50mm
part and a 1200mm one (see diag-quality-ceiling.mjs). The 2M floor is kept so it
can never recommend less than before.

Budgets below 8 GB now resolve to a lower cap than the old nominal 16M/32M.
That is deliberate: the old numbers were not reachable. Pipeline fingerprints
unchanged, 10/10 identical to upstream across sphere, cube-with-fillets, cone,
holed-plate and sliver-strip inputs.
@Dgmtnz
Dgmtnz marked this pull request as ready for review August 8, 2026 23:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants