partforge 0.145.2 → 0.146.1

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.
@@ -1,87 +1,175 @@
1
1
  // Minimal 3MF reader, the read twin of threemf.js's writer. 3MF is an OPC
2
- // package (a zip) holding an XML model; unzip (fflate), find the model part,
3
- // extract vertices/triangles/per-item transforms and the model unit, and
4
- // merge every build item into one soup-free indexed mesh in millimetres.
2
+ // package (a zip) holding one or more XML model parts; unzip (fflate), find the
3
+ // ROOT model part, extract vertices/triangles, resolve <components> and per-item
4
+ // transforms, and merge every build item into one soup-free indexed mesh in
5
+ // millimetres.
6
+ //
7
+ // Slicer project files (Bambu Studio, Orca, PrusaSlicer) keep no mesh in the
8
+ // root part at all: its objects are <components> pointing, through the
9
+ // Production extension's `p:path`, at meshes in other parts
10
+ // (3D/Objects/object_1.model). Reading only one part — or only top-level
11
+ // <object><mesh> — finds nothing in exactly the files people export most.
5
12
  //
6
13
  // Regex-based extraction, NOT a DOM parse — workers have no DOMParser and the
7
14
  // worker graph must stay DOM-free (test/worker-layering.test.js enforces
8
15
  // this transitively). Scope: geometry only — materials, colors and beam
9
- // lattices are ignored, and only top-level <object><mesh> content is read
10
- // (no <components> nesting).
16
+ // lattices are ignored.
11
17
  import { unzipSync } from "fflate";
12
18
 
13
19
  const UNIT_MM = { micron: 0.001, millimeter: 1, centimeter: 10, inch: 25.4, foot: 304.8, meter: 1000 };
20
+ const MAX_COMPONENT_DEPTH = 32;
21
+ const IDENTITY = [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0];
14
22
 
15
- export function parse3MF(bytes) {
16
- const u8 = bytes instanceof ArrayBuffer ? new Uint8Array(bytes) : bytes;
17
- let files;
18
- try {
19
- files = unzipSync(u8);
20
- } catch (e) {
21
- throw new Error(`3mf import: not a readable zip archive (${e?.message || e})`);
23
+ // Part names are compared without a leading slash and case-insensitively: OPC
24
+ // names are case-insensitive, and references carry a leading "/" that zip
25
+ // entry names do not.
26
+ const partKey = (name) => name.replace(/^\/+/, "").toLowerCase();
27
+
28
+ // Transforms are 12 numbers, row-major 4x3 with the translation in the last
29
+ // row, per the 3MF core spec (applied to a row vector: v' = v*M, i.e.
30
+ // x' = x*m00 + y*m10 + z*m20 + m30). The translation is in the units of the
31
+ // part that holds the transform, so it is scaled to mm on read; the linear
32
+ // part is unitless.
33
+ function readTransform(tag, scale) {
34
+ const raw = tag.match(/\btransform="([^"]+)"/)?.[1];
35
+ if (!raw) return IDENTITY;
36
+ const t = raw.trim().split(/\s+/).map(Number);
37
+ if (t.length !== 12 || t.some((n) => !Number.isFinite(n))) return IDENTITY;
38
+ return [...t.slice(0, 9), t[9] * scale, t[10] * scale, t[11] * scale];
39
+ }
40
+
41
+ // Child first, then parent: v * C * P.
42
+ function compose(c, p) {
43
+ const r = new Array(12);
44
+ for (let row = 0; row < 4; row++) {
45
+ for (let col = 0; col < 3; col++) {
46
+ r[row * 3 + col] =
47
+ c[row * 3] * p[col] + c[row * 3 + 1] * p[3 + col] + c[row * 3 + 2] * p[6 + col] + (row === 3 ? p[9 + col] : 0);
48
+ }
22
49
  }
23
- const modelPath = Object.keys(files).find((f) => f.toLowerCase().endsWith(".model"));
24
- if (!modelPath) throw new Error("3mf import: archive has no 3D model part (*.model)");
25
- const xml = new TextDecoder().decode(files[modelPath]);
50
+ return r;
51
+ }
52
+
53
+ // The OPC/Production-extension `path` attribute, whatever its namespace prefix.
54
+ const readPath = (tag) => tag.match(/\s(?:[\w.-]+:)?path="([^"]+)"/)?.[1] ?? null;
55
+
56
+ function rootModelPath(files, keys) {
57
+ const rels = files.get("_rels/.rels");
58
+ if (rels) {
59
+ const xml = new TextDecoder().decode(rels);
60
+ for (const m of xml.matchAll(/<Relationship\b[^>]*>/g)) {
61
+ const type = m[0].match(/\bType="([^"]+)"/)?.[1] ?? "";
62
+ const target = m[0].match(/\bTarget="([^"]+)"/)?.[1];
63
+ if (target && /\/3dmodel$/i.test(type) && files.has(partKey(target))) return partKey(target);
64
+ }
65
+ }
66
+ if (files.has("3d/3dmodel.model")) return "3d/3dmodel.model";
67
+ return keys.find((k) => k.endsWith(".model")) ?? null;
68
+ }
26
69
 
70
+ function parseModelPart(xml, key) {
27
71
  const unit = xml.match(/<model\b[^>]*\bunit="([^"]+)"/)?.[1] ?? "millimeter";
28
72
  const scale = UNIT_MM[unit];
29
73
  if (!scale) throw new Error(`3mf import: unknown unit "${unit}"`);
30
74
 
31
- // objects: id -> { P: number[] (already scaled to mm), I: number[] }
75
+ // objects: id -> { type, mesh: {P (mm), I} | null, components: [{key, id, t}] }
32
76
  const objects = new Map();
33
- const objRe = /<object\b[^>]*\bid="(\d+)"[^>]*>([\s\S]*?)<\/object>/g;
77
+ const objRe = /<object\b([^>]*)>([\s\S]*?)<\/object>/g;
34
78
  for (let m; (m = objRe.exec(xml)); ) {
35
- const [, id, body] = m;
79
+ const [, attrs, body] = m;
80
+ const id = attrs.match(/\bid="(\d+)"/)?.[1];
81
+ if (!id) continue;
82
+ const type = attrs.match(/\btype="([^"]+)"/)?.[1] ?? "model";
36
83
  const P = [], I = [];
37
84
  const vRe = /<vertex\b[^>]*\bx="([^"]+)"[^>]*\by="([^"]+)"[^>]*\bz="([^"]+)"/g;
38
85
  for (let v; (v = vRe.exec(body)); ) P.push(+v[1] * scale, +v[2] * scale, +v[3] * scale);
39
86
  const tRe = /<triangle\b[^>]*\bv1="(\d+)"[^>]*\bv2="(\d+)"[^>]*\bv3="(\d+)"/g;
40
87
  for (let t; (t = tRe.exec(body)); ) I.push(+t[1], +t[2], +t[3]);
41
- if (I.length) objects.set(id, { P, I });
88
+ const components = [];
89
+ for (const c of body.matchAll(/<component\b[^>]*>/g)) {
90
+ const cid = c[0].match(/\bobjectid="(\d+)"/)?.[1];
91
+ if (!cid) continue;
92
+ const path = readPath(c[0]);
93
+ components.push({ key: path ? partKey(path) : key, id: cid, t: readTransform(c[0], scale) });
94
+ }
95
+ objects.set(id, { type, mesh: I.length ? { P, I } : null, components });
42
96
  }
43
- if (objects.size === 0) throw new Error("3mf import: model contains no mesh geometry");
44
97
 
45
- // Build items: <item objectid="N" transform="m00 m01 m02 m10 m11 m12 m20 m21
46
- // m22 m30 m31 m32"/> — row-major 4x3, translation in the last row, per the
47
- // 3MF core spec (the transform is applied to a row vector: v' = v*M, i.e.
48
- // x' = x*m00 + y*m10 + z*m20 + m30, and so on). No <build>, or an object
49
- // with no matching item, falls back to identity placement.
50
- //
51
- // Attributes are pulled independently from each <item> tag (rather than in
52
- // one fixed-order regex) because `objectid` and `transform` can appear in
53
- // either order and a single ordered pattern with an optional middle group
54
- // can match the tag while silently leaving `transform` uncaptured.
98
+ // Build items. Attributes are pulled independently from each <item> tag
99
+ // (rather than in one fixed-order regex) because `objectid` and `transform`
100
+ // can appear in either order and a single ordered pattern with an optional
101
+ // middle group can match the tag while silently leaving `transform`
102
+ // uncaptured. Items may be self-closing or carry children (metadata).
55
103
  const items = [];
56
- const itemTagRe = /<item\b[^>]*\/>/g;
57
- for (let m; (m = itemTagRe.exec(xml)); ) {
58
- const tag = m[0];
59
- const id = tag.match(/\bobjectid="(\d+)"/)?.[1];
104
+ const build = xml.match(/<build\b[^>]*>([\s\S]*?)<\/build>/)?.[1] ?? "";
105
+ for (const m of build.matchAll(/<item\b[^>]*>/g)) {
106
+ const id = m[0].match(/\bobjectid="(\d+)"/)?.[1];
60
107
  if (!id) continue;
61
- const t = tag.match(/\btransform="([^"]+)"/)?.[1];
62
- items.push({ id, t: t ? t.trim().split(/\s+/).map(Number) : null });
108
+ const path = readPath(m[0]);
109
+ items.push({ key: path ? partKey(path) : key, id, t: readTransform(m[0], scale) });
110
+ }
111
+ return { objects, items };
112
+ }
113
+
114
+ export function parse3MF(bytes) {
115
+ const u8 = bytes instanceof ArrayBuffer ? new Uint8Array(bytes) : bytes;
116
+ let raw;
117
+ try {
118
+ raw = unzipSync(u8);
119
+ } catch (e) {
120
+ throw new Error(`3mf import: not a readable zip archive (${e?.message || e})`, { cause: e });
63
121
  }
64
- const chosen = items.length ? items : [...objects.keys()].map((id) => ({ id, t: null }));
122
+ const files = new Map(Object.entries(raw).map(([name, data]) => [partKey(name), data]));
123
+ const root = rootModelPath(files, [...files.keys()]);
124
+ if (!root) throw new Error("3mf import: archive has no 3D model part (*.model)");
125
+
126
+ const parts = new Map();
127
+ const partFor = (key) => {
128
+ if (!parts.has(key)) {
129
+ const data = files.get(key);
130
+ if (!data) throw new Error(`3mf import: a component references a missing model part (/${key})`);
131
+ parts.set(key, parseModelPart(new TextDecoder().decode(data), key));
132
+ }
133
+ return parts.get(key);
134
+ };
65
135
 
66
136
  const V = [], Tr = [];
67
- for (const { id, t } of chosen) {
68
- const o = objects.get(id);
69
- if (!o) continue;
137
+ const emit = ({ P, I }, t) => {
70
138
  const base = V.length / 3;
71
- for (let i = 0; i < o.P.length; i += 3) {
72
- let x = o.P[i], y = o.P[i + 1], z = o.P[i + 2];
73
- if (t) {
74
- // Translation components (m30 m31 m32) are expressed in model units
75
- // per the 3MF spec, same as vertex coordinates — scale them to mm too
76
- // so they combine correctly with the already-scaled x/y/z above.
77
- const x2 = t[0] * x + t[3] * y + t[6] * z + t[9] * scale;
78
- const y2 = t[1] * x + t[4] * y + t[7] * z + t[10] * scale;
79
- const z2 = t[2] * x + t[5] * y + t[8] * z + t[11] * scale;
80
- x = x2; y = y2; z = z2;
81
- }
82
- V.push(x, y, z);
139
+ for (let i = 0; i < P.length; i += 3) {
140
+ const x = P[i], y = P[i + 1], z = P[i + 2];
141
+ V.push(
142
+ t[0] * x + t[3] * y + t[6] * z + t[9],
143
+ t[1] * x + t[4] * y + t[7] * z + t[10],
144
+ t[2] * x + t[5] * y + t[8] * z + t[11],
145
+ );
83
146
  }
84
- for (const idx of o.I) Tr.push(base + idx);
147
+ for (const idx of I) Tr.push(base + idx);
148
+ };
149
+ const place = (key, id, t, stack) => {
150
+ const ref = `${key}#${id}`;
151
+ if (stack.includes(ref) || stack.length > MAX_COMPONENT_DEPTH) {
152
+ throw new Error("3mf import: components reference each other in a loop");
153
+ }
154
+ const o = partFor(key).objects.get(id);
155
+ if (!o) return;
156
+ // Support and "other" objects are not part of the printed shape.
157
+ if (o.type === "support" || o.type === "other") return;
158
+ if (o.mesh) emit(o.mesh, t);
159
+ for (const c of o.components) place(c.key, c.id, compose(c.t, t), [...stack, ref]);
160
+ };
161
+
162
+ // No <build> items: every object in the root part that no component uses,
163
+ // at identity.
164
+ const rootPart = partFor(root);
165
+ let items = rootPart.items;
166
+ if (!items.length) {
167
+ const used = new Set();
168
+ for (const o of rootPart.objects.values()) for (const c of o.components) if (c.key === root) used.add(c.id);
169
+ items = [...rootPart.objects.keys()].filter((id) => !used.has(id)).map((id) => ({ key: root, id, t: IDENTITY }));
85
170
  }
171
+ for (const { key, id, t } of items) place(key, id, t, []);
172
+
173
+ if (Tr.length === 0) throw new Error("3mf import: model contains no mesh geometry");
86
174
  return { positions: Float32Array.from(V), indices: Uint32Array.from(Tr) };
87
175
  }
@@ -3,8 +3,8 @@
3
3
  // triangle, no `indices`) or OCCT indexed form (`positions` = 3 floats/vertex +
4
4
  // `indices` = 3 vertex-indices/triangle). A reusable spatial index: nearest ray hit
5
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.
6
+ // (distanceTo). AABB tree built by a binned surface-area heuristic (sahSplit),
7
+ // slab ray–box test with nearer-child-first traversal and pruning.
8
8
  //
9
9
  // STORAGE — four flat typed arrays, no per-triangle and no per-node JS objects.
10
10
  // The nested-array representation this replaced ([[x,y,z],[x,y,z],[x,y,z]] per
@@ -26,21 +26,50 @@
26
26
  // triangles). Read it through readTriangleInto() rather than
27
27
  // open-coding the stride.
28
28
  // order Uint32 triangle ids, permuted by the build so each leaf owns a
29
- // contiguous run. The vertices themselves never move.
29
+ // contiguous run (at most MAX_LEAF). The vertices themselves never
30
+ // move.
30
31
  // bounds Float64, 6 per node: [minx,miny,minz, maxx,maxy,maxz]. Kept at double
31
32
  // precision whatever the vertices are — a narrowed bound would have to
32
33
  // be rounded outward to stay conservative, and node bounds are a small
33
- // fraction of the total anyway (~0.6 nodes per triangle).
34
+ // fraction of the total anyway (well under one node per triangle).
34
35
  // meta Uint32, 2 per node. A LEAF is [firstIndexIntoOrder, count + 1]; an
35
36
  // INTERNAL node is [rightChildIndex, 0]. The +1 is what lets an empty
36
37
  // mesh's root still read as a leaf instead of as an internal node
37
38
  // pointing at itself. Nodes are laid out in pre-order, so an internal
38
39
  // node's left child is always the next node.
39
40
 
40
- import { core, countRefusedMesh, poisonCore } from "../core/core.js";
41
+ import { core, poisonCore } from "../core/core.js";
41
42
  import { buildCoreBVH } from "../core/bvh-core.js";
42
43
 
43
- const LEAF = 4; // max triangles per leaf
44
+ // The build (buildBVH): a binned surface-area heuristic. Each node is split
45
+ // where the two children's box areas, weighted by their triangle counts, sum
46
+ // least — the split that makes a query least likely to have to visit both — so
47
+ // sibling boxes overlap far less than a median split's do. Measured on the
48
+ // reference parts against the median build this replaced (native core): min-wall
49
+ // ~2.9x, closest point ~2x, mesh-to-mesh distance ~36x (lattice-box ~400x, whose
50
+ // median boxes overlapped so badly that its gap check was nearly brute force),
51
+ // and the build itself ~1.2x FASTER; in this JS, min-wall ~2.2x and the build
52
+ // ~1.1x faster.
53
+ //
54
+ // It is DETERMINISTIC by construction and has a C++ twin (native/bvh.cpp) that
55
+ // must build the same tree bit for bit, so: no sort anywhere (a NaN key would
56
+ // make a comparison sort's order the algorithm's own), a STABLE partition, the
57
+ // bin index and every cost computed by the same expressions in the same order,
58
+ // ties going to the first candidate (lowest axis, then lowest bin).
59
+ const BINS = 16; // candidate split planes per axis, per node
60
+ const MAX_LEAF = 8; // a node over more triangles is always split
61
+ const DEPTH_LIMIT = 48; // past this depth, split ranges in half (bounds the tree's depth)
62
+
63
+ // Which of the BINS bins a centroid coordinate falls in. NaN lands in bin 0.
64
+ function binOf(c, lo, scale) {
65
+ const b = Math.floor((c - lo) * scale);
66
+ return b >= BINS ? BINS - 1 : b >= 0 ? b : 0;
67
+ }
68
+ // Half the surface area of a box (the factor of 2 cancels in every comparison).
69
+ const halfArea = (x0, y0, z0, x1, y1, z1) => {
70
+ const x = x1 - x0, y = y1 - y0, z = z1 - z0;
71
+ return x * y + y * z + z * x;
72
+ };
44
73
 
45
74
  // Coords per triangle in a `vertices` store: v0,v1,v2 interleaved. The ONE place
46
75
  // this layout is decoded outside the queries below — copy triangle `t` of `V` into
@@ -106,35 +135,22 @@ export function triangleVertices(mesh) {
106
135
  return verts;
107
136
  }
108
137
 
109
- // Node count for a subtree of `len` triangles. The split is a pure function of the
110
- // length (median at len>>1, leaf at <= LEAF), so the tree's shape — and therefore
111
- // its exact size — is known before a single triangle is sorted. That is what lets
112
- // `bounds`/`meta` be allocated once at the right size rather than grown or trimmed.
113
- function countNodes(len, memo) {
114
- if (len <= LEAF) return 1;
115
- const hit = memo.get(len);
116
- if (hit !== undefined) return hit;
117
- const mid = len >> 1;
118
- const n = 1 + countNodes(mid, memo) + countNodes(len - mid, memo);
119
- memo.set(len, n);
120
- return n;
121
- }
122
-
123
- // slab test: does the ray meet node `nb`'s box within (tMin, best]?
124
- function rayHitsBox(ox, oy, oz, ix, iy, iz, B, nb, tMin, best) {
138
+ // slab test: where the ray enters node `nb`'s box within (tMin, best], or
139
+ // Infinity if it doesn't meet it there.
140
+ function rayEntry(ox, oy, oz, ix, iy, iz, B, nb, tMin, best) {
125
141
  let t0 = tMin, t1 = best;
126
142
  let lo = (B[nb] - ox) * ix, hi = (B[nb + 3] - ox) * ix;
127
143
  if (lo > hi) { const s = lo; lo = hi; hi = s; }
128
144
  if (lo > t0) t0 = lo; if (hi < t1) t1 = hi;
129
- if (t0 > t1) return false;
145
+ if (t0 > t1) return Infinity;
130
146
  lo = (B[nb + 1] - oy) * iy; hi = (B[nb + 4] - oy) * iy;
131
147
  if (lo > hi) { const s = lo; lo = hi; hi = s; }
132
148
  if (lo > t0) t0 = lo; if (hi < t1) t1 = hi;
133
- if (t0 > t1) return false;
149
+ if (t0 > t1) return Infinity;
134
150
  lo = (B[nb + 2] - oz) * iz; hi = (B[nb + 5] - oz) * iz;
135
151
  if (lo > hi) { const s = lo; lo = hi; hi = s; }
136
152
  if (lo > t0) t0 = lo; if (hi < t1) t1 = hi;
137
- return t0 <= t1;
153
+ return t0 <= t1 ? t0 : Infinity;
138
154
  }
139
155
 
140
156
  // nearest point on triangle `base` of `V` to P (Ericson), returns { point, d2 }
@@ -315,9 +331,7 @@ function indexFor(mesh, allowCore) {
315
331
  const c = allowCore ? core() : null;
316
332
  if (c) {
317
333
  try {
318
- const index = buildCoreBVH(c, triangleVertices(mesh), () => buildBVH(mesh));
319
- if (index) return index;
320
- countRefusedMesh(); // a mesh the core declines (not a fault): the JS index below
334
+ return buildCoreBVH(c, triangleVertices(mesh), () => buildBVH(mesh));
321
335
  } catch (err) {
322
336
  poisonCore(c, err); // a fault while building: this realm's core is done; JS from here
323
337
  }
@@ -335,73 +349,176 @@ export function buildBVH(mesh) {
335
349
  const verts = triangleVertices(mesh);
336
350
  const count = verts.length / 9;
337
351
 
352
+ // Per-triangle boxes, held in BUILD order (permuted with `order`) so a node's
353
+ // pass over its range reads them sequentially. A box corner is a vertex
354
+ // coordinate, so the vertices' own array type holds it exactly. Transient,
355
+ // like the partition scratch.
356
+ const Store = verts.constructor;
338
357
  const order = new Uint32Array(count);
339
- // AABB-midpoint centroids, one Float64 triple per triangle. Transient: only the
340
- // median split reads them, and they are released explicitly below.
341
- let cent = new Float64Array(count * 3);
358
+ let tb = new Store(count * 6), tbTmp = new Store(count * 6), orderTmp = new Uint32Array(count);
342
359
  for (let t = 0; t < count; t++) {
343
360
  order[t] = t;
344
361
  const o = t * 9;
345
- for (let a = 0; a < 3; a++) {
346
- const p = verts[o + a], q = verts[o + 3 + a], r = verts[o + 6 + a];
347
- const lo = p < q ? (p < r ? p : r) : (q < r ? q : r);
348
- const hi = p > q ? (p > r ? p : r) : (q > r ? q : r);
349
- cent[t * 3 + a] = (lo + hi) / 2;
362
+ let x0 = Infinity, y0 = Infinity, z0 = Infinity, x1 = -Infinity, y1 = -Infinity, z1 = -Infinity;
363
+ for (let j = 0; j < 9; j += 3) {
364
+ const x = verts[o + j], y = verts[o + j + 1], z = verts[o + j + 2];
365
+ if (x < x0) x0 = x; if (x > x1) x1 = x;
366
+ if (y < y0) y0 = y; if (y > y1) y1 = y;
367
+ if (z < z0) z0 = z; if (z > z1) z1 = z;
350
368
  }
369
+ tb[t * 6] = x0; tb[t * 6 + 1] = y0; tb[t * 6 + 2] = z0; tb[t * 6 + 3] = x1; tb[t * 6 + 4] = y1; tb[t * 6 + 5] = z1;
351
370
  }
352
371
 
353
- const bounds = new Float64Array(countNodes(count, new Map()) * 6);
354
- const meta = new Uint32Array(bounds.length / 3);
372
+ // A binary tree over n >= 1 leaves has at most 2n - 1 nodes; trimmed below.
373
+ let bounds = new Float64Array(Math.max(1, 2 * count - 1) * 6);
374
+ let meta = new Uint32Array(bounds.length / 3);
355
375
  let next = 0;
376
+ // Bin scratch, reused by every node (done with before it recurses).
377
+ const binN = new Float64Array(BINS), binB = new Float64Array(BINS * 6);
378
+ const rightArea = new Float64Array(BINS), rightN = new Float64Array(BINS);
356
379
 
357
- // Pre-order emit: a node claims its slot, writes its own AABB, then either
358
- // becomes a leaf over order[start, start+len) or sorts that range by the widest
359
- // centroid axis and recurses. The left child lands at self+1 by construction, so
360
- // only the right child's index needs storing.
361
- function emit(start, len) {
380
+ // A range's box and its centroids' box, as 12 numbers: lo xyz, hi xyz,
381
+ // centroid lo xyz, centroid hi xyz. The parent's partition pass fills one
382
+ // per child (same triangles, same order — the same values a pass of the
383
+ // child's own would find), so a node never rescans its range just for these.
384
+ const rangeBox = () => Float64Array.of(Infinity, Infinity, Infinity, -Infinity, -Infinity, -Infinity,
385
+ Infinity, Infinity, Infinity, -Infinity, -Infinity, -Infinity);
386
+ function addTo(R, o) {
387
+ const l0 = tb[o], l1 = tb[o + 1], l2 = tb[o + 2], h0 = tb[o + 3], h1 = tb[o + 4], h2 = tb[o + 5];
388
+ const c0 = (l0 + h0) / 2, c1 = (l1 + h1) / 2, c2 = (l2 + h2) / 2;
389
+ if (l0 < R[0]) R[0] = l0; if (h0 > R[3]) R[3] = h0; if (c0 < R[6]) R[6] = c0; if (c0 > R[9]) R[9] = c0;
390
+ if (l1 < R[1]) R[1] = l1; if (h1 > R[4]) R[4] = h1; if (c1 < R[7]) R[7] = c1; if (c1 > R[10]) R[10] = c1;
391
+ if (l2 < R[2]) R[2] = l2; if (h2 > R[5]) R[5] = h2; if (c2 < R[8]) R[8] = c2; if (c2 > R[11]) R[11] = c2;
392
+ }
393
+ function measureRange(start, len) {
394
+ const R = rangeBox();
395
+ for (let k = start; k < start + len; k++) addTo(R, k * 6);
396
+ return R;
397
+ }
398
+
399
+ // Pre-order emit: a node claims its slot and writes its own AABB (`R`, its
400
+ // range's boxes), then either becomes a leaf over order[start, start+len) or
401
+ // partitions that range and recurses. The left child lands at self+1 by
402
+ // construction, so only the right child's index needs storing.
403
+ function emit(start, len, depth, R) {
362
404
  const self = next++, nb = self * 6;
363
- let x0 = Infinity, y0 = Infinity, z0 = Infinity, x1 = -Infinity, y1 = -Infinity, z1 = -Infinity;
364
- for (let k = 0; k < len; k++) {
365
- const o = order[start + k] * 9;
366
- for (let j = 0; j < 9; j += 3) {
367
- const x = verts[o + j], y = verts[o + j + 1], z = verts[o + j + 2];
368
- if (x < x0) x0 = x; if (x > x1) x1 = x;
369
- if (y < y0) y0 = y; if (y > y1) y1 = y;
370
- if (z < z0) z0 = z; if (z > z1) z1 = z;
405
+ for (let j = 0; j < 6; j++) bounds[nb + j] = R[j];
406
+ if (len <= 1) { meta[self * 2] = start; meta[self * 2 + 1] = len + 1; return self; }
407
+
408
+ if (depth < DEPTH_LIMIT) {
409
+ // The split search: the axis of the widest centroid spread (ties to the
410
+ // lower axis), its BINS bins, the cheapest plane between them. One axis,
411
+ // not three: measured, binning all three bought queries 5-10% for a
412
+ // build ~1.8x as long in the native core.
413
+ const ex = R[9] - R[6], ey = R[10] - R[7], ez = R[11] - R[8];
414
+ const axis = ex >= ey && ex >= ez ? 0 : ey >= ez ? 1 : 2;
415
+ const ext = R[9 + axis] - R[6 + axis];
416
+ let bestCost = Infinity, bestAxis = -1, bestSplit = 0;
417
+ if (ext > 0) {
418
+ const lo = R[6 + axis], scale = BINS / ext;
419
+ binN.fill(0);
420
+ for (let i = 0; i < BINS; i++) {
421
+ binB[i * 6] = binB[i * 6 + 1] = binB[i * 6 + 2] = Infinity;
422
+ binB[i * 6 + 3] = binB[i * 6 + 4] = binB[i * 6 + 5] = -Infinity;
423
+ }
424
+ for (let k = start; k < start + len; k++) {
425
+ const o = k * 6, bin = binOf((tb[o + axis] + tb[o + 3 + axis]) / 2, lo, scale), b = bin * 6;
426
+ binN[bin]++;
427
+ const l0 = tb[o], l1 = tb[o + 1], l2 = tb[o + 2], h0 = tb[o + 3], h1 = tb[o + 4], h2 = tb[o + 5];
428
+ if (l0 < binB[b]) binB[b] = l0; if (h0 > binB[b + 3]) binB[b + 3] = h0;
429
+ if (l1 < binB[b + 1]) binB[b + 1] = l1; if (h1 > binB[b + 4]) binB[b + 4] = h1;
430
+ if (l2 < binB[b + 2]) binB[b + 2] = l2; if (h2 > binB[b + 5]) binB[b + 5] = h2;
431
+ }
432
+ // Sweep from the right for each candidate's right side, then from the
433
+ // left, costing every plane that leaves triangles on both sides.
434
+ let rx0 = Infinity, ry0 = Infinity, rz0 = Infinity, rx1 = -Infinity, ry1 = -Infinity, rz1 = -Infinity, rn = 0;
435
+ for (let i = BINS - 1; i > 0; i--) {
436
+ const o = i * 6;
437
+ if (binB[o] < rx0) rx0 = binB[o]; if (binB[o + 3] > rx1) rx1 = binB[o + 3];
438
+ if (binB[o + 1] < ry0) ry0 = binB[o + 1]; if (binB[o + 4] > ry1) ry1 = binB[o + 4];
439
+ if (binB[o + 2] < rz0) rz0 = binB[o + 2]; if (binB[o + 5] > rz1) rz1 = binB[o + 5];
440
+ rn += binN[i];
441
+ rightArea[i] = halfArea(rx0, ry0, rz0, rx1, ry1, rz1); rightN[i] = rn;
442
+ }
443
+ let lx0 = Infinity, ly0 = Infinity, lz0 = Infinity, lx1 = -Infinity, ly1 = -Infinity, lz1 = -Infinity, ln = 0;
444
+ for (let i = 0; i < BINS - 1; i++) {
445
+ const o = i * 6;
446
+ if (binB[o] < lx0) lx0 = binB[o]; if (binB[o + 3] > lx1) lx1 = binB[o + 3];
447
+ if (binB[o + 1] < ly0) ly0 = binB[o + 1]; if (binB[o + 4] > ly1) ly1 = binB[o + 4];
448
+ if (binB[o + 2] < lz0) lz0 = binB[o + 2]; if (binB[o + 5] > lz1) lz1 = binB[o + 5];
449
+ ln += binN[i];
450
+ if (!ln || !rightN[i + 1]) continue;
451
+ const cost = halfArea(lx0, ly0, lz0, lx1, ly1, lz1) * ln + rightArea[i + 1] * rightN[i + 1];
452
+ if (cost < bestCost) { bestCost = cost; bestAxis = axis; bestSplit = i; }
453
+ }
454
+ }
455
+ if (bestAxis >= 0) {
456
+ // A small node stays a leaf unless splitting beats testing its
457
+ // triangles outright (one box test ~ one triangle test).
458
+ const area = halfArea(R[0], R[1], R[2], R[3], R[4], R[5]);
459
+ if (len <= MAX_LEAF && area + bestCost >= area * len) {
460
+ meta[self * 2] = start; meta[self * 2 + 1] = len + 1; return self;
461
+ }
462
+ // Stable partition: bins <= bestSplit first, each side in its order.
463
+ // The left side is compacted in place (it never overtakes k); the
464
+ // right side waits in scratch and is appended after.
465
+ const lo = R[6 + bestAxis], scale = BINS / (R[9 + bestAxis] - R[6 + bestAxis]);
466
+ const L = rangeBox(), Rt = rangeBox();
467
+ let l = start, r = 0;
468
+ for (let k = start; k < start + len; k++) {
469
+ const o = k * 6;
470
+ if (binOf((tb[o + bestAxis] + tb[o + 3 + bestAxis]) / 2, lo, scale) <= bestSplit) {
471
+ addTo(L, o);
472
+ order[l] = order[k];
473
+ for (let j = 0; j < 6; j++) tb[l * 6 + j] = tb[o + j];
474
+ l++;
475
+ } else {
476
+ addTo(Rt, o);
477
+ orderTmp[r] = order[k];
478
+ for (let j = 0; j < 6; j++) tbTmp[r * 6 + j] = tb[o + j];
479
+ r++;
480
+ }
481
+ }
482
+ for (let i = 0; i < r; i++) {
483
+ order[l + i] = orderTmp[i];
484
+ for (let j = 0; j < 6; j++) tb[(l + i) * 6 + j] = tbTmp[i * 6 + j];
485
+ }
486
+ emit(start, l - start, depth + 1, L);
487
+ meta[self * 2] = emit(l, r, depth + 1, Rt);
488
+ meta[self * 2 + 1] = 0;
489
+ return self;
371
490
  }
372
491
  }
373
- bounds[nb] = x0; bounds[nb + 1] = y0; bounds[nb + 2] = z0;
374
- bounds[nb + 3] = x1; bounds[nb + 4] = y1; bounds[nb + 5] = z1;
375
- if (len <= LEAF) { meta[self * 2] = start; meta[self * 2 + 1] = len + 1; return self; }
376
- const ex = x1 - x0, ey = y1 - y0, ez = z1 - z0;
377
- const axis = ex >= ey && ex >= ez ? 0 : ey >= ez ? 1 : 2;
378
- order.subarray(start, start + len).sort((p, q) => cent[p * 3 + axis] - cent[q * 3 + axis]);
379
- // No empty-half guard: len > LEAF here (LEAF >= 1), so mid = len>>1 >= 2 and
380
- // len - mid >= 3 — both halves always non-empty. The nested-array build this
381
- // replaced carried such a check; it was dead code there too, and countNodes()
382
- // assumes this same split, so a guard that ever fired would mis-size `bounds`.
492
+ // No plane separates the centroids (they coincide), or the depth limit:
493
+ // halve the range as it stands.
494
+ if (len <= MAX_LEAF) { meta[self * 2] = start; meta[self * 2 + 1] = len + 1; return self; }
383
495
  const mid = len >> 1;
384
- emit(start, mid);
385
- meta[self * 2] = emit(start + mid, len - mid);
496
+ emit(start, mid, depth + 1, measureRange(start, mid));
497
+ meta[self * 2] = emit(start + mid, len - mid, depth + 1, measureRange(start + mid, len - mid));
386
498
  meta[self * 2 + 1] = 0;
387
499
  return self;
388
500
  }
389
- emit(0, count);
390
- // Dropped explicitly, not left to scope: `emit` and the three query closures below
391
- // share one function context, so a `cent` merely gone out of use would still be
392
- // reachable from every returned BVH — 24 bytes/triangle of dead weight for the
393
- // life of the index.
394
- cent = null;
501
+ emit(0, count, 0, measureRange(0, count));
502
+ bounds = bounds.slice(0, next * 6);
503
+ meta = meta.slice(0, next * 2);
504
+ // Dropped explicitly, not left to scope: `emit` and the three query closures
505
+ // below share one function context, so build scratch merely gone out of use
506
+ // would stay reachable from every returned BVH for the life of the index.
507
+ tb = tbTmp = orderTmp = null;
395
508
 
396
509
  function raycast(origin, dir, { tMin = 1e-6, tMax = Infinity, skipTri = -1 } = {}) {
397
510
  const ox = origin[0], oy = origin[1], oz = origin[2];
398
511
  const dx = dir[0], dy = dir[1], dz = dir[2];
399
512
  const ix = 1 / dx, iy = 1 / dy, iz = 1 / dz;
400
513
  let best = tMax, bestTri = -1;
401
- const stack = [0];
514
+ // Nearer child first, each pushed with its entry distance: a node whose box
515
+ // starts beyond the best hit found since is dropped on the pop.
516
+ const stack = [], entry = [];
517
+ const e0 = rayEntry(ox, oy, oz, ix, iy, iz, bounds, 0, tMin, best);
518
+ if (e0 !== Infinity) { stack.push(0); entry.push(e0); }
402
519
  while (stack.length) {
403
520
  const n = stack.pop();
404
- if (!rayHitsBox(ox, oy, oz, ix, iy, iz, bounds, n * 6, tMin, best)) continue;
521
+ if (entry.pop() > best) continue;
405
522
  const packed = meta[n * 2 + 1];
406
523
  if (packed) {
407
524
  const start = meta[n * 2];
@@ -411,7 +528,18 @@ export function buildBVH(mesh) {
411
528
  const t = rayTri(ox, oy, oz, dx, dy, dz, verts, tri * 9, tMin);
412
529
  if (t < best) { best = t; bestTri = tri; }
413
530
  }
414
- } else { stack.push(n + 1, meta[n * 2]); }
531
+ } else {
532
+ const l = n + 1, r = meta[n * 2];
533
+ const tl = rayEntry(ox, oy, oz, ix, iy, iz, bounds, l * 6, tMin, best);
534
+ const tr = rayEntry(ox, oy, oz, ix, iy, iz, bounds, r * 6, tMin, best);
535
+ if (tl <= tr) {
536
+ if (tr !== Infinity) { stack.push(r); entry.push(tr); }
537
+ if (tl !== Infinity) { stack.push(l); entry.push(tl); }
538
+ } else {
539
+ if (tl !== Infinity) { stack.push(l); entry.push(tl); }
540
+ if (tr !== Infinity) { stack.push(r); entry.push(tr); }
541
+ }
542
+ }
415
543
  }
416
544
  return bestTri === -1 ? null : { t: best, tri: bestTri };
417
545
  }