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.
- package/docs/KERNEL-CONTRACT.md +31 -0
- package/package.json +1 -1
- package/src/framework/assembly.js +13 -5
- package/src/framework/geometry/manifold-backend.js +40 -4
- package/src/framework/geometry/solid-cache.js +38 -4
- package/src/framework/geometry/transform-hoist.js +47 -0
- package/src/framework/oracle/build.js +15 -4
package/docs/KERNEL-CONTRACT.md
CHANGED
|
@@ -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
|
@@ -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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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.
|
|
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
|
-
:
|
|
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
|
-
|
|
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))
|
|
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
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
}
|