partforge 0.40.0 → 0.44.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.
Files changed (72) hide show
  1. package/README.md +46 -6
  2. package/bin/cli.js +103 -27
  3. package/docs/AUTHORING-PARTS.md +143 -15
  4. package/docs/ERROR-PATTERNS.md +6 -0
  5. package/package.json +48 -7
  6. package/skills/partforge/SKILL.md +17 -3
  7. package/src/app-embed-test.js +1 -1
  8. package/src/app-hinged-box.js +12 -0
  9. package/src/framework/animation-controls.js +243 -0
  10. package/src/framework/animation.js +217 -0
  11. package/src/framework/app.css +32 -0
  12. package/src/framework/assembly.js +1 -1
  13. package/src/framework/backend-select.js +25 -0
  14. package/src/framework/camera-tween.js +58 -0
  15. package/src/framework/chrome.css +16 -0
  16. package/src/framework/controls.js +13 -3
  17. package/src/framework/cutaway-gizmo-scene.js +244 -0
  18. package/src/framework/cutaway-gizmo.js +80 -243
  19. package/src/framework/default-view.js +46 -0
  20. package/src/framework/download.js +7 -2
  21. package/src/framework/export-controller.js +13 -2
  22. package/src/framework/geometry/probe.js +3 -22
  23. package/src/framework/jobs.js +23 -42
  24. package/src/framework/lint/finding.js +4 -0
  25. package/src/framework/lint/index.js +7 -3
  26. package/src/framework/lint/rules-animations.js +404 -0
  27. package/src/framework/lint/rules-place.js +76 -0
  28. package/src/framework/lint/rules-shape.js +12 -0
  29. package/src/framework/lint/rules-verify.js +2 -2
  30. package/src/framework/mount.js +113 -18
  31. package/src/{testing → framework/oracle}/build.js +1 -1
  32. package/src/framework/oracle/bvh.js +463 -0
  33. package/src/{testing → framework/oracle}/gaps.js +6 -3
  34. package/src/{testing → framework/oracle}/measure.js +34 -4
  35. package/src/framework/oracle/min-wall.js +98 -0
  36. package/src/{testing → framework/oracle}/verify.js +61 -5
  37. package/src/framework/param-deps.js +1 -1
  38. package/src/framework/part-model.js +48 -0
  39. package/src/framework/pick-request/client.js +11 -3
  40. package/src/framework/pick-request/endpoint.js +60 -0
  41. package/src/framework/pick-request/index.js +6 -0
  42. package/src/framework/pick-request/server.js +222 -34
  43. package/src/framework/pick-request/token-store.js +31 -0
  44. package/src/framework/pose-fast-path.js +12 -1
  45. package/src/framework/pose-probe-core.js +129 -0
  46. package/src/framework/pose-probe.js +7 -123
  47. package/src/framework/regen-loop.js +10 -3
  48. package/src/framework/safe-name.js +26 -0
  49. package/src/framework/verify-metrics.js +19 -6
  50. package/src/framework/view-state.js +25 -21
  51. package/src/framework/view-tabs.js +22 -7
  52. package/src/framework/viewer-controls.js +5 -26
  53. package/src/framework/viewer.js +126 -16
  54. package/src/hinged-box-worker.js +3 -0
  55. package/src/index.js +1 -1
  56. package/src/parts/hinged-box.js +94 -0
  57. package/src/testing/render.js +19 -8
  58. package/src/testing.js +15 -8
  59. package/types/derive.d.ts +14 -0
  60. package/types/geometry.d.ts +117 -0
  61. package/types/index.d.ts +240 -0
  62. package/types/kernel.d.ts +409 -0
  63. package/types/lint.d.ts +85 -0
  64. package/types/part.d.ts +381 -0
  65. package/types/testing.d.ts +362 -0
  66. package/types/worker.d.ts +21 -0
  67. package/src/testing/bvh.js +0 -273
  68. package/src/testing/min-wall.js +0 -38
  69. /package/src/{testing → framework/oracle}/assert-dsl.js +0 -0
  70. /package/src/{testing → framework/oracle}/cases.js +0 -0
  71. /package/src/{testing → framework/oracle}/dfm-profiles.js +0 -0
  72. /package/src/{testing → framework/oracle}/mesh.js +0 -0
@@ -0,0 +1,463 @@
1
+ // src/framework/oracle/bvh.js
2
+ // Triangle BVH over a mesh in either Manifold non-indexed soup form (9 floats per
3
+ // triangle, no `indices`) or OCCT indexed form (`positions` = 3 floats/vertex +
4
+ // `indices` = 3 vertex-indices/triangle). A reusable spatial index: nearest ray hit
5
+ // (raycast), nearest surface point (closestPoint), and exact mesh-to-mesh distance
6
+ // (distanceTo). AABB tree, median split on the widest centroid axis, slab ray–box
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.
39
+
40
+ const LEAF = 4; // max triangles per leaf
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
+
53
+ // Triangles as [v0,v1,v2] coord triples, from either a Manifold non-indexed soup
54
+ // (positions = 9 floats/triangle, no indices) or an OCCT indexed mesh (positions =
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.
58
+ export function meshTriangles(mesh) {
59
+ const { positions, indices } = mesh;
60
+ if (indices) {
61
+ const n = indices.length / 3, out = new Array(n);
62
+ for (let t = 0; t < n; t++) {
63
+ const a = indices[3 * t] * 3, b = indices[3 * t + 1] * 3, c = indices[3 * t + 2] * 3;
64
+ out[t] = [[positions[a], positions[a + 1], positions[a + 2]],
65
+ [positions[b], positions[b + 1], positions[b + 2]],
66
+ [positions[c], positions[c + 1], positions[c + 2]]];
67
+ }
68
+ return out;
69
+ }
70
+ const n = positions.length / 9, out = new Array(n);
71
+ for (let t = 0; t < n; t++) {
72
+ const o = t * 9;
73
+ out[t] = [[positions[o], positions[o + 1], positions[o + 2]],
74
+ [positions[o + 3], positions[o + 4], positions[o + 5]],
75
+ [positions[o + 6], positions[o + 7], positions[o + 8]]];
76
+ }
77
+ return out;
78
+ }
79
+
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;
104
+ }
105
+
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;
118
+ }
119
+
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) {
122
+ let t0 = tMin, t1 = best;
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;
135
+ }
136
+
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]];
142
+ const sub = (p, q) => [p[0]-q[0], p[1]-q[1], p[2]-q[2]];
143
+ const dot = (p, q) => p[0]*q[0] + p[1]*q[1] + p[2]*q[2];
144
+ const add = (p, q) => [p[0]+q[0], p[1]+q[1], p[2]+q[2]];
145
+ const mul = (p, s) => [p[0]*s, p[1]*s, p[2]*s];
146
+ const ab = sub(B,A), ac = sub(C,A), ap = sub(P,A);
147
+ const d1 = dot(ab,ap), d2 = dot(ac,ap);
148
+ let Q;
149
+ if (d1<=0&&d2<=0) Q = A;
150
+ else { const bp = sub(P,B), d3 = dot(ab,bp), d4 = dot(ac,bp);
151
+ if (d3>=0&&d4<=d3) Q = B;
152
+ else { const vc = d1*d4 - d3*d2;
153
+ if (vc<=0&&d1>=0&&d3<=0) Q = add(A, mul(ab, d1/(d1-d3)));
154
+ else { const cp = sub(P,C), d5 = dot(ab,cp), d6 = dot(ac,cp);
155
+ if (d6>=0&&d5<=d6) Q = C;
156
+ else { const vb = d5*d2 - d1*d6;
157
+ if (vb<=0&&d2>=0&&d6<=0) Q = add(A, mul(ac, d2/(d2-d6)));
158
+ else { const va = d3*d6 - d5*d4;
159
+ if (va<=0&&(d4-d3)>=0&&(d5-d6)>=0) Q = add(B, mul(sub(C,B), (d4-d3)/((d4-d3)+(d5-d6))));
160
+ else { const denom = 1/(va+vb+vc); Q = add(add(A, mul(ab, vb*denom)), mul(ac, vc*denom)); } } } } } }
161
+ const pq = sub(P, Q);
162
+ return { point: Q, d2: dot(pq, pq) };
163
+ }
164
+
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;
171
+ }
172
+
173
+ // summed extent of a node's AABB — the "which node is larger" heuristic for dual traversal
174
+ const nodeExtent = (B, nb) => (B[nb + 3] - B[nb]) + (B[nb + 4] - B[nb + 1]) + (B[nb + 5] - B[nb + 2]);
175
+
176
+ // squared distance between two nodes' AABBs (0 when they overlap)
177
+ function boxBoxDistSq(A, na, B, nb) {
178
+ let s = 0;
179
+ for (let ax = 0; ax < 3; ax++) {
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;
182
+ s += v * v;
183
+ }
184
+ return s;
185
+ }
186
+
187
+ // closest points between segments P1→Q1 and P2→Q2 (Ericson 5.1.9), → { a, b, d2 }
188
+ function closestSegSeg(P1, Q1, P2, Q2) {
189
+ const sub = (p, q) => [p[0] - q[0], p[1] - q[1], p[2] - q[2]];
190
+ const dot = (p, q) => p[0] * q[0] + p[1] * q[1] + p[2] * q[2];
191
+ const clamp01 = (x) => (x < 0 ? 0 : x > 1 ? 1 : x);
192
+ const d1 = sub(Q1, P1), d2v = sub(Q2, P2), r = sub(P1, P2);
193
+ const a = dot(d1, d1), e = dot(d2v, d2v), f = dot(d2v, r);
194
+ const EPS = 1e-12;
195
+ let s, t;
196
+ if (a <= EPS && e <= EPS) { s = 0; t = 0; }
197
+ else if (a <= EPS) { s = 0; t = clamp01(f / e); }
198
+ else {
199
+ const c = dot(d1, r);
200
+ if (e <= EPS) { t = 0; s = clamp01(-c / a); }
201
+ else {
202
+ const b = dot(d1, d2v), denom = a * e - b * b;
203
+ s = denom !== 0 ? clamp01((b * f - c * e) / denom) : 0;
204
+ t = (b * s + f) / e;
205
+ if (t < 0) { t = 0; s = clamp01(-c / a); }
206
+ else if (t > 1) { t = 1; s = clamp01((b - c) / a); }
207
+ }
208
+ }
209
+ const A = [P1[0] + d1[0] * s, P1[1] + d1[1] * s, P1[2] + d1[2] * s];
210
+ const B = [P2[0] + d2v[0] * t, P2[1] + d2v[1] * t, P2[2] + d2v[2] * t];
211
+ const pq = sub(A, B);
212
+ return { a: A, b: B, d2: dot(pq, pq) };
213
+ }
214
+
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]]];
226
+ for (const [p, q] of edges(t1)) {
227
+ const d = [q[0] - p[0], q[1] - p[1], q[2] - p[2]];
228
+ const t = rayTri(p[0], p[1], p[2], d[0], d[1], d[2], V2, b2, 0);
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 }; }
230
+ }
231
+ for (const [p, q] of edges(t2)) {
232
+ const d = [q[0] - p[0], q[1] - p[1], q[2] - p[2]];
233
+ const t = rayTri(p[0], p[1], p[2], d[0], d[1], d[2], V1, b1, 0);
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 }; }
235
+ }
236
+ let best = { d2: Infinity, a: null, b: null };
237
+ for (const v of t2) {
238
+ const r = closestOnTri(v, V1, b1);
239
+ if (r.d2 < best.d2) best = { d2: r.d2, a: r.point, b: v };
240
+ }
241
+ for (const v of t1) {
242
+ const r = closestOnTri(v, V2, b2);
243
+ if (r.d2 < best.d2) best = { d2: r.d2, a: v, b: r.point };
244
+ }
245
+ for (const [p1, q1] of edges(t1)) for (const [p2, q2] of edges(t2)) {
246
+ const r = closestSegSeg(p1, q1, p2, q2);
247
+ if (r.d2 < best.d2) best = { d2: r.d2, a: r.a, b: r.b };
248
+ }
249
+ return best;
250
+ }
251
+
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;
261
+ if (det > -1e-12 && det < 1e-12) return Infinity;
262
+ const inv = 1 / det;
263
+ const tx = ox - ax, ty = oy - ay, tz = oz - az;
264
+ const u = (tx * px + ty * py + tz * pz) * inv;
265
+ if (u < 0 || u > 1) return Infinity;
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;
268
+ if (v < 0 || u + v > 1) return Infinity;
269
+ const t = (e2x * qx + e2y * qy + e2z * qz) * inv;
270
+ return t > tMin ? t : Infinity;
271
+ }
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
+
295
+ export function buildBVH(mesh) {
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;
356
+
357
+ function raycast(origin, dir, { tMin = 1e-6, tMax = Infinity, skipTri = -1 } = {}) {
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;
361
+ let best = tMax, bestTri = -1;
362
+ const stack = [0];
363
+ while (stack.length) {
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; }
374
+ }
375
+ } else { stack.push(n + 1, meta[n * 2]); }
376
+ }
377
+ return bestTri === -1 ? null : { t: best, tri: bestTri };
378
+ }
379
+
380
+ function closestPoint(p) {
381
+ const px = p[0], py = p[1], pz = p[2];
382
+ let best2 = Infinity, bestPt = null, bestTri = -1;
383
+ const stack = [0];
384
+ while (stack.length) {
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
+ }
395
+ } else {
396
+ // visit the nearer child first for better pruning
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); }
400
+ }
401
+ }
402
+ return { point: bestPt, dist: Math.sqrt(best2), tri: bestTri };
403
+ }
404
+
405
+ // Exact minimum surface-to-surface distance to another buildBVH result.
406
+ // Dual traversal pruned by AABB–AABB distance; exact triangle–triangle
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.
409
+ function distanceTo(other) {
410
+ const oV = other.vertices, oB = other._bounds, oM = other._meta, oO = other._order;
411
+ let best = { d2: Infinity, a: null, b: null };
412
+ const stack = [0, 0];
413
+ while (stack.length && best.d2 > 0) {
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);
421
+ if (r.d2 < best.d2) best = r;
422
+ }
423
+ } else if (!pa && (pb || nodeExtent(bounds, na * 6) >= nodeExtent(oB, nb * 6))) {
424
+ // descend the larger node; push the nearer child last so it pops first
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);
428
+ } else {
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);
432
+ }
433
+ }
434
+ if (best.a === null) return { distance: Infinity, at: null, pointA: null, pointB: null }; // empty mesh
435
+ const at = [(best.a[0] + best.b[0]) / 2, (best.a[1] + best.b[1]) / 2, (best.a[2] + best.b[2]) / 2];
436
+ return { distance: Math.sqrt(best.d2), at, pointA: best.a, pointB: best.b };
437
+ }
438
+
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
+ };
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,5 +1,6 @@
1
1
  import { buildView } from "./build.js";
2
- import { assemblyOverlaps } from "../framework/assembly.js";
2
+ import { cachedBVH } from "./bvh.js";
3
+ import { assemblyOverlaps } from "../assembly.js";
3
4
  import { meshGaps, pairKey, CONTACT_EPS, GAP_THRESHOLD } from "./gaps.js";
4
5
  import { bounds, meshArea, meshCentroid } from "./mesh.js";
5
6
  import { minWall } from "./min-wall.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,
@@ -0,0 +1,98 @@
1
+ // src/framework/oracle/min-wall.js
2
+ // Min wall thickness by ray/shot on a triangle BVH (see the spec's spike: this beat the
3
+ // voxel/SDF approach on both accuracy and speed). For each surface triangle, cast a ray
4
+ // inward (reverse of its outward normal) from the centroid; the nearest hit is the local
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 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";
27
+
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;
35
+
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.)
66
+ if (maxThickness == null) {
67
+ const rb = bvh.rootBounds;
68
+ maxThickness = Math.hypot(rb[3] - rb[0], rb[4] - rb[1], rb[5] - rb[2]) + 1;
69
+ }
70
+
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;
85
+ const len = Math.hypot(nx, ny, nz);
86
+ if (len < 1e-9) continue; // degenerate triangle: no normal, no ray
87
+ nx /= len; ny /= len; nz /= len; // outward normal (manifold winding)
88
+ const c = [(v0x + tri[3] + tri[6]) / 3, (v0y + tri[4] + tri[7]) / 3, (v0z + tri[5] + tri[8]) / 3];
89
+ const dir = [-nx, -ny, -nz]; // inward
90
+ const origin = [c[0] + dir[0] * 1e-4, c[1] + dir[1] * 1e-4, c[2] + dir[2] * 1e-4];
91
+ const hit = bvh.raycast(origin, dir, { tMax: maxThickness, skipTri: t });
92
+ if (hit && hit.t < best) { best = hit.t; loc = c; }
93
+ }
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 };
98
+ }