partforge 0.39.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 +19 -0
- package/bin/cli.js +3 -0
- package/docs/AUTHORING-PARTS.md +17 -1
- package/package.json +1 -1
- package/src/framework/cutaway-gizmo.js +16 -2
- package/src/framework/cutaway-math.js +50 -1
- package/src/framework/cutaway-outline.js +233 -0
- package/src/framework/cutaway-render.js +20 -0
- package/src/framework/cutaway.js +47 -1
- package/src/framework/jobs.js +14 -2
- package/src/framework/mount.js +21 -1
- package/src/framework/verify-metrics.js +15 -2
- package/src/framework/viewer.js +84 -5
- package/src/testing/bvh.js +296 -106
- package/src/testing/gaps.js +6 -3
- package/src/testing/measure.js +33 -3
- package/src/testing/min-wall.js +80 -20
- package/src/testing/verify.js +58 -2
package/src/testing/bvh.js
CHANGED
|
@@ -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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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:
|
|
64
|
-
function
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
72
|
-
|
|
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,
|
|
77
|
-
const A =
|
|
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
|
|
102
|
-
function distSqBox(
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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 = (
|
|
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(
|
|
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 =
|
|
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
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
// feature distances.
|
|
156
|
-
function triTriDist(
|
|
157
|
-
const
|
|
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,
|
|
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,
|
|
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
|
|
170
|
-
const r = closestOnTri(v,
|
|
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
|
|
174
|
-
const r = closestOnTri(v,
|
|
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
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const
|
|
189
|
-
const
|
|
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
|
|
193
|
-
const u = (
|
|
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
|
|
196
|
-
const v = (
|
|
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 = (
|
|
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
|
|
204
|
-
const
|
|
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
|
|
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 = [
|
|
362
|
+
const stack = [0];
|
|
210
363
|
while (stack.length) {
|
|
211
|
-
const
|
|
212
|
-
if (
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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(
|
|
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 = [
|
|
383
|
+
const stack = [0];
|
|
227
384
|
while (stack.length) {
|
|
228
|
-
const
|
|
229
|
-
if (distSqBox(
|
|
230
|
-
|
|
231
|
-
|
|
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
|
|
235
|
-
|
|
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 = [
|
|
412
|
+
const stack = [0, 0];
|
|
247
413
|
while (stack.length && best.d2 > 0) {
|
|
248
|
-
const
|
|
249
|
-
if (boxBoxDistSq(na, nb) >= best.d2) continue;
|
|
250
|
-
const
|
|
251
|
-
if (
|
|
252
|
-
|
|
253
|
-
|
|
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 (!
|
|
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
|
|
259
|
-
|
|
260
|
-
|
|
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
|
|
263
|
-
|
|
264
|
-
|
|
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 {
|
|
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
|
}
|
package/src/testing/gaps.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { buildView } from "./build.js";
|
|
2
|
-
import {
|
|
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:
|
|
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++) {
|
package/src/testing/measure.js
CHANGED
|
@@ -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[],
|
|
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
|
-
|
|
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,
|