partforge 0.69.0 → 0.70.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,23 @@ 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
+ Sub-part brackets bound cache RETENTION, not reuse: a solid one sub-part builds is reused
102
+ by any other that asks for the same content hash, so a sheet of identical cells split
103
+ across row sub-parts evaluates each distinct cell once rather than once per row. An adopted
104
+ entry is retained by both partitions and disposed only when the last one drops it.
105
+
106
+ **Transform hoisting.** Booleans commute with rigid transforms, so a conforming backend MAY
107
+ lift a transform every operand shares out of the boolean and apply it to the result
108
+ instead — which is what lets N identically-built copies share one evaluation. Two
109
+ consequences a host must expect. Hoisting evaluates the boolean in a different frame, so
110
+ the result is geometrically equivalent but **not** guaranteed mesh-identical: vertex order,
111
+ triangulation, and triangle count may differ (measured on the in-repo `scott-label`
112
+ lettering: same genus and bounding box, volume agreeing to ~1e-9 relative, ~1% more
113
+ triangles). Output stays deterministic for a given build. And an op is eligible only if it
114
+ provably commutes with the transform — `fillet`/`chamfer` do NOT, because their edge
115
+ selectors can be world-space, so hoisting past one would select different edges and emit
116
+ wrong geometry.
117
+
101
118
  **`import`.** `kernel.import(name) → Solid` returns previously-registered imported geometry
102
119
  (STL/STEP/3MF geometry declared in a part's `imports` field); `_registerImport`/
103
120
  `_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.70.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",
@@ -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,41 @@
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;
12
18
  let hits = 0, misses = 0;
13
19
 
20
+ // One partition stops retaining `entry`. Disposal waits for the LAST holder:
21
+ // a solid shared across sub-parts is one WASM object, so disposing it when the
22
+ // first partition drops it would leave every other partition — and the shared
23
+ // index — pointing at freed memory. Dropping the index entry in the same breath
24
+ // is what keeps the cache from handing out a disposed solid later.
25
+ const release = (hash, entry) => {
26
+ if (--entry.refs > 0) return;
27
+ if (index.get(hash) === entry) index.delete(hash);
28
+ pinned.delete(entry.pin);
29
+ entry.dispose();
30
+ };
31
+
14
32
  return {
15
33
  begin(n) { name = n; lastBuilt.set(n, generation); prev = caches.get(n) ?? new Map(); active = new Map(); },
16
34
 
17
35
  end() {
18
36
  if (name == null) return;
19
37
  for (const [hash, entry] of prev) {
20
- if (!active.has(hash)) { pinned.delete(entry.pin); entry.dispose(); } // evict
38
+ if (!active.has(hash)) release(hash, entry); // this partition drops it
21
39
  }
22
40
  caches.set(name, active);
23
41
  name = null; active = prev = null;
@@ -31,7 +49,7 @@ export function createSolidCache() {
31
49
  generation++;
32
50
  for (const [n, entries] of caches) {
33
51
  if (generation - (lastBuilt.get(n) ?? 0) < 3) continue;
34
- for (const entry of entries.values()) { pinned.delete(entry.pin); entry.dispose(); }
52
+ for (const [hash, entry] of entries) release(hash, entry); // refcounted: a shared solid survives
35
53
  caches.delete(n);
36
54
  lastBuilt.delete(n);
37
55
  }
@@ -41,9 +59,14 @@ export function createSolidCache() {
41
59
  if (name == null) return make().value; // not bracketed → no caching
42
60
  if (active.has(hash)) { hits++; return active.get(hash).value; }
43
61
  if (prev.has(hash)) { hits++; const e = prev.get(hash); active.set(hash, e); return e.value; }
62
+ // Another sub-part already built this exact solid — adopt it. `refs` rises
63
+ // because a second partition now retains it; see release().
64
+ if (index.has(hash)) { hits++; const e = index.get(hash); e.refs++; active.set(hash, e); return e.value; }
44
65
  misses++;
45
66
  const entry = make();
67
+ entry.refs = 1;
46
68
  active.set(hash, entry);
69
+ index.set(hash, entry);
47
70
  pinned.add(entry.pin);
48
71
  return entry.value;
49
72
  },
@@ -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
+ }