partforge 0.63.0 → 0.64.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.
Files changed (36) hide show
  1. package/bin/cli.js +2 -3
  2. package/docs/AUTHORING-PARTS.md +135 -0
  3. package/docs/ERROR-PATTERNS.md +29 -1
  4. package/docs/KERNEL-CONTRACT.md +15 -1
  5. package/package.json +1 -1
  6. package/src/app-import-demo.js +18 -0
  7. package/src/framework/app.css +8 -1
  8. package/src/framework/asset-resolve.js +71 -0
  9. package/src/framework/capture-build.js +12 -4
  10. package/src/framework/export-controller.js +12 -0
  11. package/src/framework/fonts.js +12 -30
  12. package/src/framework/geometry/kernel.js +2 -1
  13. package/src/framework/geometry/manifold-backend.js +41 -0
  14. package/src/framework/geometry/mesh-repair.js +87 -0
  15. package/src/framework/geometry/occt-backend.js +33 -1
  16. package/src/framework/geometry/stl-parse.js +45 -0
  17. package/src/framework/geometry/threemf-parse.js +87 -0
  18. package/src/framework/geometry-service.js +3 -1
  19. package/src/framework/imports.js +84 -0
  20. package/src/framework/jobs.js +20 -1
  21. package/src/framework/lint/index.js +2 -1
  22. package/src/framework/lint/rules-imports.js +115 -0
  23. package/src/framework/mount.js +94 -1
  24. package/src/framework/oracle/measure.js +28 -2
  25. package/src/framework/verify-metrics.js +10 -0
  26. package/src/framework/worker.js +11 -2
  27. package/src/import-demo-worker.js +3 -0
  28. package/src/parts/assets/import-demo-scan.stl +86 -0
  29. package/src/parts/import-demo.js +134 -0
  30. package/src/testing/assets.js +19 -0
  31. package/src/testing/manifold.js +14 -2
  32. package/src/testing/occt.js +5 -2
  33. package/src/testing/step-mesh-thread.js +15 -0
  34. package/src/testing/step-mesh.js +17 -0
  35. package/types/kernel.d.ts +6 -0
  36. package/types/part.d.ts +14 -0
@@ -37,6 +37,17 @@ const NOOP_TOOLTIP_BINDING = { sync: () => {}, hide: () => {}, detach: () => {}
37
37
  // Same no-op-default stance as attachTooltips/setHostPane below, for a
38
38
  // makeHandle caller (or a direct test) that doesn't wire measure mode.
39
39
  const NOOP_MEASURE = { isEnabled: () => false, setEnabled: () => {}, clearPins: () => {}, pinCount: () => 0 };
40
+ // The STEP-on-Manifold import crossover's broken-state message (a second
41
+ // needs-import-mesh after the mesh is already primed — see the "needs-import-mesh"
42
+ // case below). One shared string so the status line, onBuild, and the ready
43
+ // rejection can never drift apart.
44
+ const IMPORT_MESH_BROKEN_MESSAGE = "STEP import tessellation failed to satisfy the import — see console";
45
+ // The crossover's OTHER failure mode: the tessellate-imports request itself
46
+ // throws (malformed STEP, etc.) rather than delivering a mesh to prime. Distinct
47
+ // from IMPORT_MESH_BROKEN_MESSAGE above (that one names a digest mismatch AFTER
48
+ // a successful tessellation) — this one names the tessellation failure and
49
+ // carries the worker's own error text. See the correlated "error" case below.
50
+ const importTessellateFailedMessage = (workerMessage) => `STEP import tessellation failed — ${workerMessage}`;
40
51
 
41
52
  export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, setHostPane, animation, getView, setView, captureView, attachTooltips, measure }) {
42
53
  return {
@@ -261,6 +272,30 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
261
272
  if (forcedBackend !== "occt" && forcedBackend !== "manifold") forcedBackend = null;
262
273
  const backendPolicy = createBackendPolicy(part, { forced: forcedBackend });
263
274
  const backendFor = () => backendPolicy.backendFor(params);
275
+ // STEP-on-Manifold import crossover (spec 2026-08-16): null → "requested" →
276
+ // "primed", tracking the one tessellate-imports round trip this mount instance
277
+ // ever needs (a worker-lifetime prime, per Task 8). One mount() call handles
278
+ // exactly one part for its whole lifetime — there is no rebind point to reset
279
+ // this at — so declaring it here beside backendPolicy is its only reset.
280
+ let importMeshState = null;
281
+ // jobId of the outstanding tessellate-imports request (mount-generated —
282
+ // jobs.js's tessellate-imports branch echoes it back on both its
283
+ // "import-meshes" reply and, via the shared catch, on a thrown "error").
284
+ // Lets the "error" case below tell a correlated tessellation failure apart
285
+ // from an unrelated build error sharing the same message type, so it can
286
+ // reset the latch instead of stranding it at "requested" forever.
287
+ // String-namespaced ("tess-N"), mirroring capture-build.js's "cap-N" —
288
+ // this request shares the OCCT worker's message space with
289
+ // export-controller's PLAIN NUMERIC jobIds (both start counting at 1), and
290
+ // exportCtl.handleMessage (called before mount's own switch, below) does a
291
+ // raw pending.get(m.jobId) before checking type. A bare numeric id here
292
+ // could collide with a pending STEP export's jobId, so the export
293
+ // controller would wrongly claim this reply — rejecting an unrelated
294
+ // export with the tessellation failure text AND leaving importMeshState
295
+ // stranded at "requested" (mount's own switch, which would have reset it,
296
+ // never runs). The string namespace makes that collision impossible.
297
+ let importTessellateJobId = null;
298
+ let importTessellateJobSeq = 0;
264
299
 
265
300
  // ?debug shows the cache debug overlay; ?debug&nocache starts with caching off.
266
301
  const qs = new URLSearchParams(location.search);
@@ -582,7 +617,64 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
582
617
  loop.buildDone();
583
618
  loop.kick();
584
619
  break;
585
- case "error":
620
+ case "needs-import-mesh":
621
+ // STEP import on the Manifold worker: an unprimed import threw
622
+ // NEEDS_IMPORT_MESH (Task 8). Settle this build the same way
623
+ // needs-occt does — buildDone() only, no kick() here: the retry can't
624
+ // succeed until priming completes, and kicking now would just repeat
625
+ // the same failure before the mesh exists. The "import-meshes" case
626
+ // below is what kicks, once the prime has actually landed.
627
+ loop.buildDone();
628
+ if (importMeshState === "primed") {
629
+ // Tessellation already delivered a mesh but Manifold still can't
630
+ // satisfy the import — the digest didn't match. A genuinely broken
631
+ // state, not a retry loop: surface it like any other build error
632
+ // rather than re-requesting tessellation forever.
633
+ ui.hideBusy();
634
+ refreshView();
635
+ ui.setStatus(`failed: ${IMPORT_MESH_BROKEN_MESSAGE}`, true);
636
+ onBuild?.({ status: "error", error: IMPORT_MESH_BROKEN_MESSAGE });
637
+ if (!readySettled) { readySettled = true; rejectReady(new Error(IMPORT_MESH_BROKEN_MESSAGE)); }
638
+ } else if (importMeshState !== "requested") {
639
+ importMeshState = "requested";
640
+ importTessellateJobId = `tess-${++importTessellateJobSeq}`;
641
+ service.send({ type: "tessellate-imports", jobId: importTessellateJobId }, "occt");
642
+ }
643
+ break;
644
+ case "import-meshes":
645
+ // The OCCT worker answered tessellate-imports: prime the Manifold
646
+ // worker's import cache (worker-lifetime, per Task 8) with transferable
647
+ // mesh buffers, then kick the loop — the needs-import-mesh case above
648
+ // already settled the failed build (buildDone), so this kick is the
649
+ // other half of the same buildDone()/kick() pairing needs-occt does in
650
+ // one step, split across the two crossover replies here.
651
+ importMeshState = "primed";
652
+ importTessellateJobId = null; // the request this jobId correlated is answered — nothing to match against it anymore
653
+ service.send({ type: "prime-imports", meshes: data.meshes }, "manifold",
654
+ Object.values(data.meshes).flatMap((m) => [m.positions.buffer, m.indices.buffer]));
655
+ loop.kick();
656
+ break;
657
+ case "error": {
658
+ // A correlated tessellate-imports failure (malformed STEP, etc.) must
659
+ // not fall through the generic handling below: that request was
660
+ // dispatched directly via service.send (see needs-import-mesh above),
661
+ // never through loop.send, so loop.buildDone() has no matching pending
662
+ // count to release for it — calling it here would mis-credit an
663
+ // unrelated in-flight generate job. Reset the latch instead of leaving
664
+ // it stuck at "requested" forever (which would silently swallow every
665
+ // later needs-import-mesh via the `!== "requested"` guard above) — the
666
+ // next kick (param/view change) retries tessellation fresh.
667
+ if (data.jobId != null && data.jobId === importTessellateJobId) {
668
+ importMeshState = null;
669
+ importTessellateJobId = null;
670
+ ui.hideBusy();
671
+ refreshView();
672
+ const tessellateMessage = importTessellateFailedMessage(data.message);
673
+ ui.setStatus(`failed: ${tessellateMessage}`, true);
674
+ onBuild?.({ status: "error", error: tessellateMessage });
675
+ if (!readySettled) { readySettled = true; rejectReady(new Error(tessellateMessage)); }
676
+ break;
677
+ }
586
678
  loop.buildDone();
587
679
  ui.hideBusy();
588
680
  // refreshView FIRST: its all-current branch clears the status line,
@@ -592,6 +684,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
592
684
  onBuild?.({ status: "error", error: data.message });
593
685
  if (!readySettled) { readySettled = true; rejectReady(new Error(data.message)); }
594
686
  break;
687
+ }
595
688
  }
596
689
  }
597
690
 
@@ -15,7 +15,11 @@ const unionBounds = (list) => list.reduce(
15
15
  // solid facts (volume/genus/emptiness) and mesh facts (bbox/area/triangles), plus
16
16
  // the assembly overlap check plus pair gap distances (near misses are reported,
17
17
  // never folded into `ok`). All solid facts are read BEFORE assemblyOverlaps,
18
- // which frees the shared kernel's objects at its end.
18
+ // which frees the shared kernel's objects at its end. A sub-part that declares
19
+ // `reference: "<import name>"` also gets a `deviation` fact — the posed solid's
20
+ // symmetric-difference volume, volume delta %, and bbox-corner drift against
21
+ // that import — for the `ref*` gate metrics (verify-metrics.js); every other
22
+ // sub-part gets `deviation: null`.
19
23
  // → { part, view, measuredMinWall, subparts[], aggregate, overlaps[], gaps[],
20
24
  // nearMisses[], ok }
21
25
  export function measure(kernel, part, view = Object.keys(part.views)[0], params = {}, opts = {}) {
@@ -43,16 +47,38 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
43
47
  // Resolved lazily and only when asked for: without min-wall, a single-sub-part
44
48
  // view (no meshGaps) must still build no index at all.
45
49
  const mw = opts.minWall ? minWall(mesh, { bvh: cachedBVH(mesh, bvhCache) }) : null;
50
+ const vol = solid.volume();
51
+ // Deviation-from-reference: only for a sub-part that declares `reference:
52
+ // "<import name>"` (Task 12 — the gate that holds a parametric rebuild to
53
+ // its imported reference), and only when this kernel can import at all (a
54
+ // bare/third-party kernel may lack `import`). Read here, alongside every
55
+ // other solid fact, so it is captured before assemblyOverlaps/cleanup below
56
+ // frees the shared kernel's objects.
57
+ const refName = part.parts[name]?.reference;
58
+ let deviation = null;
59
+ if (refName && typeof kernel.import === "function") {
60
+ const ref = kernel.import(refName);
61
+ const refVol = ref.volume();
62
+ const rb = ref.boundingBox();
63
+ const inter = solid.intersect(ref).volume();
64
+ deviation = {
65
+ ref: refName,
66
+ xorVolume: vol + refVol - 2 * inter, // symmetric difference, one boolean
67
+ volumeDeltaPct: refVol > 1e-9 ? (100 * Math.abs(vol - refVol)) / refVol : null,
68
+ bboxDelta: [0, 1, 2].map((i) => Math.max(Math.abs(b.min[i] - rb.min[i]), Math.abs(b.max[i] - rb.max[i]))),
69
+ };
70
+ }
46
71
  return {
47
72
  name,
48
73
  bbox: size(b),
49
74
  bounds: { min: b.min, max: b.max },
50
75
  centerOfMass: meshCentroid(mesh.positions, mesh.indices),
51
- volume: solid.volume(),
76
+ volume: vol,
52
77
  surfaceArea: meshArea(mesh.positions, mesh.indices),
53
78
  triangleCount: mesh.triangles,
54
79
  watertight: typeof solid.isEmpty === "function" ? !solid.isEmpty() : null,
55
80
  holes: typeof solid.genus === "function" ? solid.genus() : null,
81
+ deviation,
56
82
  minWall: mw?.value ?? null,
57
83
  minWallAt: mw?.location ?? null,
58
84
  // Sampling accounting, so a report can tell a guaranteed minimum from an
@@ -46,6 +46,16 @@ export const SUBPART_METRICS = {
46
46
  ? `no reading from the ${sampled} of ${total} triangles sampled — not a clean bill of health; a thin spot may exist between samples`
47
47
  : `sampled ${sampled} of ${total} triangles — an upper bound; a thinner spot may exist between samples`;
48
48
  } },
49
+ // `s.deviation` (measure.js) exists only for a sub-part declaring `reference:
50
+ // "<import name>"`; on every other sub-part `extract` returns null, which
51
+ // `check()` already reports as status "skip" rather than a fail — the
52
+ // no-reference path needs nothing here.
53
+ refXorVolume: { kind: "gate", extract: (s) => s.deviation?.xorVolume ?? null,
54
+ hint: "the rebuild's symmetric difference vs its reference import is too large — compare the ghost overlay, then adjust the governing dimensions toward the measured reference" },
55
+ refVolumeDeltaPct: { kind: "gate", extract: (s) => s.deviation?.volumeDeltaPct ?? null,
56
+ hint: "rebuild volume differs from the reference import by more than the allowed percentage — a feature is missing, doubled, or mis-scaled vs the reference" },
57
+ refBboxDelta: { kind: "gate", extract: (s) => s.deviation?.bboxDelta ?? null,
58
+ hint: "the rebuild's bounding-box corners drift from the reference import — check overall dimensions and that the rebuild is aligned to the reference's coordinates" },
49
59
  };
50
60
  export const VIEW_METRICS = {
51
61
  bbox: { kind: "gate", extract: (r) => r.aggregate.bbox,
@@ -45,6 +45,7 @@ export function runWorker(part) {
45
45
  let epoch = 0; // bumped per incoming generate and per setPart
46
46
  const queue = []; // { data, part, epoch } — jobs run against the part current at arrival
47
47
  let pumping = false;
48
+ const importMeshes = new Map(); // name → {digest, positions, indices} — primed by the host for STEP-on-manifold
48
49
 
49
50
  // Manifold is cheap to boot — bring it up eagerly and signal readiness.
50
51
  if (backend === "manifold") {
@@ -98,7 +99,7 @@ export function runWorker(part) {
98
99
  const kernel = await kernelFor(job.data);
99
100
  // handle() declares each message's transferables (the big binary buffers).
100
101
  const post = (m, transfer = []) => postMessage(m, transfer);
101
- if (job.epoch === null) { await handle(kernel, job.part, job.data, post); continue; }
102
+ if (job.epoch === null) { await handle(kernel, job.part, job.data, post, { importMeshes }); continue; }
102
103
  const isStale = () => job.epoch !== epoch;
103
104
  // Post gate. The boundary check cannot catch a generate that goes stale during
104
105
  // its FINAL sub-part — there is no boundary after it — nor a single-sub-part
@@ -107,7 +108,7 @@ export function runWorker(part) {
107
108
  // contract simple: a `meshes` post is current as of the moment it is posted.
108
109
  const gated = (m, transfer = []) =>
109
110
  (m.type === "meshes" && isStale() ? post({ type: "superseded" }) : post(m, transfer));
110
- await handle(kernel, job.part, job.data, gated, { isStale });
111
+ await handle(kernel, job.part, job.data, gated, { isStale, importMeshes });
111
112
  } catch (err) {
112
113
  // Same shape jobs.js posts for a failed build, so hosts need no new branch.
113
114
  // Carry the job's jobId when it has one (capture/export are correlated by it):
@@ -123,6 +124,14 @@ export function runWorker(part) {
123
124
  }
124
125
 
125
126
  self.onmessage = (e) => {
127
+ // STEP-on-Manifold crossover: the host primes this worker's importMeshes before
128
+ // (or interleaved with) a generate, so a build's k.import(name) that needs
129
+ // pre-tessellated triangles finds them without touching the kernel — no queueing,
130
+ // no epoch bump, answered on the worker's own turn like the lint intercept below.
131
+ if (e.data?.type === "prime-imports") {
132
+ for (const [name, m] of Object.entries(e.data.meshes)) importMeshes.set(name, m);
133
+ return;
134
+ }
126
135
  // Lint is geometry-free by construction, so answer it before touching — or
127
136
  // booting — a kernel. handle() in jobs.js takes an already-booted kernel, and
128
137
  // the pump awaits that boot, so routing lint through the queue would drag in
@@ -0,0 +1,3 @@
1
+ import part from "./parts/import-demo.js";
2
+ import { runWorker } from "./framework/worker.js";
3
+ runWorker(part);
@@ -0,0 +1,86 @@
1
+ solid import-demo-scan
2
+ facet normal 0 0 -1
3
+ outer loop
4
+ vertex 0 0 0
5
+ vertex 20 14 0
6
+ vertex 20 0 0
7
+ endloop
8
+ endfacet
9
+ facet normal 0 0 -1
10
+ outer loop
11
+ vertex 0 0 0
12
+ vertex 0 14 0
13
+ vertex 20 14 0
14
+ endloop
15
+ endfacet
16
+ facet normal 0 0 1
17
+ outer loop
18
+ vertex 0 0 8
19
+ vertex 20 0 8
20
+ vertex 20 14 8
21
+ endloop
22
+ endfacet
23
+ facet normal 0 0 1
24
+ outer loop
25
+ vertex 0 0 8
26
+ vertex 20 14 8
27
+ vertex 0 14 8
28
+ endloop
29
+ endfacet
30
+ facet normal 0 -1 0
31
+ outer loop
32
+ vertex 0 0 0
33
+ vertex 20 0 0
34
+ vertex 20 0 8
35
+ endloop
36
+ endfacet
37
+ facet normal 0 -1 0
38
+ outer loop
39
+ vertex 0 0 0
40
+ vertex 20 0 8
41
+ vertex 0 0 8
42
+ endloop
43
+ endfacet
44
+ facet normal 0 1 0
45
+ outer loop
46
+ vertex 0 14 0
47
+ vertex 0 14 8
48
+ vertex 20 14 8
49
+ endloop
50
+ endfacet
51
+ facet normal 0 1 0
52
+ outer loop
53
+ vertex 0 14 0
54
+ vertex 20 14 8
55
+ vertex 20 14 0
56
+ endloop
57
+ endfacet
58
+ facet normal -1 0 0
59
+ outer loop
60
+ vertex 0 0 0
61
+ vertex 0 0 8
62
+ vertex 0 14 8
63
+ endloop
64
+ endfacet
65
+ facet normal -1 0 0
66
+ outer loop
67
+ vertex 0 0 0
68
+ vertex 0 14 8
69
+ vertex 0 14 0
70
+ endloop
71
+ endfacet
72
+ facet normal 1 0 0
73
+ outer loop
74
+ vertex 20 0 0
75
+ vertex 20 14 0
76
+ vertex 20 14 8
77
+ endloop
78
+ endfacet
79
+ facet normal 1 0 0
80
+ outer loop
81
+ vertex 20 0 0
82
+ vertex 20 14 8
83
+ vertex 20 0 8
84
+ endloop
85
+ endfacet
86
+ endsolid import-demo-scan
@@ -0,0 +1,134 @@
1
+ // Reference part for docs/AUTHORING-PARTS.md's "Importing geometry" section —
2
+ // the worked example for BOTH import uses in one part:
3
+ // • reference — `ref` is a translucent ghost of the imported scan (never
4
+ // exported); `body` is a parametric rebuild of the same block, bound to
5
+ // the scan via `reference: "scan"` and held to it by the three ref*
6
+ // deviation metrics in `verify.expect.body`.
7
+ // • component — `mount` uses the import as a real boolean tool: a plate
8
+ // with a through-socket cut to the scan's own shape (scaled up by `fit`
9
+ // for clearance), exported like any other sub-part.
10
+ // `src/parts/assets/import-demo-scan.stl` is a small hand-written ascii STL —
11
+ // a 20×14×8 mm block, origin-cornered, outward-wound. `body`'s defaults
12
+ // reproduce those dimensions exactly, so the deviation gate passes with real
13
+ // margin. Open /import-demo.html after `npm run dev`.
14
+ export default {
15
+ meta: { title: "Import demo", units: "mm", background: 0x15181d },
16
+ imports: {
17
+ scan: new URL("./assets/import-demo-scan.stl", import.meta.url),
18
+ },
19
+ parameters: [
20
+ {
21
+ id: "scan",
22
+ title: "Scan",
23
+ description: "Dimensions of the parametric rebuild (`body`). Defaults match the imported reference block exactly — drag one and watch the deviation gate in `partforge measure` react.",
24
+ advanced: [
25
+ { key: "scanW", label: "Width", unit: "mm", min: 10, max: 40, step: 0.5,
26
+ description: "Rebuild width (X). Matches the scan's width at the default." },
27
+ { key: "scanD", label: "Depth", unit: "mm", min: 10, max: 40, step: 0.5,
28
+ description: "Rebuild depth (Y). Matches the scan's depth at the default." },
29
+ { key: "scanH", label: "Height", unit: "mm", min: 4, max: 20, step: 0.5,
30
+ description: "Rebuild height (Z). Matches the scan's height at the default." },
31
+ ],
32
+ },
33
+ {
34
+ id: "mount",
35
+ title: "Mount",
36
+ description: "A plate with a through-socket cut to the scan's own shape — the import used as a real boolean component, not just a reference.",
37
+ advanced: [
38
+ { key: "fit", label: "Socket clearance", unit: "×", min: 1, max: 1.2, step: 0.01,
39
+ description: "Uniform scale applied to the imported scan before it's used as the cutting tool, so the block seats with a slip fit." },
40
+ { key: "margin", label: "Plate margin", unit: "mm", min: 1, max: 10, step: 0.5,
41
+ description: "Solid plate border kept around the socket on every side." },
42
+ // The socket is the scan's own (fixed, ~8mm-tall) imported geometry scaled
43
+ // by `fit` and overcut 1mm past the plate's bottom face — see `mount.build`
44
+ // below. It only stays a genuine through-hole (not a blind pocket) while
45
+ // plateH < scanH*fit - 1; at the worst-case corner (fit at its slider
46
+ // minimum, 1) that's plateH < 7. max is capped at 6.5, half a millimetre
47
+ // inside that bound, so every reachable (plateH, fit) combination — not
48
+ // just the defaults — keeps `mount: { holes: 1 }` true.
49
+ { key: "plateH", label: "Plate thickness", unit: "mm", min: 2, max: 6.5, step: 0.5,
50
+ description: "Mount plate thickness. The socket cuts all the way through it." },
51
+ { key: "gap", label: "Gap from rebuild", unit: "mm", min: 5, max: 30, step: 1,
52
+ description: "Presentation-only spacing between `body` and `mount` in the assembly view, so the two never overlap." },
53
+ ],
54
+ },
55
+ ],
56
+ defaults: { scanW: 20, scanD: 14, scanH: 8, fit: 1.05, margin: 3, plateH: 4, gap: 10 },
57
+ // mountOffsetX: how far along X the mount plate sits from the rebuild, so
58
+ // the two solids never interpenetrate regardless of the current dimensions.
59
+ derive: (p) => ({ mountOffsetX: p.scanW + p.gap }),
60
+ parts: {
61
+ // Ghost overlay of the raw import — ref* deviation is computed against
62
+ // this by name (`reference: "scan"` on `body`, below) regardless of which
63
+ // view is active, so `ref` only needs to appear in the "reference" view,
64
+ // where it's shown translucent over `body` for visual alignment checking.
65
+ // It is deliberately absent from "assembly": measure()'s `ok` requires
66
+ // zero sub-part overlaps in the measured view, and a ghost coincident
67
+ // with its rebuild always overlaps by design.
68
+ ref: {
69
+ label: "Reference (ghost)",
70
+ views: ["reference"],
71
+ exportable: false,
72
+ display: { opacity: 0.3 },
73
+ build: (k) => k.import("scan"),
74
+ },
75
+ // Parametric rebuild of the scanned block. `reference: "scan"` tells
76
+ // measure() to compute deviation facts (xorVolume / volumeDeltaPct /
77
+ // bboxDelta) against the import; verify.expect.body below gates on them.
78
+ // Shown in both views: alone (fitted with `mount`) in "assembly", and
79
+ // against the ghost in "reference".
80
+ body: {
81
+ label: "Rebuild",
82
+ views: ["assembly", "reference"],
83
+ export: { name: "body" },
84
+ reference: "scan",
85
+ build: (k, p) => k.box({ min: [0, 0, 0], max: [p.scanW, p.scanD, p.scanH] }),
86
+ },
87
+ // The import used as a real component: a plate with a through-socket cut
88
+ // to the (clearance-scaled) scan shape. Offset along X so it never
89
+ // overlaps `body` in the assembly view.
90
+ mount: {
91
+ label: "Mount plate",
92
+ views: ["assembly"],
93
+ export: { name: "mount" },
94
+ build: (k, p, d) => {
95
+ const plate = k.box({
96
+ min: [d.mountOffsetX - p.margin, -p.margin, -p.plateH],
97
+ max: [d.mountOffsetX + p.scanW * p.fit + p.margin, p.scanD * p.fit + p.margin, 0],
98
+ });
99
+ const socket = k.import("scan")
100
+ .scale(p.fit)
101
+ .translate([d.mountOffsetX, 0, -p.plateH - 1]); // overcut past both plate faces
102
+ return plate.cut(socket);
103
+ },
104
+ },
105
+ },
106
+ // "assembly" is first so `measure`/`verify`/`render` (which default to the
107
+ // first view key) see only the two real, non-overlapping parts — `mount`'s
108
+ // socket never touches `body` (see `mountOffsetX`). "reference" is the
109
+ // ghost-overlay view, browsed by hand or with an explicit view argument.
110
+ views: { assembly: { label: "Assembly" }, reference: { label: "Reference overlay" } },
111
+ verify: {
112
+ process: "fdm-pla",
113
+ expect: {
114
+ body: {
115
+ holes: 0,
116
+ watertight: true,
117
+ bbox: "<=[30,20,15]",
118
+ // Deviation gate: at the defaults, body reproduces the scan's exact
119
+ // box dimensions, so all three read near-zero — thresholds below
120
+ // leave real margin rather than sitting on the exact values.
121
+ refXorVolume: "<=5mm3",
122
+ refVolumeDeltaPct: "<=1",
123
+ refBboxDelta: "<=[0.2,0.2,0.2]",
124
+ },
125
+ mount: { holes: 1, watertight: true, bbox: "<=[40,25,10]" },
126
+ // Checked against the default ("assembly") view only — `body` and
127
+ // `mount` are real, non-overlapping parts by construction (see
128
+ // `mountOffsetX`). The "reference" view's ghost is deliberately
129
+ // coincident with `body` (that's the point of the overlay) and would
130
+ // always read overlaps > 0, which is why it isn't the default view.
131
+ _view: { overlaps: 0 },
132
+ },
133
+ },
134
+ };
@@ -0,0 +1,19 @@
1
+ // Node-side source mapping for part asset declarations (fonts + imports):
2
+ // framework resolvers use global fetch, which cannot read file: URLs in Node,
3
+ // so map those to bytes here before handing the decl down. Everything else
4
+ // (http(s) strings, bytes, thunks) passes through untouched.
5
+ import { readFileSync } from "node:fs";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ export function nodeAssetSources(decl) {
9
+ if (!decl) return decl;
10
+ const out = {};
11
+ for (const [name, src] of Object.entries(decl)) {
12
+ const u = src instanceof URL ? src : typeof src === "string" && src.startsWith("file:") ? new URL(src) : null;
13
+ if (u?.protocol === "file:") {
14
+ const b = readFileSync(fileURLToPath(u));
15
+ out[name] = b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength);
16
+ } else out[name] = src;
17
+ }
18
+ return out;
19
+ }
@@ -4,12 +4,24 @@
4
4
  import Module from "manifold-3d";
5
5
  import { createManifoldKernel } from "../framework/geometry/manifold-backend.js";
6
6
  import { resolveFonts } from "../framework/fonts.js";
7
+ import { ensureImports } from "../framework/imports.js";
8
+ import { nodeAssetSources } from "./assets.js";
9
+ import { tessellateStepAssets } from "./step-mesh.js";
7
10
 
8
- export async function bootManifoldKernel({ quality = "preview", fonts } = {}) {
11
+ export async function bootManifoldKernel({ quality = "preview", fonts, imports, importMeshes } = {}) {
9
12
  const wasm = await Module();
10
13
  wasm.setup();
11
14
  const kernel = createManifoldKernel(wasm, { quality });
12
15
  if (fonts) { const opentype = (await import("opentype.js")).default;
13
- for (const [name, buf] of await resolveFonts(fonts)) kernel._fonts.set(name, opentype.parse(buf)); }
16
+ for (const [name, buf] of await resolveFonts(nodeAssetSources(fonts))) kernel._fonts.set(name, opentype.parse(buf)); }
17
+ if (imports) {
18
+ const decl = nodeAssetSources(imports);
19
+ const { resolveImports } = await import("../framework/imports.js");
20
+ const resolved = await resolveImports(decl);
21
+ const stepEntries = [...resolved].filter(([, a]) => a.format === "step")
22
+ .map(([name, a]) => ({ name, bytes: a.bytes, digest: a.digest }));
23
+ const meshes = importMeshes ?? (stepEntries.length ? await tessellateStepAssets(stepEntries) : null);
24
+ await ensureImports(kernel, decl, meshes);
25
+ }
14
26
  return kernel;
15
27
  }
@@ -6,8 +6,10 @@ import path from "path";
6
6
  import fs from "fs";
7
7
  import { createOcctKernel } from "../framework/geometry/occt-backend.js";
8
8
  import { resolveFonts } from "../framework/fonts.js";
9
+ import { ensureImports } from "../framework/imports.js";
10
+ import { nodeAssetSources } from "./assets.js";
9
11
 
10
- export async function bootOcctKernel({ fonts } = {}) {
12
+ export async function bootOcctKernel({ fonts, imports, importMeshes } = {}) {
11
13
  const require = createRequire(import.meta.url);
12
14
  globalThis.require = globalThis.require ?? require;
13
15
  globalThis.__dirname = globalThis.__dirname ?? path.dirname(fileURLToPath(import.meta.url));
@@ -17,6 +19,7 @@ export async function bootOcctKernel({ fonts } = {}) {
17
19
  replicad.setOC(OC);
18
20
  const kernel = createOcctKernel(replicad);
19
21
  if (fonts) { const opentype = (await import("opentype.js")).default;
20
- for (const [name, buf] of await resolveFonts(fonts)) kernel._fonts.set(name, opentype.parse(buf)); }
22
+ for (const [name, buf] of await resolveFonts(nodeAssetSources(fonts))) kernel._fonts.set(name, opentype.parse(buf)); }
23
+ if (imports) await ensureImports(kernel, nodeAssetSources(imports), importMeshes ?? null);
21
24
  return kernel;
22
25
  }
@@ -0,0 +1,15 @@
1
+ // src/testing/step-mesh-thread.js — runs INSIDE the worker_thread only.
2
+ import { parentPort, workerData } from "node:worker_threads";
3
+ import { bootOcctKernel } from "./occt.js";
4
+
5
+ const kernel = await bootOcctKernel();
6
+ const out = [];
7
+ const transfer = [];
8
+ for (const { name, bytes, digest } of workerData) {
9
+ await kernel._registerImport({ name, digest, step: bytes });
10
+ const { positions, indices } = kernel.import(name).toIndexedMesh({ quality: "print" });
11
+ out.push({ name, digest, positions, indices });
12
+ transfer.push(positions.buffer, indices.buffer);
13
+ }
14
+ parentPort.postMessage(out, transfer);
15
+ process.exit(0);
@@ -0,0 +1,17 @@
1
+ // STEP → triangle mesh for the Node crossover (Manifold part importing STEP).
2
+ // OCCT boots in a worker_thread — a separate isolate is a separate WASM world,
3
+ // so the "never both kernels in one process" invariant holds by construction.
4
+ import { Worker } from "node:worker_threads";
5
+
6
+ export function tessellateStepAssets(entries) {
7
+ return new Promise((resolve, reject) => {
8
+ const w = new Worker(new URL("./step-mesh-thread.js", import.meta.url),
9
+ { workerData: entries.map(({ name, bytes, digest }) => ({ name, bytes, digest })) });
10
+ w.once("message", (out) => {
11
+ resolve(new Map(out.map((m) => [m.name, { digest: m.digest, positions: m.positions, indices: m.indices }])));
12
+ w.terminate();
13
+ });
14
+ w.once("error", reject);
15
+ w.once("exit", (code) => { if (code !== 0) reject(new Error(`step tessellation thread exited ${code}`)); });
16
+ });
17
+ }
package/types/kernel.d.ts CHANGED
@@ -477,6 +477,12 @@ export interface GeometryKernel {
477
477
  hullChain(inputs: HullInput[]): Shape2D;
478
478
  /** STEP bytes — OCCT only (Manifold throws `KernelCapabilityError`). */
479
479
  toSTEP(named: Array<{ name: string; solid: Solid }>): Promise<ArrayBuffer>;
480
+ /**
481
+ * Imported geometry declared in the part's `imports` field, registered
482
+ * pre-build by the framework via the underscore-prefixed `_registerImport`
483
+ * side-channel (not a part author's calling surface).
484
+ */
485
+ import(name: string): Solid;
480
486
 
481
487
  // Backend-optional: the sub-part cache brackets and WASM lifetime hooks. Every
482
488
  // framework caller reaches these through `?.`, so a third-party backend may
package/types/part.d.ts CHANGED
@@ -300,6 +300,14 @@ export interface SubPartDefinition<P = ResolvedParams, D = Derived> {
300
300
  enabled?: (p: P) => unknown;
301
301
  /** `false` = reference/preview-only: shown in the viewer, never exported. */
302
302
  exportable?: boolean;
303
+ /**
304
+ * Name of a declared `imports` entry this sub-part is held to. When set,
305
+ * `measure()` computes a `deviation` fact (symmetric-difference volume,
306
+ * volume delta %, bbox-corner drift) against that import's posed solid, and
307
+ * `verify.expect.<subpart>` may use the `refXorVolume` / `refVolumeDeltaPct`
308
+ * / `refBboxDelta` gate metrics.
309
+ */
310
+ reference?: string;
303
311
  /** Viewer-only override — `color` is `0xRRGGBB`, `opacity` is 0..1. */
304
312
  display?: { color?: number; opacity?: number };
305
313
  /** Filename / object name on export; defaults to the key. */
@@ -362,6 +370,12 @@ export interface SubPartExpectations {
362
370
  boundsMin?: Expectation;
363
371
  boundsMax?: Expectation;
364
372
  minWall?: Expectation;
373
+ /** Symmetric-difference volume vs. the sub-part's declared `reference` import. */
374
+ refXorVolume?: Expectation;
375
+ /** Percent volume delta vs. the sub-part's declared `reference` import. */
376
+ refVolumeDeltaPct?: Expectation;
377
+ /** Bounding-box corner drift `[dx, dy, dz]` vs. the sub-part's declared `reference` import. */
378
+ refBboxDelta?: Expectation;
365
379
  }
366
380
 
367
381
  /**