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.
package/docs/KERNEL-CONTRACT.md
CHANGED
|
@@ -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
|
@@ -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
|
-
:
|
|
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))
|
|
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
|
|
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
|
+
}
|