partforge 0.69.0 → 0.71.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.
@@ -98,6 +98,37 @@ them loses sub-part caching and mesh-topology gates (`holes`, emptiness), nothin
98
98
  a part (never inside a `beginSubPart`/`endSubPart` bracket), it drops cache partitions that
99
99
  have gone unbuilt for three consecutive rebinds.
100
100
 
101
+ `beginSubPart`/`endSubPart` brackets MAY nest: only the outermost pair opens and
102
+ commits a round, and an inner pair is a balanced no-op. Nesting is real rather than
103
+ theoretical — `buildView` opens a round of its own, so any caller that brackets around
104
+ a view build contains one. A backend that keeps a single open round (rather than a
105
+ stack) must collapse inner pairs this way; committing on the inner `end()` would close
106
+ the outer round early and leave the rest of that build uncached.
107
+
108
+ Sub-part brackets bound cache RETENTION, not reuse: a solid one sub-part builds is reused
109
+ by any other that asks for the same content hash, so a sheet of identical cells split
110
+ across row sub-parts evaluates each distinct cell once rather than once per row. An adopted
111
+ entry is retained by both partitions and disposed only when the last one drops it.
112
+
113
+ The oracle (`buildView`, `assemblyOverlaps`) brackets under partition names of its
114
+ own rather than the display sub-part names, and a host adding another oracle-side build
115
+ should do the same. Both reuse the display build's solids through the cross-partition
116
+ index, so measuring a view costs almost nothing right after drawing it; keeping them in
117
+ separate partitions is what stops a measurement's own geometry — verify walks cases with
118
+ params of their own — from displacing the geometry the viewer is showing.
119
+
120
+ **Transform hoisting.** Booleans commute with rigid transforms, so a conforming backend MAY
121
+ lift a transform every operand shares out of the boolean and apply it to the result
122
+ instead — which is what lets N identically-built copies share one evaluation. Two
123
+ consequences a host must expect. Hoisting evaluates the boolean in a different frame, so
124
+ the result is geometrically equivalent but **not** guaranteed mesh-identical: vertex order,
125
+ triangulation, and triangle count may differ (measured on the in-repo `scott-label`
126
+ lettering: same genus and bounding box, volume agreeing to ~1e-9 relative, ~1% more
127
+ triangles). Output stays deterministic for a given build. And an op is eligible only if it
128
+ provably commutes with the transform — `fillet`/`chamfer` do NOT, because their edge
129
+ selectors can be world-space, so hoisting past one would select different edges and emit
130
+ wrong geometry.
131
+
101
132
  **`import`.** `kernel.import(name) → Solid` returns previously-registered imported geometry
102
133
  (STL/STEP/3MF geometry declared in a part's `imports` field); `_registerImport`/
103
134
  `_importDigest`/`_acceptsStep`/`_acceptsMesh` are the underscore-prefixed side-channel the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.69.0",
3
+ "version": "0.71.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -9,10 +9,17 @@ import { viewSubParts, resolveParams, buildPosed } from "./part-model.js";
9
9
  // → [{ a, b, volume, location }] for each offending pair (empty = no collisions)
10
10
  export function assemblyOverlaps(kernel, part, view, params = {}, { tolerance = 1 } = {}) {
11
11
  const { p, d } = resolveParams(part, params);
12
- const posed = viewSubParts(part, view, p).map((name) => ({
13
- name,
14
- solid: buildPosed(kernel, part, name, { purpose: "display", view, p, d }),
15
- }));
12
+ // Same posed solids buildView builds, so this round is almost entirely hits off
13
+ // that one; only the pairwise intersects below are new. Its own oracle partition,
14
+ // for the reason buildView's comment gives.
15
+ kernel.beginSubPart?.(`oracle:overlaps:${view}`);
16
+ let posed;
17
+ try {
18
+ posed = viewSubParts(part, view, p).map((name) => ({
19
+ name,
20
+ solid: buildPosed(kernel, part, name, { purpose: "display", view, p, d }),
21
+ }));
22
+ } catch (e) { kernel.endSubPart?.(); throw e; } // never strand the round on a failed build
16
23
 
17
24
  const overlaps = [];
18
25
  for (let i = 0; i < posed.length; i++) {
@@ -27,6 +34,7 @@ export function assemblyOverlaps(kernel, part, view, params = {}, { tolerance =
27
34
  }
28
35
  }
29
36
  }
30
- kernel.cleanup?.(); // free the per-check WASM objects
37
+ kernel.endSubPart?.();
38
+ kernel.cleanup?.(); // free the per-check WASM objects (cached solids are pinned)
31
39
  return overlaps;
32
40
  }
@@ -7,6 +7,7 @@ import { h } from "./solid-hash.js";
7
7
  import { ensureOutward, openEdgeCount } from "./mesh-repair.js";
8
8
  import { manifoldFromMesh } from "./mesh-build.js";
9
9
  import { createSolidCache } from "./solid-cache.js";
10
+ import { hoistCommonSuffix } from "./transform-hoist.js";
10
11
  import { addSugar } from "./solid-sugar.js";
11
12
  import { makeShape2dFactory } from "./shape2d.js";
12
13
  import { offsetRegions } from "./contour-offset.js";
@@ -94,6 +95,20 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
94
95
  return { value: wrap(m, hash), pin: m, dispose: () => m.delete?.() };
95
96
  });
96
97
 
98
+ // Booleans commute with any invertible affine map, so a transform EVERY operand
99
+ // ends with can be lifted out of the boolean and applied to its result instead.
100
+ // That is what collapses N identically-built copies into one evaluation: with the
101
+ // shared transform gone, the operand hashes are identical for every copy, so the
102
+ // boolean itself hits the cache. Returns null when nothing is shared, leaving the
103
+ // caller on its ordinary path.
104
+ const hoistBoolean = (opName, solids, evaluate) => {
105
+ const { hoisted, residuals } = hoistCommonSuffix(solids.map((s2) => s2._canon.chain));
106
+ if (!hoisted.length) return null;
107
+ const ops = solids.map((s2, i) => replay(wrap(s2._canon.m, s2._canon.hash), residuals[i]));
108
+ const canonical = cached(h(opName, ops.map((s2) => s2._hash)), () => evaluate(ops));
109
+ return replay(canonical, hoisted);
110
+ };
111
+
97
112
  // Contour-IR region list -> flat point rings at `nSeg` (outer + holes, even/odd
98
113
  // fill sorts them out). The one place the IR meets CrossSection.ofPolygons.
99
114
  const regionPolys = (regions, nSeg) => regions.flatMap((rg) =>
@@ -306,14 +321,27 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
306
321
  }
307
322
  };
308
323
 
324
+ // Replay a recorded transform chain onto a solid. Each record maps back to the op
325
+ // that produced it, so the replayed solid rebuilds the same chain on its own canon.
326
+ const replay = (solid, chain) => chain.reduce(
327
+ (s2, r) => (r.op === "translate" ? s2.translate(r.v) : s2.rotate(r.deg, r.center, r.axis)), solid);
328
+
329
+ // `canon` is this solid expressed as a base solid plus the trailing transform chain
330
+ // applied to it (oldest first). Only ops that provably COMMUTE with a rigid
331
+ // transform extend the chain — translate, rotate, and label; everything else starts
332
+ // a fresh canonical base. fillet/chamfer are deliberately excluded even though they
333
+ // look eligible: their edge selectors can be world-space, so filleting the
334
+ // untranslated base would pick different edges — wrong geometry, not a missed hit.
335
+ //
309
336
  // `self` names the wrapper being built so the degrading public fillet/chamfer
310
337
  // can delegate to their throwing `_`-prefixed twins above without re-deriving
311
338
  // the cache key or the capability checks. Declared as a binding the closures
312
339
  // capture: every reference runs after addSugar has returned.
313
- const wrap = (m, hash) => {
340
+ const wrap = (m, hash, canon = { m, hash, chain: [] }) => {
314
341
  const self = addSugar({
315
342
  _m: m,
316
343
  _hash: hash,
344
+ _canon: canon,
317
345
  cut: (t) => cached(h("cut", hash, t._hash), () => T(m.subtract(t._m))),
318
346
  // THROWING forms. These are the composition primitives — internal callers
319
347
  // that have their own recovery (prismRoundAllFast, which answers a failed
@@ -388,6 +416,11 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
388
416
  // registry entry lives exactly as long as the cache pins the solid — eviction
389
417
  // disposes both, so the registry can't grow unboundedly across regenerates.
390
418
  label: (name) => {
419
+ // Labeling only re-stamps surface ids, so it commutes with the trailing
420
+ // transform. This is load-bearing rather than an optimization: the common
421
+ // authoring idiom labels each piece AFTER positioning it, which would give every
422
+ // copy its own canonical base and stop the hoist below from ever firing.
423
+ if (canon.chain.length) return replay(wrap(canon.m, canon.hash).label(name), canon.chain);
391
424
  const lh = h("label", hash, name);
392
425
  return cache.lookup(lh, () => {
393
426
  // Blend-aware re-stamp. If this mesh carries blend surfaces (the boundaryLines
@@ -492,14 +525,16 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
492
525
  volume: () => m.volume(),
493
526
  genus: () => m.genus(),
494
527
  isEmpty: () => m.isEmpty(),
495
- translate: (v) => wrap(T(m.translate(v)), h("translate", hash, v)),
528
+ translate: (v) => wrap(T(m.translate(v)), h("translate", hash, v),
529
+ { m: canon.m, hash: canon.hash, chain: [...canon.chain, { op: "translate", v }] }),
496
530
  rotate: (deg, center, axis) => {
497
531
  const nz = (axis[0] !== 0) + (axis[1] !== 0) + (axis[2] !== 0);
498
532
  const a = T(m.translate([-center[0], -center[1], -center[2]]));
499
533
  const b = nz <= 1
500
534
  ? T(a.rotate([axis[0] * deg, axis[1] * deg, axis[2] * deg])) // basis axis — euler is exact; unchanged
501
535
  : T(a.transform(axisAngleMat4(axis, deg))); // general axis-angle
502
- return wrap(T(b.translate(center)), h("rotate", hash, deg, center, axis));
536
+ return wrap(T(b.translate(center)), h("rotate", hash, deg, center, axis),
537
+ { m: canon.m, hash: canon.hash, chain: [...canon.chain, { op: "rotate", deg, center, axis }] });
503
538
  },
504
539
  mirror: (plane) => wrap(T(m.mirror(PLANE_NORMAL[plane])), h("mirror", hash, plane)),
505
540
  scale: (factor, center) => { // factor validated (and center defaulted) by addSugar
@@ -603,7 +638,8 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
603
638
  // would pin one WASM object under two entries and eviction would dispose it twice.
604
639
  union: (solids) => solids.length === 1
605
640
  ? solids[0]
606
- : cached(h("union", solids.map((s) => s._hash)), () => unionRaw(solids.map((s) => s._m))),
641
+ : hoistBoolean("union", solids, (ops) => unionRaw(ops.map((s) => s._m)))
642
+ ?? cached(h("union", solids.map((s) => s._hash)), () => unionRaw(solids.map((s) => s._m))),
607
643
  // Imported geometry, registered pre-build by the framework via `_registerImport`
608
644
  // (ensureImports, Task 8). The master Manifold is kernel-lifetime (untracked —
609
645
  // see `imports` above); wrap() is free, so every call is cheap.
@@ -1,23 +1,52 @@
1
1
  // Worker-side cache of boundary-op solids, partitioned per sub-part. Retention is
2
2
  // bounded to the CURRENT build's graph: each begin()/end() bracket rebuilds a
3
3
  // sub-part's retained set from scratch, disposing any entry not re-used this round.
4
+ // Partitions bound RETENTION, never reuse: a `index` keyed by hash spans them all, so
5
+ // geometry one sub-part builds is adopted by the next rather than rebuilt (a sheet of
6
+ // identical cells split across row sub-parts paid that rebuild per row). An adopted
7
+ // entry is retained by BOTH partitions, so disposal is refcounted — see release().
4
8
  // WASM-agnostic — it stores opaque {value, pin, dispose} triples supplied by the
5
9
  // caller (the Manifold backend), so it is unit-testable with plain objects.
6
10
  export function createSolidCache() {
7
- const caches = new Map(); // name -> Map(hash -> { value, pin, dispose })
11
+ const caches = new Map(); // name -> Map(hash -> { value, pin, dispose, refs })
8
12
  const pinned = new Set(); // every live `pin` across all sub-parts
13
+ const index = new Map(); // hash -> entry, ACROSS partitions: identical geometry built
14
+ // for one sub-part is reused by every other (see lookup).
9
15
  const lastBuilt = new Map(); // name -> rebind generation of the partition's last begin()
10
16
  let generation = 0; // bumped only by sweep() (i.e. per part rebind)
11
17
  let name = null, active = null, prev = null;
18
+ let depth = 0; // bracket nesting; only the OUTERMOST one is real (see begin)
12
19
  let hits = 0, misses = 0;
13
20
 
21
+ // One partition stops retaining `entry`. Disposal waits for the LAST holder:
22
+ // a solid shared across sub-parts is one WASM object, so disposing it when the
23
+ // first partition drops it would leave every other partition — and the shared
24
+ // index — pointing at freed memory. Dropping the index entry in the same breath
25
+ // is what keeps the cache from handing out a disposed solid later.
26
+ const release = (hash, entry) => {
27
+ if (--entry.refs > 0) return;
28
+ if (index.get(hash) === entry) index.delete(hash);
29
+ pinned.delete(entry.pin);
30
+ entry.dispose();
31
+ };
32
+
14
33
  return {
15
- begin(n) { name = n; lastBuilt.set(n, generation); prev = caches.get(n) ?? new Map(); active = new Map(); },
34
+ // Nested brackets collapse into the outermost one. There is a single open
35
+ // round (name/active/prev), not a stack, so an inner begin() would otherwise
36
+ // rebind it and the inner end() would commit-and-close the OUTER round early —
37
+ // evicting its entries and leaving the rest of that build uncached. Callers
38
+ // nest legitimately now that buildView brackets: an outer bracket is free to
39
+ // contain one, and the inner pair becomes a no-op.
40
+ begin(n) {
41
+ if (depth++ > 0) return;
42
+ name = n; lastBuilt.set(n, generation); prev = caches.get(n) ?? new Map(); active = new Map();
43
+ },
16
44
 
17
45
  end() {
46
+ if (depth > 0 && --depth > 0) return; // inner bracket — the outer one still owns the round
18
47
  if (name == null) return;
19
48
  for (const [hash, entry] of prev) {
20
- if (!active.has(hash)) { pinned.delete(entry.pin); entry.dispose(); } // evict
49
+ if (!active.has(hash)) release(hash, entry); // this partition drops it
21
50
  }
22
51
  caches.set(name, active);
23
52
  name = null; active = prev = null;
@@ -31,7 +60,7 @@ export function createSolidCache() {
31
60
  generation++;
32
61
  for (const [n, entries] of caches) {
33
62
  if (generation - (lastBuilt.get(n) ?? 0) < 3) continue;
34
- for (const entry of entries.values()) { pinned.delete(entry.pin); entry.dispose(); }
63
+ for (const [hash, entry] of entries) release(hash, entry); // refcounted: a shared solid survives
35
64
  caches.delete(n);
36
65
  lastBuilt.delete(n);
37
66
  }
@@ -41,9 +70,14 @@ export function createSolidCache() {
41
70
  if (name == null) return make().value; // not bracketed → no caching
42
71
  if (active.has(hash)) { hits++; return active.get(hash).value; }
43
72
  if (prev.has(hash)) { hits++; const e = prev.get(hash); active.set(hash, e); return e.value; }
73
+ // Another sub-part already built this exact solid — adopt it. `refs` rises
74
+ // because a second partition now retains it; see release().
75
+ if (index.has(hash)) { hits++; const e = index.get(hash); e.refs++; active.set(hash, e); return e.value; }
44
76
  misses++;
45
77
  const entry = make();
78
+ entry.refs = 1;
46
79
  active.set(hash, entry);
80
+ index.set(hash, entry);
47
81
  pinned.add(entry.pin);
48
82
  return entry.value;
49
83
  },
@@ -0,0 +1,47 @@
1
+ // Pure suffix-matching for boolean transform hoisting. A solid carries the trailing
2
+ // transform chain applied to its canonical base (`canon.chain`, oldest first, so the
3
+ // LAST record is the outermost transform). Booleans commute with any invertible
4
+ // affine map, so a transform every operand ends with can be lifted out of the boolean
5
+ // and applied to its result instead — which is what lets 30 identically-built cells
6
+ // share ONE evaluated union instead of 30.
7
+ //
8
+ // Matching is SYMBOLIC (compare the recorded arguments), never numeric: deriving a
9
+ // residual as X⁻¹·xᵢ would make rotations disagree in the last bits from copy to copy
10
+ // and the hoist would silently stop firing. Comparing arguments is exact.
11
+
12
+ const same = (a, b) => {
13
+ if (a.op !== b.op) return false;
14
+ if (a.op === "translate") return vecEq(a.v, b.v);
15
+ return a.deg === b.deg && vecEq(a.center, b.center) && vecEq(a.axis, b.axis);
16
+ };
17
+ const vecEq = (a, b) => a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
18
+
19
+ export function hoistCommonSuffix(chains) {
20
+ const rest = chains.map((c) => c.slice());
21
+ const hoisted = [];
22
+ for (;;) {
23
+ if (rest.some((c) => c.length === 0)) break;
24
+ const last = rest.map((c) => c[c.length - 1]);
25
+ if (last.every((r) => same(r, last[0]))) {
26
+ hoisted.unshift(last[0]);
27
+ for (const c of rest) c.pop();
28
+ continue;
29
+ }
30
+ // Trailing translations that DISAGREE still share a common part, and splitting it
31
+ // out is what the grid case needs: a cell's hub ends .at([cx,cy,0]) while its
32
+ // support ends .at([cx,cy,z]), so exact matching alone would hoist nothing.
33
+ // Translations commute, so translate(vᵢ) = translate(v₀) ∘ translate(vᵢ−v₀), and
34
+ // subtracting shared coordinates is exact. Splitting leaves a translate behind on
35
+ // every other operand, so nothing deeper can match — this always ends the loop.
36
+ if (!last.every((r) => r.op === "translate")) break;
37
+ const v0 = last[0].v;
38
+ for (const [i, c] of rest.entries()) {
39
+ const d = [last[i].v[0] - v0[0], last[i].v[1] - v0[1], last[i].v[2] - v0[2]];
40
+ c.pop();
41
+ if (d[0] !== 0 || d[1] !== 0 || d[2] !== 0) c.push({ op: "translate", v: d });
42
+ }
43
+ hoisted.unshift({ op: "translate", v: v0 });
44
+ break;
45
+ }
46
+ return { hoisted, residuals: rest };
47
+ }
@@ -7,8 +7,19 @@ import { viewSubParts, resolveParams, buildPosed } from "../part-model.js";
7
7
  // before they free the kernel. Meshes are JS-owned arrays and survive cleanup.
8
8
  export function buildView(kernel, part, view, params = {}) {
9
9
  const { p, d } = resolveParams(part, params);
10
- return viewSubParts(part, view, p).map((name) => {
11
- const solid = buildPosed(kernel, part, name, { purpose: "display", view, p, d });
12
- return { name, solid, mesh: solid.toMesh() };
13
- });
10
+ // Cache round for the whole view. The name is the ORACLE's, deliberately not the
11
+ // display sub-part names the generate path brackets under: a distinct partition
12
+ // still reuses those solids (the cache indexes entries by content hash across
13
+ // partitions), while keeping this round's own eviction away from the geometry the
14
+ // viewer is showing — bracketing under the display names would make running the
15
+ // oracle throw away the display cache. One name per view also bounds retention:
16
+ // verify walks its cases through here, so each case evicts the previous rather
17
+ // than accumulating every case's geometry at once.
18
+ kernel.beginSubPart?.(`oracle:view:${view}`);
19
+ try {
20
+ return viewSubParts(part, view, p).map((name) => {
21
+ const solid = buildPosed(kernel, part, name, { purpose: "display", view, p, d });
22
+ return { name, solid, mesh: solid.toMesh() };
23
+ });
24
+ } finally { kernel.endSubPart?.(); }
14
25
  }