partforge 0.40.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -112,9 +112,28 @@ await runtime.ready; // first successful build (rejects on a first-build error
112
112
  runtime.setHostPane("rail"); // narrow layout only: show just the controls
113
113
  // rail ('stage' | 'rail'), suppressing the
114
114
  // built-in tab bar. null hands selection back.
115
+ runtime.setActive(false); // park the viewer: stop the render loop, release the
116
+ // drawing buffer. setActive(true) restores both.
117
+ const off = runtime.onContextLost(() => {}); // WebGL context loss; returns an unsubscribe
115
118
  runtime.dispose(); // stops loops, workers, observers, listeners; frees GPU resources
116
119
  ```
117
120
 
121
+ **Park the viewer when you hide it.** A host that hides the canvas with
122
+ `display: none` needs nothing — the container collapses and the ResizeObserver
123
+ shrinks the drawing buffer for free. A host that hides it any other way
124
+ (`visibility: hidden`, an inactive tab, an off-screen pane) gets no such signal:
125
+ the full-resolution MSAA buffer stays resident and the render loop keeps drawing
126
+ an auto-rotating scene at 60fps that nobody can see. On a phone that is tens of
127
+ megabytes plus continuous GPU work, and it has been enough on its own to get a
128
+ tab killed. Call `runtime.setActive(false)` when the viewer goes off-screen and
129
+ `setActive(true)` when it comes back.
130
+
131
+ Parking releases both large GPU allocations — the drawing buffer and the cached
132
+ 1024² capture target — and stops the render loop. Offscreen captures
133
+ (`captureCurrent`, `captureViews`) keep working while parked and framing is
134
+ unchanged, so a host can still take a build screenshot of a hidden viewer; the
135
+ first capture after parking just re-allocates its target.
136
+
118
137
  Every `elements` entry defaults to the legacy global ID (`#app`, `#controls`,
119
138
  `#panel` for `rail`, `#status`/`#busy`/`#phase`, `#part`,
120
139
  `#download`/`#download-step`/`#download-3mf`,
package/bin/cli.js CHANGED
@@ -204,6 +204,9 @@ function printVerify(v) {
204
204
  for (const ch of c.checks) {
205
205
  const icon = ch.status === "pass" ? "✓" : ch.status === "fail" ? "✗" : ch.status === "warn" ? "⚠" : "·";
206
206
  console.log(` ${icon} ${ch.subpart ?? "_view"} ${ch.metric} ${ch.expr} (${ch.message})`);
207
+ // A measurement caveat prints whatever the verdict — "passed, but sampled"
208
+ // is precisely the line a reader must not miss.
209
+ if (ch.note) console.log(` note: ${ch.note}`);
207
210
  if (ch.status === "fail" || ch.status === "warn") {
208
211
  if (ch.location) console.log(` at [${ch.location.map((n) => n.toFixed(1)).join(", ")}]`);
209
212
  if (ch.hint) console.log(` hint: ${ch.hint}${ch.pattern ? ` (ERROR-PATTERNS.md#${ch.pattern})` : ""}`);
@@ -1032,6 +1032,9 @@ carries:
1032
1032
  - `hint` — one self-contained corrective sentence (always present),
1033
1033
  - `pattern` — a stable [ERROR-PATTERNS.md](ERROR-PATTERNS.md) entry ID when one
1034
1034
  applies (follow it with `ERROR-PATTERNS.md#<id>`),
1035
+ - `note` — an optional caveat about *how* the value was measured, attached
1036
+ whatever the verdict. Today only `minWall` sets one, when the reading came
1037
+ from a sample rather than every triangle (see below),
1035
1038
  - `location` — `[x, y, z]` in mm where the metric has one: `minWall` (thinnest
1036
1039
  sample point) and `overlaps` (the center of the first offending intersection's
1037
1040
  *bounding box* — a nearby indicator, not an exact point: when a pair overlaps in
@@ -1042,7 +1045,20 @@ carries:
1042
1045
 
1043
1046
  Subpart facts include `minWall` (number or `null` — null exactly when no reading
1044
1047
  exists, e.g. the OCCT backend or min-wall measurement turned off, matching
1045
- `minWallAt`'s null) and `minWallAt` (`[x,y,z]` or `null`); overlap entries are
1048
+ `minWallAt`'s null) and `minWallAt` (`[x,y,z]` or `null`). Min wall casts one ray
1049
+ per triangle, which is unbounded work on a dense mesh, so past 50,000 triangles
1050
+ it casts from a spread, deterministic subset instead — `minWallSampled` (boolean)
1051
+ and `minWallSamples` (`{ sampled, total }` or `null`) say whether that happened.
1052
+ `sampled` is how many triangles the walk *selected*, not how many rays were
1053
+ cast: a degenerate (zero-area) triangle has no normal to cast along and is
1054
+ skipped. A sampled reading is an **upper bound**: it can miss a thin spot, never
1055
+ invent one — and a sampled run that found no wall at all still reports its
1056
+ `minWallSamples`, so a null `minWall` there is "we looked and found nothing",
1057
+ not "nobody looked". Everything in `src/parts/` is far below the budget and
1058
+ reads exactly. The report's top-level `measuredMinWall` says whether this run
1059
+ cast min-wall rays at all — the difference between a null `minWall` that means
1060
+ "no wall found" and one that means "not measured".
1061
+ Overlap entries are
1046
1062
  `{ a, b, volume, location }`. Pair-distance facts are `gaps` (every sub-part
1047
1063
  pair: `{ a, b, distance, at }`, distance 0 = touching or overlapping) and
1048
1064
  `nearMisses` (the pairs with an unintended-looking gap under 0.5 mm).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.40.0",
3
+ "version": "0.41.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -149,9 +149,21 @@ export async function handle(kernel, part, msg, post, opts = {}) {
149
149
  // — the main thread only has mesh arrays, so this can only happen here.
150
150
  // measure/verify build their own solids via buildView and are cleaned up by
151
151
  // the `finally` below.
152
+ // The two halves overlap: verify always expands a "defaults" case, and for
153
+ // an unparameterized inspect that case IS this measurement. Seeding it in
154
+ // (see verify.js's seeding block for the min-wall superset rule that makes
155
+ // the reuse sound) stops the oracle from rebuilding the same geometry and
156
+ // re-casting the same min-wall rays a second time. Measuring `{ minWall:
157
+ // true }` here is what makes the seed usable by any verify run, min-wall
158
+ // gated or not — the result says so itself (`measuredMinWall`), so this
159
+ // call and the seed cannot drift apart.
160
+ const measured = measure(kernel, part, msg.view, msg.params ?? {}, { minWall: true });
152
161
  const report = {
153
- measure: measure(kernel, part, msg.view, msg.params ?? {}, { minWall: true }),
154
- verify: verify(kernel, part, { view: msg.view }),
162
+ measure: measured,
163
+ verify: verify(kernel, part, {
164
+ view: msg.view,
165
+ seed: { params: msg.params ?? {}, result: measured },
166
+ }),
155
167
  };
156
168
  post({ type: "report", ...report });
157
169
  }
@@ -31,6 +31,15 @@ export function makeHandle({ ready, dispose, viewer, setParams, listExportablePa
31
31
  ready, dispose, setParams,
32
32
  captureViews: (viewNames) => viewer.captureCanonicalViews(viewNames),
33
33
  captureCurrent: (opts) => viewer.captureCurrent(opts),
34
+ // Park/unpark the viewer: stops the render loop and frees the drawing
35
+ // buffer and the cached capture target. For an embedder that hides the
36
+ // canvas without unmounting it — `visibility: hidden`, an off-screen tab —
37
+ // where nothing collapses the container and the loop would otherwise run
38
+ // forever. See the setActive comment in viewer.js for what it costs.
39
+ setActive: (active) => viewer.setActive(active),
40
+ // Subscribe to WebGL context loss (returns an unsubscribe), so a host can
41
+ // say "the 3D view ran out of memory" instead of showing a dead canvas.
42
+ onContextLost: (listener) => viewer.onContextLost(listener),
34
43
  listExportableParts,
35
44
  exportParts,
36
45
  // Narrow-layout pane selection, for a host that draws its own tab bar
@@ -69,7 +78,7 @@ function createCleanupStack() {
69
78
  // mesh-validity cache, and the geometry workers. The app supplies `createWorker(name)`
70
79
  // so Vite can bundle the worker (see geometry-service.js).
71
80
  //
72
- // Embedding contract (0.39.0):
81
+ // Embedding contract (0.41.0):
73
82
  // const runtime = mount(part, { createWorker, elements, onBuild, onPick, onDownload });
74
83
  // await runtime.ready; // first successful build of the default view
75
84
  // runtime.setParams({ openAngle: 45 }); // programmatic edit; pose-only changes apply instantly
@@ -86,6 +95,17 @@ function createCleanupStack() {
86
95
  // runtime.setHostPane("rail"); // narrow layout only: show just the controls
87
96
  // // rail ('stage' | 'rail'), suppressing the
88
97
  // // built-in tab bar. null hands selection back.
98
+ // runtime.setActive(false); // park the viewer: stop the render loop and release
99
+ // // both large GPU allocations (the drawing buffer and
100
+ // // the cached capture target). For a host that hides the
101
+ // // canvas WITHOUT unmounting it (`visibility: hidden`, an
102
+ // // inactive tab) — nothing else can detect that, and the
103
+ // // loop would otherwise render a hidden pane forever.
104
+ // // Captures still work while parked (they re-allocate).
105
+ // // setActive(true) restores it. Safe after dispose().
106
+ // const off = runtime.onContextLost(() => …); // WebGL context loss, i.e. the GPU or the
107
+ // // OS gave up — surface it rather than showing a dead
108
+ // // canvas. Returns an unsubscribe.
89
109
  // runtime.dispose(); // full teardown
90
110
  // onBuild fires per completed build, so it does NOT fire for a pose-only edit —
91
111
  // those are repaired in the viewer and produce no build at all.
@@ -2,7 +2,9 @@
2
2
  // is a hard gate or a warning, and the diagnostics attached to a non-pass check:
3
3
  // `hint` (required — the report contract promises one on every fail/warn),
4
4
  // `pattern` (optional stable ERROR-PATTERNS.md#<id>), `locate` (optional
5
- // [x,y,z] source). `manifoldOnly` facts are null on OCCT parts.
5
+ // [x,y,z] source), `note` (optional caveat about HOW the value was measured,
6
+ // attached whatever the status — a passing-but-sampled reading is exactly the
7
+ // case a reader needs told about). `manifoldOnly` facts are null on OCCT parts.
6
8
  //
7
9
  // This lives in framework/ rather than testing/ deliberately: the set of legal
8
10
  // `verify.expect` metrics is part of the PartDefinition CONTRACT, which both the
@@ -32,7 +34,18 @@ export const SUBPART_METRICS = {
32
34
  minWall: { kind: "warn", extract: (s) => s.minWall,
33
35
  hint: "thinnest wall is at the reported location — increase the governing wall/thickness parameter or reduce the intersecting feature's depth",
34
36
  pattern: "minwall-sliver-triangles",
35
- locate: (s) => s.minWallAt },
37
+ locate: (s) => s.minWallAt,
38
+ // Two sampled outcomes, and the second is the one that most needs saying: a
39
+ // sampled run that found NO wall reports minWall null, which without this note
40
+ // is indistinguishable from a part nobody measured. `sampled` counts triangles
41
+ // the walk selected, not rays cast — degenerate triangles are skipped.
42
+ note: (s) => {
43
+ if (!s.minWallSampled || !s.minWallSamples) return null;
44
+ const { sampled, total } = s.minWallSamples;
45
+ return s.minWall == null
46
+ ? `no reading from the ${sampled} of ${total} triangles sampled — not a clean bill of health; a thin spot may exist between samples`
47
+ : `sampled ${sampled} of ${total} triangles — an upper bound; a thinner spot may exist between samples`;
48
+ } },
36
49
  };
37
50
  export const VIEW_METRICS = {
38
51
  bbox: { kind: "gate", extract: (r) => r.aggregate.bbox,
@@ -94,6 +94,10 @@ export function createViewer(container, part) {
94
94
  renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
95
95
  container.appendChild(renderer.domElement);
96
96
 
97
+ // Declared up here, not beside setActive() below, because the initial resize()
98
+ // runs during construction and reads it.
99
+ let active = true;
100
+
97
101
  const scene = new THREE.Scene();
98
102
 
99
103
  // Light/dark scene palettes (the page chrome is themed separately, via CSS on the
@@ -361,6 +365,10 @@ export function createViewer(container, part) {
361
365
  // --- resize ---------------------------------------------------------------
362
366
  // Size from the host container (not the window) so embedders control the pane.
363
367
  function resize() {
368
+ // Parked (see setActive): the buffer is deliberately 1x1 and must stay that
369
+ // way. iOS fires resizes constantly as the URL bar collapses, and every one
370
+ // of them would otherwise re-allocate a full MSAA buffer for a hidden pane.
371
+ if (!active) return;
364
372
  const w = container.clientWidth || 300, h = container.clientHeight || 150;
365
373
  renderer.setSize(w, h);
366
374
  camera.aspect = w / h;
@@ -481,12 +489,66 @@ export function createViewer(container, part) {
481
489
  }
482
490
 
483
491
  // --- render loop ----------------------------------------------------------
484
- renderer.setAnimationLoop(() => {
492
+ function renderFrame() {
485
493
  controls.update();
486
494
  if (cutaway.isEnabled) cutaway.updateForCamera();
487
495
  renderer.render(scene, camera);
488
496
  cutaway.renderOverlay(renderer, camera);
489
- });
497
+ }
498
+ renderer.setAnimationLoop(renderFrame);
499
+
500
+ // --- active / parked ------------------------------------------------------
501
+ // For a host that HIDES the viewer without unmounting it. partforge's own
502
+ // narrow layout uses `display: none` on the stage, which zeroes clientWidth
503
+ // and lets the ResizeObserver above collapse the buffer for free. An embedder
504
+ // that cannot do that — partforge-cloud's phone tab bar uses
505
+ // `visibility: hidden`, because the canvas has to keep its size for build
506
+ // screenshots — gets no such signal: the full-resolution MSAA drawing buffer
507
+ // stays resident and this loop keeps rendering an auto-rotating scene at
508
+ // 60fps behind an invisible pane. On an iPhone that is tens of megabytes and
509
+ // continuous GPU work nobody can see, so the host has to say so explicitly.
510
+ //
511
+ // Parking stops the loop and releases the drawing buffer. `setSize(1, 1,
512
+ // false)` leaves the canvas element's CSS box alone, so the host's layout
513
+ // does not move and the pane can be revealed again without a reflow.
514
+ function setActive(next) {
515
+ const want = next !== false;
516
+ if (disposed || want === active) return;
517
+ active = want;
518
+ if (!active) {
519
+ renderer.setAnimationLoop(null);
520
+ renderer.setSize(1, 1, false);
521
+ // The cached 1024² 4x-MSAA + stencil capture target is the other large
522
+ // allocation here — on a phone it is comparable to the canvas itself, so
523
+ // parking that kept it would leave half the memory behind. Dropping it
524
+ // costs one re-allocation on the next capture, which a parked viewer
525
+ // barely notices: the cache only ever hits on an exactly-square request,
526
+ // and a phone's capture aspect is not square, so those captures were
527
+ // allocating per call regardless.
528
+ _rt?.dispose();
529
+ _rt = null;
530
+ return;
531
+ }
532
+ resize(); // rebuild the buffer at whatever size the container is now
533
+ renderer.setAnimationLoop(renderFrame);
534
+ }
535
+
536
+ // --- context loss ---------------------------------------------------------
537
+ // Losing the WebGL context is how a memory-starved phone tells you it gave
538
+ // up. With no handler the canvas just freezes, indistinguishable from a hang,
539
+ // and three never re-initialises. preventDefault() is what makes the loss
540
+ // recoverable (three's own listener re-uploads on restore); the subscribers
541
+ // let an embedder surface it instead of showing a dead rectangle.
542
+ const contextLostListeners = new Set();
543
+ const onContextLostEvent = (event) => {
544
+ event.preventDefault();
545
+ for (const listener of [...contextLostListeners]) listener();
546
+ };
547
+ renderer.domElement.addEventListener("webglcontextlost", onContextLostEvent);
548
+ function onContextLost(listener) {
549
+ contextLostListeners.add(listener);
550
+ return () => contextLostListeners.delete(listener);
551
+ }
490
552
 
491
553
  // --- camera state (read/write for persistence; mount.js owns storage) -------
492
554
  function getCameraState() {
@@ -528,6 +590,11 @@ export function createViewer(container, part) {
528
590
  disposed = true;
529
591
  ro.disconnect();
530
592
  renderer.setAnimationLoop(null);
593
+ // Embedder callbacks must not outlive teardown — a disposed viewer has no
594
+ // context left to lose, and a surviving listener would keep the embedder's
595
+ // closure (and whatever it captured) alive.
596
+ renderer.domElement.removeEventListener("webglcontextlost", onContextLostEvent);
597
+ contextLostListeners.clear();
531
598
  controls.dispose();
532
599
  for (const t of flashTimers) clearTimeout(t);
533
600
  flashTimers.clear();
@@ -558,6 +625,8 @@ export function createViewer(container, part) {
558
625
  captureCanonicalViews,
559
626
  captureCurrent,
560
627
  setAutoRotate,
628
+ setActive,
629
+ onContextLost,
561
630
  setTheme,
562
631
  getCameraState,
563
632
  setCameraState,
@@ -5,12 +5,56 @@
5
5
  // (raycast), nearest surface point (closestPoint), and exact mesh-to-mesh distance
6
6
  // (distanceTo). AABB tree, median split on the widest centroid axis, slab ray–box
7
7
  // test with pruning.
8
+ //
9
+ // STORAGE — four flat typed arrays, no per-triangle and no per-node JS objects.
10
+ // The nested-array representation this replaced ([[x,y,z],[x,y,z],[x,y,z]] per
11
+ // triangle, wrapped in an object carrying min/max/centroid arrays, hung off object
12
+ // nodes) cost ~950 bytes and ~7 heap objects per triangle: 350 MB for a 400k-triangle
13
+ // mesh, which is what OOM-killed the inspect job in mobile Safari. This costs ~75
14
+ // bytes/triangle for a Manifold soup, and every query allocates only its own stack.
15
+ //
16
+ // vertices 9 coords per triangle (v0,v1,v2 interleaved), in MESH order — so the
17
+ // triangle ids in raycast's `tri`/`skipTri` are still mesh indices.
18
+ // Float32 in, Float32 out: a Manifold soup is already float32, and
19
+ // widening it would double the footprint without adding a bit of
20
+ // precision, while a mesh whose positions are plain JS numbers (OCCT,
21
+ // hand-written fixtures) is kept in a Float64Array. Either copy is
22
+ // exact, so no reading changes with the representation. Exposed
23
+ // READ-ONLY: every node bound was computed from these coords at build
24
+ // time, so writing into the array silently invalidates the whole tree
25
+ // (queries would prune against boxes that no longer contain their
26
+ // triangles). Read it through readTriangleInto() rather than
27
+ // open-coding the stride.
28
+ // order Uint32 triangle ids, permuted by the build so each leaf owns a
29
+ // contiguous run. The vertices themselves never move.
30
+ // bounds Float64, 6 per node: [minx,miny,minz, maxx,maxy,maxz]. Kept at double
31
+ // precision whatever the vertices are — a narrowed bound would have to
32
+ // be rounded outward to stay conservative, and node bounds are a small
33
+ // fraction of the total anyway (~0.6 nodes per triangle).
34
+ // meta Uint32, 2 per node. A LEAF is [firstIndexIntoOrder, count + 1]; an
35
+ // INTERNAL node is [rightChildIndex, 0]. The +1 is what lets an empty
36
+ // mesh's root still read as a leaf instead of as an internal node
37
+ // pointing at itself. Nodes are laid out in pre-order, so an internal
38
+ // node's left child is always the next node.
8
39
 
9
40
  const LEAF = 4; // max triangles per leaf
10
41
 
42
+ // Coords per triangle in a `vertices` store: v0,v1,v2 interleaved. The ONE place
43
+ // this layout is decoded outside the queries below — copy triangle `t` of `V` into
44
+ // `out` (9 numbers: x0,y0,z0, x1,y1,z1, x2,y2,z2) and hand callers a reusable
45
+ // buffer, so nobody else has to know the stride and no per-triangle garbage is
46
+ // made. min-wall casts one ray per triangle through this.
47
+ export function readTriangleInto(V, t, out) {
48
+ const o = t * 9;
49
+ for (let i = 0; i < 9; i++) out[i] = V[o + i];
50
+ return out;
51
+ }
52
+
11
53
  // Triangles as [v0,v1,v2] coord triples, from either a Manifold non-indexed soup
12
54
  // (positions = 9 floats/triangle, no indices) or an OCCT indexed mesh (positions =
13
- // 3 floats/vertex + indices = 3 vertex-indices/triangle).
55
+ // 3 floats/vertex + indices = 3 vertex-indices/triangle). A convenience for callers
56
+ // that want plain arrays; the BVH itself reads triangleVertices() below, because
57
+ // this shape costs four heap objects per triangle.
14
58
  export function meshTriangles(mesh) {
15
59
  const { positions, indices } = mesh;
16
60
  if (indices) {
@@ -33,48 +77,68 @@ export function meshTriangles(mesh) {
33
77
  return out;
34
78
  }
35
79
 
36
- function readTris(mesh) {
37
- const triangles = meshTriangles(mesh);
38
- return triangles.map(([v0, v1, v2], i) => {
39
- const min = [Math.min(v0[0], v1[0], v2[0]), Math.min(v0[1], v1[1], v2[1]), Math.min(v0[2], v1[2], v2[2])];
40
- const max = [Math.max(v0[0], v1[0], v2[0]), Math.max(v0[1], v1[1], v2[1]), Math.max(v0[2], v1[2], v2[2])];
41
- return { i, v0, v1, v2, min, max, c: [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2] };
42
- });
43
- }
44
-
45
- function aabbOf(items) {
46
- const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
47
- for (const it of items) for (let a = 0; a < 3; a++) { if (it.min[a] < min[a]) min[a] = it.min[a]; if (it.max[a] > max[a]) max[a] = it.max[a]; }
48
- return { min, max };
80
+ // The same triangles, flat: one typed array of 9 coords per triangle in mesh order.
81
+ // Float32 source → Float32Array (lossless, and half the bytes); anything else →
82
+ // Float64Array (plain JS numbers are doubles and must stay doubles).
83
+ export function triangleVertices(mesh) {
84
+ const { positions, indices } = mesh;
85
+ const Store = positions instanceof Float32Array ? Float32Array : Float64Array;
86
+ if (indices) {
87
+ const n = indices.length / 3, verts = new Store(n * 9);
88
+ for (let t = 0; t < n; t++) {
89
+ const o = t * 9;
90
+ for (let k = 0; k < 3; k++) {
91
+ const s = indices[3 * t + k] * 3;
92
+ verts[o + 3 * k] = positions[s];
93
+ verts[o + 3 * k + 1] = positions[s + 1];
94
+ verts[o + 3 * k + 2] = positions[s + 2];
95
+ }
96
+ }
97
+ return verts;
98
+ }
99
+ const n = positions.length / 9;
100
+ if (positions instanceof Float32Array) return positions.slice(0, n * 9);
101
+ const verts = new Store(n * 9);
102
+ for (let i = 0; i < n * 9; i++) verts[i] = positions[i];
103
+ return verts;
49
104
  }
50
105
 
51
- function build(items) {
52
- const box = aabbOf(items);
53
- if (items.length <= LEAF) return { ...box, tris: items };
54
- const ext = [box.max[0] - box.min[0], box.max[1] - box.min[1], box.max[2] - box.min[2]];
55
- const axis = ext[0] >= ext[1] && ext[0] >= ext[2] ? 0 : ext[1] >= ext[2] ? 1 : 2;
56
- const sorted = items.slice().sort((p, q) => p.c[axis] - q.c[axis]);
57
- const mid = sorted.length >> 1;
58
- const left = sorted.slice(0, mid), right = sorted.slice(mid);
59
- if (left.length === 0 || right.length === 0) return { ...box, tris: items }; // degenerate split
60
- return { ...box, left: build(left), right: build(right) };
106
+ // Node count for a subtree of `len` triangles. The split is a pure function of the
107
+ // length (median at len>>1, leaf at <= LEAF), so the tree's shape — and therefore
108
+ // its exact size is known before a single triangle is sorted. That is what lets
109
+ // `bounds`/`meta` be allocated once at the right size rather than grown or trimmed.
110
+ function countNodes(len, memo) {
111
+ if (len <= LEAF) return 1;
112
+ const hit = memo.get(len);
113
+ if (hit !== undefined) return hit;
114
+ const mid = len >> 1;
115
+ const n = 1 + countNodes(mid, memo) + countNodes(len - mid, memo);
116
+ memo.set(len, n);
117
+ return n;
61
118
  }
62
119
 
63
- // slab test: returns the entry distance if the ray meets [min,max] within (tMin,best], else Infinity
64
- function rayBox(o, invD, min, max, tMin, best) {
120
+ // slab test: does the ray meet node `nb`'s box within (tMin, best]?
121
+ function rayHitsBox(ox, oy, oz, ix, iy, iz, B, nb, tMin, best) {
65
122
  let t0 = tMin, t1 = best;
66
- for (let a = 0; a < 3; a++) {
67
- let lo = (min[a] - o[a]) * invD[a], hi = (max[a] - o[a]) * invD[a];
68
- if (lo > hi) { const tmp = lo; lo = hi; hi = tmp; }
69
- if (lo > t0) t0 = lo; if (hi < t1) t1 = hi;
70
- if (t0 > t1) return Infinity;
71
- }
72
- return t0;
123
+ let lo = (B[nb] - ox) * ix, hi = (B[nb + 3] - ox) * ix;
124
+ if (lo > hi) { const s = lo; lo = hi; hi = s; }
125
+ if (lo > t0) t0 = lo; if (hi < t1) t1 = hi;
126
+ if (t0 > t1) return false;
127
+ lo = (B[nb + 1] - oy) * iy; hi = (B[nb + 4] - oy) * iy;
128
+ if (lo > hi) { const s = lo; lo = hi; hi = s; }
129
+ if (lo > t0) t0 = lo; if (hi < t1) t1 = hi;
130
+ if (t0 > t1) return false;
131
+ lo = (B[nb + 2] - oz) * iz; hi = (B[nb + 5] - oz) * iz;
132
+ if (lo > hi) { const s = lo; lo = hi; hi = s; }
133
+ if (lo > t0) t0 = lo; if (hi < t1) t1 = hi;
134
+ return t0 <= t1;
73
135
  }
74
136
 
75
- // nearest point on triangle to P (Ericson), returns { point, d2 }
76
- function closestOnTri(P, tri) {
77
- const A = tri.v0, B = tri.v1, C = tri.v2;
137
+ // nearest point on triangle `base` of `V` to P (Ericson), returns { point, d2 }
138
+ function closestOnTri(P, V, base) {
139
+ const A = [V[base], V[base + 1], V[base + 2]];
140
+ const B = [V[base + 3], V[base + 4], V[base + 5]];
141
+ const C = [V[base + 6], V[base + 7], V[base + 8]];
78
142
  const sub = (p, q) => [p[0]-q[0], p[1]-q[1], p[2]-q[2]];
79
143
  const dot = (p, q) => p[0]*q[0] + p[1]*q[1] + p[2]*q[2];
80
144
  const add = (p, q) => [p[0]+q[0], p[1]+q[1], p[2]+q[2]];
@@ -98,21 +162,23 @@ function closestOnTri(P, tri) {
98
162
  return { point: Q, d2: dot(pq, pq) };
99
163
  }
100
164
 
101
- // squared distance from point to an AABB (0 inside)
102
- function distSqBox(p, min, max) {
103
- let s = 0;
104
- for (let a = 0; a < 3; a++) { const v = p[a] < min[a] ? min[a] - p[a] : p[a] > max[a] ? p[a] - max[a] : 0; s += v * v; }
105
- return s;
165
+ // squared distance from a point to node `nb`'s AABB (0 inside)
166
+ function distSqBox(px, py, pz, B, nb) {
167
+ const x = px < B[nb] ? B[nb] - px : px > B[nb + 3] ? px - B[nb + 3] : 0;
168
+ const y = py < B[nb + 1] ? B[nb + 1] - py : py > B[nb + 4] ? py - B[nb + 4] : 0;
169
+ const z = pz < B[nb + 2] ? B[nb + 2] - pz : pz > B[nb + 5] ? pz - B[nb + 5] : 0;
170
+ return x * x + y * y + z * z;
106
171
  }
107
172
 
108
173
  // summed extent of a node's AABB — the "which node is larger" heuristic for dual traversal
109
- const nodeExtent = (n) => (n.max[0] - n.min[0]) + (n.max[1] - n.min[1]) + (n.max[2] - n.min[2]);
174
+ const nodeExtent = (B, nb) => (B[nb + 3] - B[nb]) + (B[nb + 4] - B[nb + 1]) + (B[nb + 5] - B[nb + 2]);
110
175
 
111
- // squared distance between two AABBs (0 when they overlap)
112
- function boxBoxDistSq(a, b) {
176
+ // squared distance between two nodes' AABBs (0 when they overlap)
177
+ function boxBoxDistSq(A, na, B, nb) {
113
178
  let s = 0;
114
179
  for (let ax = 0; ax < 3; ax++) {
115
- const v = a.min[ax] > b.max[ax] ? a.min[ax] - b.max[ax] : b.min[ax] > a.max[ax] ? b.min[ax] - a.max[ax] : 0;
180
+ const v = A[na + ax] > B[nb + 3 + ax] ? A[na + ax] - B[nb + 3 + ax]
181
+ : B[nb + ax] > A[na + 3 + ax] ? B[nb + ax] - A[na + 3 + ax] : 0;
116
182
  s += v * v;
117
183
  }
118
184
  return s;
@@ -146,32 +212,34 @@ function closestSegSeg(P1, Q1, P2, Q2) {
146
212
  return { a: A, b: B, d2: dot(pq, pq) };
147
213
  }
148
214
 
149
- // exact min distance between two triangles { d2, a, b } (a on t1, b on t2).
150
- // Non-intersecting triangles realize their minimum at a vertex-face or edge-edge
151
- // feature pair; a piercing edge (interior×interior crossing) is caught first with
152
- // rayTri, since feature distances alone would miss it. rayTri's t is in units of
153
- // the unnormalized edge direction, so 0 < t <= 1 means the segment itself pierces;
154
- // parallel/grazing edges return Infinity and the coplanar cases fall to the
155
- // feature distances.
156
- function triTriDist(t1, t2) {
157
- const edges = (t) => [[t.v0, t.v1], [t.v1, t.v2], [t.v2, t.v0]];
215
+ // exact min distance between triangle `b1` of `V1` and triangle `b2` of `V2`
216
+ // { d2, a, b } (a on the first, b on the second). Non-intersecting triangles
217
+ // realize their minimum at a vertex-face or edge-edge feature pair; a piercing
218
+ // edge (interior×interior crossing) is caught first with rayTri, since feature
219
+ // distances alone would miss it. rayTri's t is in units of the unnormalized edge
220
+ // direction, so 0 < t <= 1 means the segment itself pierces; parallel/grazing
221
+ // edges return Infinity and the coplanar cases fall to the feature distances.
222
+ function triTriDist(V1, b1, V2, b2) {
223
+ const verts = (V, b) => [[V[b], V[b+1], V[b+2]], [V[b+3], V[b+4], V[b+5]], [V[b+6], V[b+7], V[b+8]]];
224
+ const t1 = verts(V1, b1), t2 = verts(V2, b2);
225
+ const edges = (t) => [[t[0], t[1]], [t[1], t[2]], [t[2], t[0]]];
158
226
  for (const [p, q] of edges(t1)) {
159
227
  const d = [q[0] - p[0], q[1] - p[1], q[2] - p[2]];
160
- const t = rayTri(p, d, t2, 0);
228
+ const t = rayTri(p[0], p[1], p[2], d[0], d[1], d[2], V2, b2, 0);
161
229
  if (t <= 1) { const at = [p[0] + d[0] * t, p[1] + d[1] * t, p[2] + d[2] * t]; return { d2: 0, a: at, b: at }; }
162
230
  }
163
231
  for (const [p, q] of edges(t2)) {
164
232
  const d = [q[0] - p[0], q[1] - p[1], q[2] - p[2]];
165
- const t = rayTri(p, d, t1, 0);
233
+ const t = rayTri(p[0], p[1], p[2], d[0], d[1], d[2], V1, b1, 0);
166
234
  if (t <= 1) { const at = [p[0] + d[0] * t, p[1] + d[1] * t, p[2] + d[2] * t]; return { d2: 0, a: at, b: at }; }
167
235
  }
168
236
  let best = { d2: Infinity, a: null, b: null };
169
- for (const v of [t2.v0, t2.v1, t2.v2]) {
170
- const r = closestOnTri(v, t1);
237
+ for (const v of t2) {
238
+ const r = closestOnTri(v, V1, b1);
171
239
  if (r.d2 < best.d2) best = { d2: r.d2, a: r.point, b: v };
172
240
  }
173
- for (const v of [t1.v0, t1.v1, t1.v2]) {
174
- const r = closestOnTri(v, t2);
241
+ for (const v of t1) {
242
+ const r = closestOnTri(v, V2, b2);
175
243
  if (r.d2 < best.d2) best = { d2: r.d2, a: v, b: r.point };
176
244
  }
177
245
  for (const [p1, q1] of edges(t1)) for (const [p2, q2] of edges(t2)) {
@@ -181,58 +249,154 @@ function triTriDist(t1, t2) {
181
249
  return best;
182
250
  }
183
251
 
184
- // Möller–Trumbore; returns t>tMin or Infinity
185
- function rayTri(o, d, tri, tMin) {
186
- const e1 = [tri.v1[0] - tri.v0[0], tri.v1[1] - tri.v0[1], tri.v1[2] - tri.v0[2]];
187
- const e2 = [tri.v2[0] - tri.v0[0], tri.v2[1] - tri.v0[1], tri.v2[2] - tri.v0[2]];
188
- const p = [d[1] * e2[2] - d[2] * e2[1], d[2] * e2[0] - d[0] * e2[2], d[0] * e2[1] - d[1] * e2[0]];
189
- const det = e1[0] * p[0] + e1[1] * p[1] + e1[2] * p[2];
252
+ // Möller–Trumbore against triangle `base` of `V`; returns t>tMin or Infinity.
253
+ // Scalars throughout — this is the inner loop of every raycast, and min-wall casts
254
+ // one ray per sampled triangle.
255
+ function rayTri(ox, oy, oz, dx, dy, dz, V, base, tMin) {
256
+ const ax = V[base], ay = V[base + 1], az = V[base + 2];
257
+ const e1x = V[base + 3] - ax, e1y = V[base + 4] - ay, e1z = V[base + 5] - az;
258
+ const e2x = V[base + 6] - ax, e2y = V[base + 7] - ay, e2z = V[base + 8] - az;
259
+ const px = dy * e2z - dz * e2y, py = dz * e2x - dx * e2z, pz = dx * e2y - dy * e2x;
260
+ const det = e1x * px + e1y * py + e1z * pz;
190
261
  if (det > -1e-12 && det < 1e-12) return Infinity;
191
262
  const inv = 1 / det;
192
- const tv = [o[0] - tri.v0[0], o[1] - tri.v0[1], o[2] - tri.v0[2]];
193
- const u = (tv[0] * p[0] + tv[1] * p[1] + tv[2] * p[2]) * inv;
263
+ const tx = ox - ax, ty = oy - ay, tz = oz - az;
264
+ const u = (tx * px + ty * py + tz * pz) * inv;
194
265
  if (u < 0 || u > 1) return Infinity;
195
- const q = [tv[1] * e1[2] - tv[2] * e1[1], tv[2] * e1[0] - tv[0] * e1[2], tv[0] * e1[1] - tv[1] * e1[0]];
196
- const v = (d[0] * q[0] + d[1] * q[1] + d[2] * q[2]) * inv;
266
+ const qx = ty * e1z - tz * e1y, qy = tz * e1x - tx * e1z, qz = tx * e1y - ty * e1x;
267
+ const v = (dx * qx + dy * qy + dz * qz) * inv;
197
268
  if (v < 0 || u + v > 1) return Infinity;
198
- const t = (e2[0] * q[0] + e2[1] * q[1] + e2[2] * q[2]) * inv;
269
+ const t = (e2x * qx + e2y * qy + e2z * qz) * inv;
199
270
  return t > tMin ? t : Infinity;
200
271
  }
201
272
 
273
+ // The BVH for `mesh`, memoized in a CALLER-OWNED Map keyed on the mesh object.
274
+ // THE ONE HOME for the caller-owned-Map doctrine; the callers below just point here.
275
+ //
276
+ // Exists because two passes over the same posed meshes each want an index —
277
+ // min-wall casts rays, meshGaps measures pair distances — and building both is a
278
+ // second full index per sub-part at ~77 bytes/triangle. measure() owns the Map and
279
+ // hands the same one (or, for min-wall, the resolved index) to both passes.
280
+ //
281
+ // `cache` is optional everywhere, and with none this is exactly buildBVH: the
282
+ // direct callers that have no second pass to share with (assemblyGaps' bare
283
+ // meshGaps, the tests) keep today's behaviour of building fresh. The Map is
284
+ // deliberately the caller's, not a module-level WeakMap: a WeakMap keyed on
285
+ // meshes would keep an index alive for as long as anything held its mesh, which
286
+ // is the quiet retention this pass exists to remove. Here the index's lifetime is
287
+ // visibly the caller's scope.
288
+ export function cachedBVH(mesh, cache) {
289
+ if (!cache) return buildBVH(mesh);
290
+ let bvh = cache.get(mesh);
291
+ if (!bvh) cache.set(mesh, (bvh = buildBVH(mesh)));
292
+ return bvh;
293
+ }
294
+
202
295
  export function buildBVH(mesh) {
203
- const tris = readTris(mesh);
204
- const root = build(tris);
296
+ const verts = triangleVertices(mesh);
297
+ const count = verts.length / 9;
298
+
299
+ const order = new Uint32Array(count);
300
+ // AABB-midpoint centroids, one Float64 triple per triangle. Transient: only the
301
+ // median split reads them, and they are released explicitly below.
302
+ let cent = new Float64Array(count * 3);
303
+ for (let t = 0; t < count; t++) {
304
+ order[t] = t;
305
+ const o = t * 9;
306
+ for (let a = 0; a < 3; a++) {
307
+ const p = verts[o + a], q = verts[o + 3 + a], r = verts[o + 6 + a];
308
+ const lo = p < q ? (p < r ? p : r) : (q < r ? q : r);
309
+ const hi = p > q ? (p > r ? p : r) : (q > r ? q : r);
310
+ cent[t * 3 + a] = (lo + hi) / 2;
311
+ }
312
+ }
313
+
314
+ const bounds = new Float64Array(countNodes(count, new Map()) * 6);
315
+ const meta = new Uint32Array(bounds.length / 3);
316
+ let next = 0;
317
+
318
+ // Pre-order emit: a node claims its slot, writes its own AABB, then either
319
+ // becomes a leaf over order[start, start+len) or sorts that range by the widest
320
+ // centroid axis and recurses. The left child lands at self+1 by construction, so
321
+ // only the right child's index needs storing.
322
+ function emit(start, len) {
323
+ const self = next++, nb = self * 6;
324
+ let x0 = Infinity, y0 = Infinity, z0 = Infinity, x1 = -Infinity, y1 = -Infinity, z1 = -Infinity;
325
+ for (let k = 0; k < len; k++) {
326
+ const o = order[start + k] * 9;
327
+ for (let j = 0; j < 9; j += 3) {
328
+ const x = verts[o + j], y = verts[o + j + 1], z = verts[o + j + 2];
329
+ if (x < x0) x0 = x; if (x > x1) x1 = x;
330
+ if (y < y0) y0 = y; if (y > y1) y1 = y;
331
+ if (z < z0) z0 = z; if (z > z1) z1 = z;
332
+ }
333
+ }
334
+ bounds[nb] = x0; bounds[nb + 1] = y0; bounds[nb + 2] = z0;
335
+ bounds[nb + 3] = x1; bounds[nb + 4] = y1; bounds[nb + 5] = z1;
336
+ if (len <= LEAF) { meta[self * 2] = start; meta[self * 2 + 1] = len + 1; return self; }
337
+ const ex = x1 - x0, ey = y1 - y0, ez = z1 - z0;
338
+ const axis = ex >= ey && ex >= ez ? 0 : ey >= ez ? 1 : 2;
339
+ order.subarray(start, start + len).sort((p, q) => cent[p * 3 + axis] - cent[q * 3 + axis]);
340
+ // No empty-half guard: len > LEAF here (LEAF >= 1), so mid = len>>1 >= 2 and
341
+ // len - mid >= 3 — both halves always non-empty. The nested-array build this
342
+ // replaced carried such a check; it was dead code there too, and countNodes()
343
+ // assumes this same split, so a guard that ever fired would mis-size `bounds`.
344
+ const mid = len >> 1;
345
+ emit(start, mid);
346
+ meta[self * 2] = emit(start + mid, len - mid);
347
+ meta[self * 2 + 1] = 0;
348
+ return self;
349
+ }
350
+ emit(0, count);
351
+ // Dropped explicitly, not left to scope: `emit` and the three query closures below
352
+ // share one function context, so a `cent` merely gone out of use would still be
353
+ // reachable from every returned BVH — 24 bytes/triangle of dead weight for the
354
+ // life of the index.
355
+ cent = null;
205
356
 
206
357
  function raycast(origin, dir, { tMin = 1e-6, tMax = Infinity, skipTri = -1 } = {}) {
207
- const invD = [1 / dir[0], 1 / dir[1], 1 / dir[2]];
358
+ const ox = origin[0], oy = origin[1], oz = origin[2];
359
+ const dx = dir[0], dy = dir[1], dz = dir[2];
360
+ const ix = 1 / dx, iy = 1 / dy, iz = 1 / dz;
208
361
  let best = tMax, bestTri = -1;
209
- const stack = [root];
362
+ const stack = [0];
210
363
  while (stack.length) {
211
- const node = stack.pop();
212
- if (rayBox(origin, invD, node.min, node.max, tMin, best) === Infinity) continue;
213
- if (node.tris) {
214
- for (const tri of node.tris) {
215
- if (tri.i === skipTri) continue;
216
- const t = rayTri(origin, dir, tri, tMin);
217
- if (t < best) { best = t; bestTri = tri.i; }
364
+ const n = stack.pop();
365
+ if (!rayHitsBox(ox, oy, oz, ix, iy, iz, bounds, n * 6, tMin, best)) continue;
366
+ const packed = meta[n * 2 + 1];
367
+ if (packed) {
368
+ const start = meta[n * 2];
369
+ for (let k = 0; k < packed - 1; k++) {
370
+ const tri = order[start + k];
371
+ if (tri === skipTri) continue;
372
+ const t = rayTri(ox, oy, oz, dx, dy, dz, verts, tri * 9, tMin);
373
+ if (t < best) { best = t; bestTri = tri; }
218
374
  }
219
- } else { stack.push(node.left, node.right); }
375
+ } else { stack.push(n + 1, meta[n * 2]); }
220
376
  }
221
377
  return bestTri === -1 ? null : { t: best, tri: bestTri };
222
378
  }
223
379
 
224
380
  function closestPoint(p) {
381
+ const px = p[0], py = p[1], pz = p[2];
225
382
  let best2 = Infinity, bestPt = null, bestTri = -1;
226
- const stack = [root];
383
+ const stack = [0];
227
384
  while (stack.length) {
228
- const node = stack.pop();
229
- if (distSqBox(p, node.min, node.max) > best2) continue;
230
- if (node.tris) {
231
- for (const tri of node.tris) { const r = closestOnTri(p, tri); if (r.d2 < best2) { best2 = r.d2; bestPt = r.point; bestTri = tri.i; } }
385
+ const n = stack.pop();
386
+ if (distSqBox(px, py, pz, bounds, n * 6) > best2) continue;
387
+ const packed = meta[n * 2 + 1];
388
+ if (packed) {
389
+ const start = meta[n * 2];
390
+ for (let k = 0; k < packed - 1; k++) {
391
+ const tri = order[start + k];
392
+ const r = closestOnTri(p, verts, tri * 9);
393
+ if (r.d2 < best2) { best2 = r.d2; bestPt = r.point; bestTri = tri; }
394
+ }
232
395
  } else {
233
396
  // visit the nearer child first for better pruning
234
- const dl = distSqBox(p, node.left.min, node.left.max), dr = distSqBox(p, node.right.min, node.right.max);
235
- if (dl < dr) { stack.push(node.right, node.left); } else { stack.push(node.left, node.right); }
397
+ const l = n + 1, r = meta[n * 2];
398
+ const dl = distSqBox(px, py, pz, bounds, l * 6), dr = distSqBox(px, py, pz, bounds, r * 6);
399
+ if (dl < dr) { stack.push(r, l); } else { stack.push(l, r); }
236
400
  }
237
401
  }
238
402
  return { point: bestPt, dist: Math.sqrt(best2), tri: bestTri };
@@ -240,28 +404,31 @@ export function buildBVH(mesh) {
240
404
 
241
405
  // Exact minimum surface-to-surface distance to another buildBVH result.
242
406
  // Dual traversal pruned by AABB–AABB distance; exact triangle–triangle
243
- // distance at leaf pairs; early-exits at 0 (touching/intersecting).
407
+ // distance at leaf pairs; early-exits at 0 (touching/intersecting). The stack
408
+ // holds node-index PAIRS, pushed and popped two entries at a time.
244
409
  function distanceTo(other) {
410
+ const oV = other.vertices, oB = other._bounds, oM = other._meta, oO = other._order;
245
411
  let best = { d2: Infinity, a: null, b: null };
246
- const stack = [[root, other._root]];
412
+ const stack = [0, 0];
247
413
  while (stack.length && best.d2 > 0) {
248
- const [na, nb] = stack.pop();
249
- if (boxBoxDistSq(na, nb) >= best.d2) continue;
250
- const aLeaf = !!na.tris, bLeaf = !!nb.tris;
251
- if (aLeaf && bLeaf) {
252
- for (const ta of na.tris) for (const tb of nb.tris) {
253
- const r = triTriDist(ta, tb);
414
+ const nb = stack.pop(), na = stack.pop();
415
+ if (boxBoxDistSq(bounds, na * 6, oB, nb * 6) >= best.d2) continue;
416
+ const pa = meta[na * 2 + 1], pb = oM[nb * 2 + 1];
417
+ if (pa && pb) {
418
+ const sa = meta[na * 2], sb = oM[nb * 2];
419
+ for (let i = 0; i < pa - 1; i++) for (let j = 0; j < pb - 1; j++) {
420
+ const r = triTriDist(verts, order[sa + i] * 9, oV, oO[sb + j] * 9);
254
421
  if (r.d2 < best.d2) best = r;
255
422
  }
256
- } else if (!aLeaf && (bLeaf || nodeExtent(na) >= nodeExtent(nb))) {
423
+ } else if (!pa && (pb || nodeExtent(bounds, na * 6) >= nodeExtent(oB, nb * 6))) {
257
424
  // descend the larger node; push the nearer child last so it pops first
258
- const dl = boxBoxDistSq(na.left, nb), dr = boxBoxDistSq(na.right, nb);
259
- if (dl < dr) stack.push([na.right, nb], [na.left, nb]);
260
- else stack.push([na.left, nb], [na.right, nb]);
425
+ const l = na + 1, r = meta[na * 2];
426
+ const dl = boxBoxDistSq(bounds, l * 6, oB, nb * 6), dr = boxBoxDistSq(bounds, r * 6, oB, nb * 6);
427
+ if (dl < dr) stack.push(r, nb, l, nb); else stack.push(l, nb, r, nb);
261
428
  } else {
262
- const dl = boxBoxDistSq(na, nb.left), dr = boxBoxDistSq(na, nb.right);
263
- if (dl < dr) stack.push([na, nb.right], [na, nb.left]);
264
- else stack.push([na, nb.left], [na, nb.right]);
429
+ const l = nb + 1, r = oM[nb * 2];
430
+ const dl = boxBoxDistSq(bounds, na * 6, oB, l * 6), dr = boxBoxDistSq(bounds, na * 6, oB, r * 6);
431
+ if (dl < dr) stack.push(na, r, na, l); else stack.push(na, l, na, r);
265
432
  }
266
433
  }
267
434
  if (best.a === null) return { distance: Infinity, at: null, pointA: null, pointB: null }; // empty mesh
@@ -269,5 +436,28 @@ export function buildBVH(mesh) {
269
436
  return { distance: Math.sqrt(best.d2), at, pointA: best.a, pointB: best.b };
270
437
  }
271
438
 
272
- return { raycast, closestPoint, distanceTo, _root: root };
439
+ return {
440
+ raycast, closestPoint, distanceTo,
441
+ triangleCount: count,
442
+ // Flat, 9 per triangle, mesh order — min-wall casts from these. READ-ONLY (see
443
+ // the STORAGE note up top); decode a triangle with readTriangleInto().
444
+ vertices: verts,
445
+ // The root node's AABB, [minx,miny,minz, maxx,maxy,maxz] — a copy, so reading
446
+ // it cannot disturb the tree. It is the mesh's own bounding box over exactly
447
+ // the vertices the triangles reference, already computed by the build; min-wall
448
+ // uses it for its default ray cap instead of rescanning mesh.positions.
449
+ rootBounds: [bounds[0], bounds[1], bounds[2], bounds[3], bounds[4], bounds[5]],
450
+ // Diagnostic self-report: the four typed arrays' byte lengths, summed at build.
451
+ // NOT a measurement of retained memory — it counts only what this function
452
+ // knows it allocated, and cannot see a regression that reintroduces per-triangle
453
+ // JS objects (test/bvh.test.js weighs that in a child process). What it does
454
+ // pin, and the child-process bound is too loose to catch, is the index's
455
+ // COMPOSITION — widening `vertices` to Float64 would show up here.
456
+ bytesAllocated: verts.byteLength + order.byteLength + bounds.byteLength + meta.byteLength,
457
+ // Private to this module: only distanceTo reads them, off the OTHER BVH — a
458
+ // dual traversal walks two trees at once, and JS has no cross-instance private
459
+ // access to reach for. Nothing outside bvh.js touches them, and nothing should:
460
+ // they are raw node storage whose meaning is the packing rules in the header.
461
+ _bounds: bounds, _meta: meta, _order: order,
462
+ };
273
463
  }
@@ -1,5 +1,5 @@
1
1
  import { buildView } from "./build.js";
2
- import { buildBVH } from "./bvh.js";
2
+ import { cachedBVH } from "./bvh.js";
3
3
 
4
4
  // A measured pair distance at or below this (mm) counts as touching — absorbs
5
5
  // posing float error while staying far below any real print clearance.
@@ -17,12 +17,15 @@ export const pairKey = (a, b) => [a, b].sort().join("×");
17
17
  // meshes ([{ name, mesh }] — buildView output). Distance 0 = touching or
18
18
  // interpenetrating surfaces; callers filter. Pairs involving an empty mesh are
19
19
  // skipped (the watertight gate owns that failure). Pure mesh math — both backends.
20
+ // `bvhCache` is an optional caller-owned Map — see cachedBVH for the doctrine;
21
+ // measure() draws min-wall's index out of the same one, so each sub-part mesh is
22
+ // indexed once, not twice.
20
23
  // → [{ a, b, distance, at: [x,y,z] }]
21
- export function meshGaps(built) {
24
+ export function meshGaps(built, { bvhCache } = {}) {
22
25
  const hasTris = (m) => (m.indices ? m.indices.length > 0 : m.positions.length > 0);
23
26
  const bvhs = built
24
27
  .filter(({ mesh }) => hasTris(mesh))
25
- .map(({ name, mesh }) => ({ name, bvh: buildBVH(mesh) }));
28
+ .map(({ name, mesh }) => ({ name, bvh: cachedBVH(mesh, bvhCache) }));
26
29
  const out = [];
27
30
  for (let i = 0; i < bvhs.length; i++) {
28
31
  for (let j = i + 1; j < bvhs.length; j++) {
@@ -1,4 +1,5 @@
1
1
  import { buildView } from "./build.js";
2
+ import { cachedBVH } from "./bvh.js";
2
3
  import { assemblyOverlaps } from "../framework/assembly.js";
3
4
  import { meshGaps, pairKey, CONTACT_EPS, GAP_THRESHOLD } from "./gaps.js";
4
5
  import { bounds, meshArea, meshCentroid } from "./mesh.js";
@@ -15,14 +16,29 @@ const unionBounds = (list) => list.reduce(
15
16
  // the assembly overlap check plus pair gap distances (near misses are reported,
16
17
  // never folded into `ok`). All solid facts are read BEFORE assemblyOverlaps,
17
18
  // which frees the shared kernel's objects at its end.
18
- // → { part, view, subparts[], aggregate, overlaps[], gaps[], nearMisses[], ok }
19
+ // → { part, view, measuredMinWall, subparts[], aggregate, overlaps[], gaps[],
20
+ // nearMisses[], ok }
19
21
  export function measure(kernel, part, view = Object.keys(part.views)[0], params = {}, opts = {}) {
20
22
  const built = buildView(kernel, part, view, params);
23
+ // ONE BVH per sub-part mesh for this call, shared by the two passes that need
24
+ // one: min-wall (inward rays per triangle) and meshGaps (pair distances). They
25
+ // used to index the same mesh objects independently, so every sub-part of a
26
+ // multi-part view was built into a BVH twice — at ~77 bytes/triangle that is a
27
+ // whole second index's worth of build time and peak memory for nothing.
28
+ //
29
+ // This Map is the caller-owned cache cachedBVH documents — see there for why it
30
+ // is a Map of ours and not a module-level WeakMap. It dies with the call. Peak
31
+ // memory is unchanged (meshGaps already held every sub-part's index at once);
32
+ // the cache just fills it earlier. min-wall indexes exactly one mesh, so it is
33
+ // handed the resolved BVH rather than the Map.
34
+ const bvhCache = new Map();
21
35
  const subBounds = [];
22
36
  const subparts = built.map(({ name, solid, mesh }) => {
23
37
  const b = bounds(mesh.positions);
24
38
  subBounds.push(b);
25
- const mw = opts.minWall ? minWall(mesh) : null;
39
+ // Resolved lazily and only when asked for: without min-wall, a single-sub-part
40
+ // view (no meshGaps) must still build no index at all.
41
+ const mw = opts.minWall ? minWall(mesh, { bvh: cachedBVH(mesh, bvhCache) }) : null;
26
42
  return {
27
43
  name,
28
44
  bbox: size(b),
@@ -35,6 +51,14 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
35
51
  holes: typeof solid.genus === "function" ? solid.genus() : null,
36
52
  minWall: mw?.value ?? null,
37
53
  minWallAt: mw?.location ?? null,
54
+ // Sampling accounting, so a report can tell a guaranteed minimum from an
55
+ // upper bound: on a dense mesh min-wall casts from a spread subset rather
56
+ // than every triangle (see min-wall.js). Exact readings say so explicitly,
57
+ // and a sampled run that found no wall still fills these in — `minWall`
58
+ // null with samples accounted for is "looked, found nothing"; null with
59
+ // `measuredMinWall` false is "never looked".
60
+ minWallSampled: mw?.sampled ?? false,
61
+ minWallSamples: mw ? { sampled: mw.sampledTriangles, total: mw.totalTriangles } : null,
38
62
  };
39
63
  });
40
64
 
@@ -42,7 +66,7 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
42
66
  // so this reads on OCCT too. nearMisses = the issue-#29 signal: pairs that
43
67
  // *almost* touch; overlapping pairs are excluded by name (a fully-contained
44
68
  // sub-part has surface distance > 0 but is the overlap gate's business).
45
- const gaps = built.length > 1 ? meshGaps(built) : [];
69
+ const gaps = built.length > 1 ? meshGaps(built, { bvhCache }) : [];
46
70
 
47
71
  // Rebuilds with the same kernel and cleans up at its end — every solid fact
48
72
  // above is already read, so this is safe.
@@ -73,6 +97,12 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
73
97
  return {
74
98
  part: part.meta?.title ?? view,
75
99
  view,
100
+ // Whether this measurement cast min-wall rays at all — stamped by the pass
101
+ // that did (or didn't) do the work, so a consumer never has to be told. A
102
+ // result with this false carries `minWall: null` on every sub-part because
103
+ // nothing measured it, which reads identically to "no reading available";
104
+ // verify's seeding rule turns on exactly this distinction (see verify.js).
105
+ measuredMinWall: !!opts.minWall,
76
106
  subparts,
77
107
  aggregate,
78
108
  overlaps,
@@ -3,36 +3,96 @@
3
3
  // voxel/SDF approach on both accuracy and speed). For each surface triangle, cast a ray
4
4
  // inward (reverse of its outward normal) from the centroid; the nearest hit is the local
5
5
  // material thickness. The minimum across samples is the reported min wall.
6
- // Works with both Manifold non-indexed meshes and OCCT indexed meshes (via meshTriangles).
7
- import { buildBVH, meshTriangles } from "./bvh.js";
6
+ // Works with both Manifold non-indexed meshes and OCCT indexed meshes (via the BVH's
7
+ // flat vertex store — never materialize a triangle-per-object list here, that is the
8
+ // allocation this pass exists to avoid).
9
+ //
10
+ // SAMPLING CONTRACT. One ray per triangle is unbounded work, and a dense mesh makes it
11
+ // the dominant cost of the inspect job: ~1.9 s and hundreds of megabytes of transient
12
+ // garbage at 400k triangles on a laptop, several times that on a phone. Past
13
+ // MAX_SAMPLES triangles the pass casts from a spread subset instead, and SAYS SO —
14
+ // the result always carries { sampled, sampledTriangles, totalTriangles }, so a
15
+ // report consumer can tell a guaranteed minimum from a lower-confidence one. A
16
+ // sampled reading is an upper bound on the true minimum: it can miss a thin spot,
17
+ // never invent one. `sampledTriangles` is the SAMPLE BUDGET — how many triangles
18
+ // the walk selected — not a count of rays actually cast: a degenerate (zero-area)
19
+ // triangle has no normal to cast along and is skipped without a ray.
20
+ //
21
+ // Only an EMPTY mesh reads as no result at all (`null`). A mesh whose sampled rays
22
+ // all miss returns the usual object with `value: null`, because the sampling
23
+ // accounting is exactly what a reader needs in that case — "we looked at 50k of
24
+ // 400k triangles and found no wall" is a very different statement from "nobody
25
+ // measured", and the two used to be indistinguishable downstream.
26
+ import { buildBVH, readTriangleInto } from "./bvh.js";
8
27
 
9
- export function minWall(mesh, { maxThickness } = {}) {
10
- const pos = mesh.positions;
11
- const tris = meshTriangles(mesh);
12
- if (tris.length === 0) return null;
28
+ // Triangle budget above which minWall samples. Chosen so the parts people actually
29
+ // author stay exact: everything in src/parts/ is 200–10,000 triangles, and a
30
+ // preview-quality mesh of a fairly ornate part lands in the low tens of thousands.
31
+ // 50,000 is comfortably above both while capping the pass at roughly a quarter
32
+ // second — dense enough meshes (a high-facet lathe, a big imported STEP tessellation)
33
+ // are the only ones that engage it. Override per call with `{ maxSamples }`.
34
+ const MAX_SAMPLES = 50_000;
13
35
 
14
- // bbox diagonal as the default cap (a ray exiting into open air gets no hit anyway).
36
+ const gcd = (a, b) => { while (b) { const t = a % b; a = b; b = t; } return a; };
37
+
38
+ // Stride for the sampling walk: near n/φ and coprime to n, so stepping by it visits
39
+ // a permutation of the triangle list — the first `budget` steps are distinct and, by
40
+ // the three-distance theorem, near-uniformly spread over the WHOLE mesh (measured
41
+ // max gap on a 480-triangle mesh sampled 100 times: 8). A contiguous slice would
42
+ // read one region of the surface, and a plain n/budget stride can beat against a
43
+ // mesh's own periodicity (a lathed part's segment count) and sample one side of it.
44
+ // No RNG anywhere, so the same mesh always reads the same wall.
45
+ function sampleStride(n) {
46
+ let s = Math.max(1, Math.round(n * 0.6180339887498949)) % n || 1;
47
+ while (gcd(s, n) !== 1) s = s + 1 < n ? s + 1 : 1;
48
+ return s;
49
+ }
50
+
51
+ // `bvh` is an already-built index for THIS mesh — one mesh, one index, so there is
52
+ // nothing to key here: measure() resolves it out of the Map it shares with meshGaps
53
+ // (see cachedBVH for why that Map is the caller's) and passes the value. Omit it and
54
+ // one is built. It does not interact with sampling — sampling picks WHICH rays to
55
+ // cast, not how the index is built, so a shared BVH is equally valid sampled or exact.
56
+ export function minWall(mesh, { maxThickness, maxSamples = MAX_SAMPLES, bvh = buildBVH(mesh) } = {}) {
57
+ const n = bvh.triangleCount;
58
+ if (n === 0) return null;
59
+ const V = bvh.vertices;
60
+
61
+ // bbox diagonal as the default cap (a ray exiting into open air gets no hit
62
+ // anyway). The BVH's root node bounds ARE that box, already computed — rescanning
63
+ // mesh.positions would be an O(n) pass on the hot path for a number we have. (On
64
+ // an indexed mesh they are also marginally tighter, since unreferenced vertices
65
+ // are not in the tree; that only shrinks a ray cap, never a reading.)
15
66
  if (maxThickness == null) {
16
- const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
17
- for (let i = 0; i < pos.length; i += 3) for (let a = 0; a < 3; a++) { if (pos[i + a] < min[a]) min[a] = pos[i + a]; if (pos[i + a] > max[a]) max[a] = pos[i + a]; }
18
- maxThickness = Math.hypot(max[0] - min[0], max[1] - min[1], max[2] - min[2]) + 1;
67
+ const rb = bvh.rootBounds;
68
+ maxThickness = Math.hypot(rb[3] - rb[0], rb[4] - rb[1], rb[5] - rb[2]) + 1;
19
69
  }
20
70
 
21
- const bvh = buildBVH(mesh);
22
- let best = Infinity, loc = null;
23
- for (let t = 0; t < tris.length; t++) {
24
- const [v0, v1, v2] = tris[t];
25
- const e1 = [v1[0] - v0[0], v1[1] - v0[1], v1[2] - v0[2]];
26
- const e2 = [v2[0] - v0[0], v2[1] - v0[1], v2[2] - v0[2]];
27
- let nx = e1[1] * e2[2] - e1[2] * e2[1], ny = e1[2] * e2[0] - e1[0] * e2[2], nz = e1[0] * e2[1] - e1[1] * e2[0];
71
+ // `maxSamples: 0` (or any non-positive) is the explicit "no cap, cast everything"
72
+ // escape hatch — the exact reading, however long it takes.
73
+ const budget = maxSamples > 0 && n > maxSamples ? Math.floor(maxSamples) : n;
74
+ const sampled = budget < n;
75
+ const stride = sampled ? sampleStride(n) : 1; // stride 1 = every triangle, in mesh order
76
+
77
+ let best = Infinity, loc = null, t = 0;
78
+ const tri = new Float64Array(9); // reused per triangle; no per-ray garbage
79
+ for (let s = 0; s < budget; s++, t = t + stride < n ? t + stride : t + stride - n) {
80
+ readTriangleInto(V, t, tri);
81
+ const v0x = tri[0], v0y = tri[1], v0z = tri[2];
82
+ const e1x = tri[3] - v0x, e1y = tri[4] - v0y, e1z = tri[5] - v0z;
83
+ const e2x = tri[6] - v0x, e2y = tri[7] - v0y, e2z = tri[8] - v0z;
84
+ let nx = e1y * e2z - e1z * e2y, ny = e1z * e2x - e1x * e2z, nz = e1x * e2y - e1y * e2x;
28
85
  const len = Math.hypot(nx, ny, nz);
29
- if (len < 1e-9) continue; // degenerate triangle
86
+ if (len < 1e-9) continue; // degenerate triangle: no normal, no ray
30
87
  nx /= len; ny /= len; nz /= len; // outward normal (manifold winding)
31
- const c = [(v0[0] + v1[0] + v2[0]) / 3, (v0[1] + v1[1] + v2[1]) / 3, (v0[2] + v1[2] + v2[2]) / 3];
88
+ const c = [(v0x + tri[3] + tri[6]) / 3, (v0y + tri[4] + tri[7]) / 3, (v0z + tri[5] + tri[8]) / 3];
32
89
  const dir = [-nx, -ny, -nz]; // inward
33
90
  const origin = [c[0] + dir[0] * 1e-4, c[1] + dir[1] * 1e-4, c[2] + dir[2] * 1e-4];
34
91
  const hit = bvh.raycast(origin, dir, { tMax: maxThickness, skipTri: t });
35
92
  if (hit && hit.t < best) { best = hit.t; loc = c; }
36
93
  }
37
- return best === Infinity ? null : { value: best, location: loc };
94
+ // No hit anywhere still reports HOW it looked (see the header): a `value: null`
95
+ // with the sampling accounting intact, never a bare null that reads downstream as
96
+ // "min wall was never measured".
97
+ return { value: best === Infinity ? null : best, location: loc, sampled, sampledTriangles: budget, totalTriangles: n };
38
98
  }
@@ -118,14 +118,23 @@ function check(scope, subpart, metric, spec, registry, factsObj) {
118
118
  if (actual === null || actual === undefined) {
119
119
  if (reg.manifoldOnly) return { ...base, actual, status: "skip", pass: null, message: "n/a (OCCT backend)" };
120
120
  if (metric === "minWall") {
121
- return { ...base, actual, status: "warn", pass: null, message: "min wall unavailable",
121
+ const out = { ...base, actual, status: "warn", pass: null, message: "min wall unavailable",
122
122
  hint: partHint ?? "no min-wall reading for this mesh — treat thin features as unverified" };
123
+ // A missing reading still has a HOW: a sampled run whose rays all missed says
124
+ // so here, rather than reading like a mesh min-wall never looked at.
125
+ const note = reg.note?.(factsObj);
126
+ if (note) out.note = note;
127
+ return out;
123
128
  }
124
129
  return { ...base, actual, status: "skip", pass: null, message: "unavailable" };
125
130
  }
126
131
  const { pass, message } = evaluateAssertion(parseAssertion(expr), actual);
127
132
  const status = pass ? "pass" : reg.kind === "warn" ? "warn" : "fail";
128
133
  const out = { ...base, actual, status, pass, message };
134
+ // A measurement caveat rides along whatever the verdict — a min-wall reading
135
+ // taken from a sample still passed, but the reader should know it was a sample.
136
+ const note = reg.note?.(factsObj);
137
+ if (note) out.note = note;
129
138
  if (!pass) {
130
139
  out.hint = partHint ?? reg.hint;
131
140
  if (reg.pattern) out.pattern = reg.pattern;
@@ -158,7 +167,10 @@ export function evaluateCase(facts, { profile, expect, subPartNames }) {
158
167
  return checks;
159
168
  }
160
169
 
161
- export function verify(kernel, part, { process, view, measureFn = defaultMeasure } = {}) {
170
+ // `seed` lets a caller that has ALREADY measured this part hand the result in so
171
+ // verify does not recompute it — see the seeding block below for the shape and
172
+ // the one correctness rule that governs it.
173
+ export function verify(kernel, part, { process, view, measureFn = defaultMeasure, seed } = {}) {
162
174
  view = view ?? Object.keys(part.views)[0];
163
175
  const profileSpec = process ?? part.verify?.process;
164
176
  const profile = profileSpec ? resolveProfile(profileSpec) : null;
@@ -185,6 +197,50 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
185
197
  : [...readKeys.entries()].map(([name, keys]) => `${name}:${relevanceHash([...keys], params)}`).join("|");
186
198
 
187
199
  const memo = new Map();
200
+
201
+ // SEEDING. expandCases always yields a "defaults" case, and the inspect job
202
+ // (framework/jobs.js) measures those exact params immediately before calling
203
+ // verify — so without this the oracle rebuilds the same geometry, casts the
204
+ // same min-wall rays and re-indexes the same meshes a second time. On a
205
+ // single-case part that is half the job.
206
+ // seed = { params, result } — the params the result was measured with, and
207
+ // the measure() output itself. Nothing else: every fact the rule below needs
208
+ // is read off the artifact, so a caller cannot assert it wrongly.
209
+ //
210
+ // THE MIN-WALL SUPERSET RULE, which is the trap here. measureCase asks for
211
+ // `{ minWall: needMinWall }`, and needMinWall is false whenever no profile and
212
+ // no expectation mentions min wall. A result measured WITH min wall is a strict
213
+ // superset of one measured without: the extra fields are only ever read by the
214
+ // minWall metric, which by definition this run never checks. Reuse in that
215
+ // direction is free. The reverse is NOT safe — a seed taken without min wall
216
+ // carries `minWall: null` on every sub-part, which the registry reports as
217
+ // "min wall unavailable", silently downgrading a real gate to a warning. So the
218
+ // seed is consulted only when `seed.result.measuredMinWall || !needMinWall` —
219
+ // and `measuredMinWall` is stamped by measure() itself, not claimed by whoever
220
+ // holds the result. Otherwise the seed is ignored and the case measured properly.
221
+ //
222
+ // ALIASING. A consulted seed is memoized BY REFERENCE, so the caller's result
223
+ // and every case that hits it are the same object — the inspect job's
224
+ // `report.measure` and `report.verify.cases[0]`'s facts included. Nothing here
225
+ // mutates facts (evaluateCase only reads), and that is what makes the sharing
226
+ // safe; a future check that wants to annotate a fact must copy first.
227
+ //
228
+ // Keyed through the SAME signature() the memo uses, never a JSON compare of the
229
+ // raw params — a separate compare would miss cases that share a signature (a
230
+ // preset touching only params the build never reads) and, worse, could hit on
231
+ // params that merely look equal. The seed's params are layered over
232
+ // part.defaults first because a caller's `{}` and the defaults case's
233
+ // `{...part.defaults}` build identical geometry but hash differently
234
+ // (JSON.stringify({}) is not JSON.stringify(defaults), and relevanceHash reads
235
+ // params[k] straight through). The view is checked too: measure()'s output is
236
+ // per-view, and a seed from another view would be a silent wrong answer.
237
+ //
238
+ // Non-default measure options (a custom `gapThreshold`) are the caller's
239
+ // responsibility: seed only a measurement taken the way verify would take it.
240
+ if (seed?.result && (seed.result.measuredMinWall || !needMinWall) && seed.result.view === view) {
241
+ memo.set(signature({ ...part.defaults, ...(seed.params ?? {}) }), seed.result);
242
+ }
243
+
188
244
  const measureCase = (params) => {
189
245
  const key = signature(params);
190
246
  if (!memo.has(key)) memo.set(key, measureFn(kernel, part, view, params, { minWall: needMinWall }));