Skip to content

fix(renderer,clash): bound the point-cloud cell map, drop a no-op quadratic dedup - #3041

Merged
louistrue merged 3 commits into
mainfrom
fix/pointcloud-index-bound-and-bvh-dedup
Aug 22, 2026
Merged

fix(renderer,clash): bound the point-cloud cell map, drop a no-op quadratic dedup#3041
louistrue merged 3 commits into
mainfrom
fix/pointcloud-index-bound-and-bvh-dedup

Conversation

@BIMvoice

Copy link
Copy Markdown
Collaborator

Two unbounded-collection defects found while diagnosing #3028. Neither is claimed to be that crash's cause — both stand on their own.

Premise verified rather than assumed: a plain int-keyed Map insert loop throws at exactly 2²⁴ = 16,777,216.

1. A memory safety valve set above the ceiling it protects

point-cloud-spatial-index.ts keys one Map entry per occupied 0.5 m voxel, guarded by DEFAULT_MAX_INDEXED_POINTS = 30_000_000above the 16.78M Map ceiling.

Measured against the unmodified source with a realistic sparse fixture (200 m × 250 m ground tile at 1 m spacing, the airborne-swath shape):

points indexed : 50000
cells created  : 50000
cells per point: 1
Map throws after 16777216 cells = ~16777216 points — BEFORE the valve at 30000000

Worst-case points-per-cell is 1, so cells === points and the valve can never bind on a sparse cloud. Dense terrestrial scans put many points per cell and never approach it — which is exactly why this survived.

(A first probe reported 0.655 cells/point; that was the CELL_KEY_BIAS boundary clamp folding a 50 km span — a fixture artefact, not density. Re-laid out, it gives the honest 1.0.)

Both limbs are now bounded, rather than lowering the point cap. DEFAULT_MAX_INDEXED_POINTS stays 30M — it bounds retained position memory and binds on dense clouds, a job that was never broken. New DEFAULT_MAX_INDEXED_CELLS = 2²⁴ − 2²⁰ = 15,728,640 bounds the Map. Neither implies the other: a sparse cloud exhausts cells at a fraction of the point cap, a dense one exhausts points with a tiny grid.

Sat 1M cells below the ceiling deliberately — anything materially lower strips picking from clouds that index fine today. The bound should bind only where V8 would otherwise have thrown.

And the truncation was silent. isCapped existed with no consumer anywhere in the repo — grepped; only tests referenced it. It now warns once, naming the limb and counts, and exposes capReason / cellCount / cellCapacity. A chunk crossing either cap is truncated to the prefix actually indexed, and getBounds folds in only indexed points so it never advertises a region queryRay cannot reach.

Two mutants survived and were fixed, not papered over. The clamp test asserted pointCount === 1 — observing nothing; it now asserts the effective budget plus the 0 / -5 / NaN / Infinity fallbacks. The warn-once mutant survived because the capWarned flag was dead code (insertRange already returns early once capped) — the flag is deleted rather than kept as untestable defensive state.

2. A quadratic Set proven to buy nothing

mesh-bvh.ts deduped emitted triangle pairs with a Set keyed `${triA}|${triB}` — O(triA × triB) for a single element pair, uncapped. Two ~4k-triangle elements whose AABB filter passes nearly everything sit exactly on the ceiling.

The comment three lines above already argued it was unnecessary. That argument was checked, not trusted: buildNode splits into slice(0, mid) / slice(mid) — disjoint, covering — so every triangle lives in exactly one leaf; and crossNode reaches a node pair by exactly one route, advancing both sides only when both are internal.

Then proven by running. A probe hashed the full ordered pair list across 432 fixtures — leafSize 1/2/3/4/8/16, eps 0/0.05/0.25/10, 1–64 triangles including lopsided 1-vs-64, overlapping/staircase/scattered — 182,610 pairs:

WITH the Set:    sha256 6453cae828555ad91fe8be6524d18c60875d48c4f79d6e41f1ff5238a983b026
WITHOUT the Set: sha256 6453cae828555ad91fe8be6524d18c60875d48c4f79d6e41f1ff5238a983b026

Byte-identical, content and order. Removed.

The committed tests prove rather than assert: 8 parameterised fixtures compare against an independent brute-force ground truth on exact count and exact set — so a double-visiting traversal shows as a count mismatch, which is precisely what a "fixing" Set would have hidden. One test walks the tree and checks the partition directly; one guards vacuity with multi-leaf trees on both sides (1600 real pairs, not one pair from two single-leaf trees where nothing could duplicate).

Mutants: breaking the partition (overlapping leaves) fails 10 of 11; making the traversal visit a pair twice fails 6 of 11.

On the bare catch {} at useClash.ts:918 — not touched

Half defensible. The fallback is right — it draws the AABB overlap box instead of the precise contact interface, so the user is not left with nothing. What is not defensible is that it emits nothing: a RangeError from a real bug and a legitimately degenerate mesh are observably identical, and neither reaches telemetry. A single console.warn would fix diagnosability without widening the catch or changing the fallback. Worth a follow-up.

packages/renderer 1011 → 1022; packages/clash 414 → 424. tsc --noEmit clean on both, typecheck-tests clean, lint 0 errors. Changesets for both packages.

🤖 Generated with Claude Code

Two independent defects found while diagnosing a production
`RangeError: Map maximum size exceeded`. Neither is claimed to be that
crash's cause; both are provable on their own. V8 throws that RangeError
at exactly 2^24 = 16,777,216 entries, and `Set` shares the limit.

1. `PointCloudSpatialIndex` stores its voxel grid as a
   `Map<number, number[]>` with one entry per occupied cell, and
   `insertRange` can create a new cell for every point it indexes. The
   only limb of its documented "memory safety valve" was
   `DEFAULT_MAX_INDEXED_POINTS = 30_000_000` — above the engine ceiling.
   On a sparse cloud (airborne LiDAR, a coarse site scan) roughly one
   point falls in each 0.5 m cell, so occupied cells track indexed points
   almost 1:1 and the Map would throw around 16.8M points, before the
   valve could bind. Dense terrestrial scans put many points in one cell
   and never approach it, which is why the gap survived.

   The two limbs bound different resources and neither implies the other,
   so both are enforced: the point cap still bounds retained position
   memory, and a new `DEFAULT_MAX_INDEXED_CELLS` (2^24 - 2^20) bounds the
   Map. It is set high enough to bind only where V8 would otherwise have
   thrown, so no cloud that indexes fully today loses coverage, and a
   caller-supplied budget is clamped below the ceiling regardless. Points
   landing in an already-occupied cell cost no budget.

   Whichever limb binds first closes the index; a chunk crossing it is
   truncated to the prefix actually indexed, so the cap bounds memory
   rather than just stopping bookkeeping. Truncation is no longer silent:
   it is reported once via `console.warn` naming the limb and the counts,
   and `capReason` / `cellCount` / `cellCapacity` expose it to callers.

2. `queryMeshCross` funnelled its candidate triangle pairs through a
   `Set` keyed `${iA}|${iB}` — one key string and one Set entry per
   emitted pair, O(triA * triB) worst case for a single element pair,
   uncapped. Its own comment explained why it was unnecessary, and the
   reasoning holds: `buildNode` splits a node's indices into disjoint,
   covering halves and a leaf keeps exactly its own slice, so every
   triangle lives in exactly one leaf; and `crossNode` reaches any node
   pair by a single route, descending both sides together while both are
   internal and only the internal side once the other is a leaf. Each
   leaf pair is visited once, so each pair is emitted at most once.
   Removed. 4096 * 4096 = 2^24, so two ~4k-triangle elements whose AABB
   filter passes nearly everything sat exactly on the Set ceiling.

Verification: output of `queryMeshCross` is byte-identical with and
without the Set — same sha256 over the full ordered pair lists of 432
fixtures (182,610 pairs) sweeping leaf size, triangle count, epsilon and
topology. New tests pin the emitted list against brute force and check
the leaf partition directly; breaking either the partition or the
traversal's single-visit property makes them fail. The index tests drive
the cell cap via a parameterised limit rather than allocating 16.7M
entries, and observe the reported outcome.

renderer 1011 -> 1022 tests, clash 414 -> 424, all passing; tsc --noEmit
and the test typecheck clean on both packages.
@BIMvoice
BIMvoice requested a review from louistrue as a code owner August 21, 2026 14:23
@coderabbitai

coderabbitai Bot commented Aug 21, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@BIMvoice, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 25 minutes

Limit details: You’ve used all 2 included reviews currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 0d8e0804-fd45-4923-b7c5-f31cd21ef46f

📥 Commits

Reviewing files that changed from the base of the PR and between fe38b33 and 8b79366.

📒 Files selected for processing (6)
  • .changeset/mesh-bvh-drop-noop-dedup-set.md
  • .changeset/pointcloud-index-cell-cap.md
  • packages/clash/src/contact/mesh-bvh.test.ts
  • packages/clash/src/contact/mesh-bvh.ts
  • packages/renderer/src/point-cloud-spatial-index.test.ts
  • packages/renderer/src/pointcloud/point-cloud-spatial-index.ts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Viewer benchmark

✅ No threshold regressions detected.

01_Snowdon_Towers_Sample_Structural(1).ifc

Baseline recorded 2026-07-01T20:31:05.538Z on github-actions ubuntu-latest, viewer-benchmark-ci (headless Chrome, SwiftShader ANGLE), production build.

Metric Current Baseline Delta Threshold Status
firstBatchWaitMs 1274ms 2905ms -56.1% +50%
firstVisibleGeometryMs 1868ms 3652ms -48.8% +50%
streamCompleteMs 2209ms 3598ms -38.6% +50%
spatialReadyMs 932ms 1032ms -9.7% +50%
metadataCompleteMs 1362ms 3063ms -55.5% +50%
totalWallClockMs 2600ms 3700ms -29.7% +50%

AC20-FZK-Haus.ifc

Baseline recorded 2026-07-01T20:30:59.972Z on github-actions ubuntu-latest, viewer-benchmark-ci (headless Chrome, SwiftShader ANGLE), production build.

Metric Current Baseline Delta Threshold Status
firstBatchWaitMs 271ms 1075ms -74.8% +50%
firstVisibleGeometryMs 951ms 1572ms -39.5% +50%
streamCompleteMs 840ms 1980ms -57.6% +50%
spatialReadyMs 866ms 915ms -5.4% +50%
metadataCompleteMs 1000ms 1392ms -28.2% +50%
totalWallClockMs 1000ms 3300ms -69.7% +50%

Refresh the baseline from a CI run: dispatch the Benchmark workflow with record_baseline, download the benchmark-baseline artifact, and commit baseline.json (see tests/benchmark/README.md).

@BIMvoice

Copy link
Copy Markdown
Collaborator Author

Flagging a claim I should have checked before opening this, now that #3040 has landed.

You are assigned to #3028 and you have #3042 open on it. This PR references the same issue and I did not check either before starting. Under the rule that just merged, that makes it a competing PR on someone else's issue, and the fact that I got here first is explicitly not the tiebreak. Your call what happens to it — I am not going to push it forward on my own.

The one thing that might make it worth keeping: the two PRs touch no file in common.

#3042 (yours)   packages/renderer/src/lod-simplify.ts
#3041 (this)    packages/renderer/src/pointcloud/point-cloud-spatial-index.ts
                packages/clash/src/contact/mesh-bvh.ts

#3028 names a RangeError: Map maximum size exceeded without pinning which map. Yours caps the LOD vertex memo. This one caps a different structure in a different package, so if the report turns out to be the point-cloud path rather than the LOD path, yours would land and the crash would persist. I have no evidence about which one the reporter actually hit — the symbolicated frame was never posted, and I said as much on the issue.

Enumerating what this holds that #3042 does not, so nothing is lost if you close it:

  1. DEFAULT_MAX_INDEXED_CELLS = 2^24 − 2^20 = 15,728,640 on the point-cloud spatial index. V8's Map/Set limit is exactly 2^24 = 16,777,216; the headroom is deliberate, since a limit set at the ceiling still throws on the insert that reaches it.
  2. Removal of a dedup Set in mesh-bvh.ts that was quadratic and provably a no-op. Proven rather than argued: sha256 over the emitted pairs across 432 fixtures / 182,610 pairs is 6453cae8…983b026 with and without it, byte-identical.

Item 2 is independent of #3028 entirely and could be split out if you want the cap discussion to stay on your PR.

Happy to close this, or to rebase whatever survives onto yours as a patch, or to leave it for you to cherry-pick. Say which and I will do it.

@vercel

vercel Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

2 Skipped Deployments
Project Deployment Actions Updated (UTC)
ifc-lite-dev Ignored Ignored Preview Aug 22, 2026 9:51am
ifc-lite-viewer-embed Ignored Ignored Aug 22, 2026 9:51am

@BIMvoice

Copy link
Copy Markdown
Collaborator Author

Self-review pass. Sound — here is what it survived, and one unenforced claim to hand back.

Shape 6 attacked directly. Probed V8 rather than reasoning about it: a Map holds exactly 2**24 entries and throws RangeError: Map maximum size exceeded on the 16,777,217th insert. DEFAULT_MAX_INDEXED_CELLS = 15,728,640 and the constructor clamp min(requested, 2**24 - 1) are both strictly below it — and the guarded structure genuinely is the cells Map, whose entry count is what V8 limits, not the array values. The only other Set in the file is visited in queryRay, bounded by cells along one ray.

The dedup removal is pinned on ordering, not just membership — which was the thing I most doubted. mesh-bvh.test.ts asserts new Set(keys).size === keys.length and keys.length === bruteForce.size and set equality. Together those force out to be exactly the brute-force pair list with zero duplicates, and since the removed Set only ever dropped later duplicates, order is pinned transitively. So the sha256-over-ordered-pairs argument is carried by the committed tests rather than only by an off-repo probe.

Mutants: partition break (indices.slice(mid)slice(mid-1)) fails 10 of 11, matching the body's number exactly; double-visit in crossNode fails 3 of 11; cell-cap >=> fails 3 of 42; clamp removal fails 1 of 42. Renderer 1023 pass / 0 fail, clash 362 pass / 0 fail.

One claim nothing enforces

point-cloud-spatial-index.ts:419 — the copy-on-truncate (truncated ? positions.slice(0, accepted * 3) : positions). Replacing it with a bare positions leaves 42/42 green.

Both the changeset ("truncated to the prefix actually indexed, so the cap bounds retained memory rather than just stopping bookkeeping") and the test comment ("Only the accepted prefix may be retained") assert it, and nothing observes it — chunks is private, and pointCount and bounds are identical either way.

It is observable: mutate the source Float32Array after a truncating insertRange and query. A copied prefix is unaffected; a by-reference one is not. I left it because such a test deliberately exercises a path the class doc tells callers not to take, which is a judgement call rather than a contained fix.

An observation, not a defect: isCapped, capReason, cellCount and cellCapacity have zero consumers outside the class and its test — the console.warn is the only real reader of the new state. Not exported from packages/renderer/src/index.ts, so no api-surface impact.

… it indexes

`insertRange` makes two memory claims, and neither was enforced.
Replacing `truncated ? positions.slice(0, accepted * 3) : positions`
with a bare `positions` left the suite at 42/42 green — `chunks` is
private, and `pointCount`, `cellCount` and `getBounds` are all folded in
at insert time and read identically whichever array the chunk ended up
holding. So the changeset's "truncated to the prefix actually indexed,
so the cap bounds retained memory rather than just stopping bookkeeping"
and the test's "Only the accepted prefix may be retained" both rested on
nothing.

The one handle on which array a chunk kept is what `queryRay` reads
back, so three tests write to the caller's buffers after the insert:

- a cap-crossing chunk's positions must be a copy (source overwritten
  wholesale, the query still reports the original prefix);
- its classifications must be a copy on the same branch, or a later
  write would change which points the LAS visibility mask hides;
- a chunk that FITS must still be aliased, since copying every streamed
  chunk would double this index's retained bytes per point.

Writing to the caller's array is what the class doc tells callers not to
do. That is the instrument, not an endorsement — what is asserted is
which storage the index kept — and the mechanism is specified rather
than incidental: `slice` allocates a new buffer, the array passed in
stays a live view over the caller's. Each test's own comment says so.

Mutants, all run: bare `positions` -> test 1 red; bare `classifications`
-> test 2 red; slice-always -> test 3 red. Production code unchanged.

point-cloud-spatial-index.test.ts 42 -> 45 pass; package suite 1026 pass
/ 0 fail.
@BIMvoice

Copy link
Copy Markdown
Collaborator Author

The unenforced claim is now enforced, pushed 8b7936649.

Verified the gap first, by running: baseline 42/42, and with positions.slice(...) replaced by a bare positions — deleting the copy the changeset promises — still 42/42. Nothing observed it.

Three tests added, each mutation-checked:

mutant RED
bare positions not ok 1 - keeps only a copy of the accepted prefix when a chunk crosses the cell cap"a truncated chunk must be copied, not aliased to the caller's array"
bare classifications not ok 2 - keeps only a copy of the accepted classifications when a chunk crosses a cap
slice-always not ok 3 - keeps a chunk that fits BY REFERENCE, paying no copy — expected 1.25, actual 1

Both directions, checked before asserting rather than assumed: the current behaviour for a non-truncating insert genuinely is by-reference, so that is pinned too — meaning a copy-always "fix" would pass the first two and fail the third.

On the judgement call

The test mutates the source array after insertRange, and the class doc says the array is kept "BY REFERENCE (never copied)" with "the caller must not mutate them afterwards". I went ahead, and the reasoning is that what is asserted is which storage the index kept, not that mutation is supported — the write is the instrument, the way a spy is.

The mechanism is specified rather than incidental: TypedArray.prototype.slice allocates a new buffer, while the array passed in stays a live view over the caller's. No undefined behaviour. Each test's comment says this in full, so a reader cannot mistake it for an endorsement of mutating.

If you would rather not have a test that touches that path at all, the alternative is to weaken the changeset's claim to what is actually observable — but a claim nothing checks seemed the worse of the two.

I also rewrote a misleading comment on the existing truncation test: it now says only the indexing is observed there, and points at the new suite for retention.

Targeted file 42 → 45 pass / 0 fail; package suite 1026 pass / 0 fail after building @ifc-lite/spatial (without which three files fail on a missing dist — environment, not code). typecheck-tests OK across 82 files, oxlint and check-changesets clean.

@louistrue
louistrue merged commit 5ea5f99 into main Aug 22, 2026
24 checks passed
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