partforge 0.62.1 → 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 (39) hide show
  1. package/bin/cli.js +2 -3
  2. package/docs/AUTHORING-PARTS.md +158 -0
  3. package/docs/ERROR-PATTERNS.md +35 -1
  4. package/docs/KERNEL-CONTRACT.md +38 -2
  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 +4 -3
  13. package/src/framework/geometry/manifold-backend.js +50 -0
  14. package/src/framework/geometry/mesh-repair.js +87 -0
  15. package/src/framework/geometry/mesh-roundall.js +83 -0
  16. package/src/framework/geometry/occt-backend.js +42 -1
  17. package/src/framework/geometry/occt-roundall.js +94 -0
  18. package/src/framework/geometry/op-options.js +2 -0
  19. package/src/framework/geometry/stl-parse.js +45 -0
  20. package/src/framework/geometry/threemf-parse.js +87 -0
  21. package/src/framework/geometry-service.js +3 -1
  22. package/src/framework/imports.js +84 -0
  23. package/src/framework/jobs.js +20 -1
  24. package/src/framework/lint/index.js +2 -1
  25. package/src/framework/lint/rules-imports.js +115 -0
  26. package/src/framework/mount.js +94 -1
  27. package/src/framework/oracle/measure.js +28 -2
  28. package/src/framework/verify-metrics.js +10 -0
  29. package/src/framework/worker.js +11 -2
  30. package/src/import-demo-worker.js +3 -0
  31. package/src/parts/assets/import-demo-scan.stl +86 -0
  32. package/src/parts/import-demo.js +134 -0
  33. package/src/testing/assets.js +19 -0
  34. package/src/testing/manifold.js +14 -2
  35. package/src/testing/occt.js +5 -2
  36. package/src/testing/step-mesh-thread.js +15 -0
  37. package/src/testing/step-mesh.js +17 -0
  38. package/types/kernel.d.ts +12 -0
  39. package/types/part.d.ts +14 -0
@@ -0,0 +1,84 @@
1
+ // Resolve a part's declared `imports` ({ name: source }) to bytes + a SHA-256
2
+ // content digest + a detected format, before the synchronous build — the
3
+ // import-asset sibling of fonts.js: same source grammar and identity-
4
+ // memoization rule (import sources are content-stable for a session), built on
5
+ // the shared resolution core in asset-resolve.js. DOM-free and node:-free;
6
+ // crypto.subtle exists in workers and Node.
7
+ import { makeAssetResolver, resolveDecl } from "./asset-resolve.js";
8
+ import { parseStl } from "./geometry/stl-parse.js";
9
+ import { parse3MF } from "./geometry/threemf-parse.js";
10
+
11
+ const EXT = { step: "step", stp: "step", stl: "stl", "3mf": "3mf" };
12
+
13
+ export function detectFormat(source, bytes) {
14
+ const path = source instanceof URL ? source.pathname : typeof source === "string" ? source.split("?")[0] : null;
15
+ const ext = path?.match(/\.([A-Za-z0-9]+)$/)?.[1]?.toLowerCase();
16
+ if (ext && EXT[ext]) return EXT[ext];
17
+ const u8 = bytes instanceof ArrayBuffer ? new Uint8Array(bytes) : bytes;
18
+ if (u8 && u8.length > 0) {
19
+ const head = String.fromCharCode(...u8.slice(0, 64));
20
+ if (head.startsWith("ISO-10303-21")) return "step";
21
+ if (u8[0] === 0x50 && u8[1] === 0x4b) return "3mf"; // zip signature
22
+ return "stl"; // ascii "solid …" and binary STL both land here
23
+ }
24
+ throw new Error(`unrecognized import format${path ? ` for "${path}"` : ""} — use a .step/.stl/.3mf extension or non-empty bytes`);
25
+ }
26
+
27
+ async function sha256Hex(bytes) {
28
+ const d = await globalThis.crypto.subtle.digest("SHA-256", bytes);
29
+ return [...new Uint8Array(d)].map((b) => b.toString(16).padStart(2, "0")).join("");
30
+ }
31
+
32
+ const cache = new Map(); // source → Promise<{bytes, digest, format}>
33
+ const resolveOne = makeAssetResolver(
34
+ cache,
35
+ async (bytes, v, source) => {
36
+ const format = detectFormat(source instanceof URL || typeof source === "string" ? source : v, bytes);
37
+ return { bytes, digest: await sha256Hex(bytes), format };
38
+ },
39
+ "resolveImports: an import source must be bytes, a URL, or a thunk returning one",
40
+ );
41
+
42
+ export async function resolveImports(importsDecl) {
43
+ return resolveDecl(importsDecl, resolveOne);
44
+ }
45
+
46
+ // Register a part's imports on a booted kernel (idempotent per digest). The
47
+ // framework calls this in the async phase before every job's synchronous
48
+ // build — worker (jobs.js) and Node boots (src/testing/) alike.
49
+ //
50
+ // Registration is total; errors are lazy (see the spec section of that name):
51
+ // every declared import registers on whichever kernel runs the job, and a
52
+ // format this kernel cannot use registers as an {error} entry that k.import
53
+ // throws at call time. That is what keeps a mixed-format declaration from
54
+ // poisoning unrelated jobs — the OCCT worker's tessellate-imports service
55
+ // job, or a per-backend generate group that never touches the unusable
56
+ // import. STEP on a mesh backend needs pre-tessellated triangles in
57
+ // `importMeshes`; absent, the entry carries code NEEDS_IMPORT_MESH and the
58
+ // first build to call k.import on it makes the host arrange tessellation
59
+ // (mount's needs-import-mesh flow in the browser, worker_threads in Node).
60
+ export async function ensureImports(kernel, importsDecl, importMeshes = null) {
61
+ if (!importsDecl || typeof kernel._registerImport !== "function") return;
62
+ const resolved = await resolveImports(importsDecl);
63
+ for (const [name, a] of resolved) {
64
+ if (kernel._importDigest?.(name) === a.digest) continue; // error entries answer undefined → always retried
65
+ if (a.format === "step") {
66
+ if (kernel._acceptsStep) { await kernel._registerImport({ name, digest: a.digest, step: a.bytes }); continue; }
67
+ const m = importMeshes?.get?.(name);
68
+ if (m && m.digest === a.digest) {
69
+ await kernel._registerImport({ name, digest: a.digest, positions: m.positions, indices: m.indices });
70
+ } else {
71
+ const e = new Error(`import "${name}": STEP needs tessellation for the Manifold backend`);
72
+ e.code = "NEEDS_IMPORT_MESH";
73
+ await kernel._registerImport({ name, digest: a.digest, error: e });
74
+ }
75
+ } else if (kernel._acceptsMesh) {
76
+ const { positions, indices } = a.format === "3mf" ? parse3MF(a.bytes) : parseStl(a.bytes);
77
+ await kernel._registerImport({ name, digest: a.digest, positions, indices });
78
+ } else {
79
+ // No parse for a kernel that can't take the mesh — error entry directly.
80
+ await kernel._registerImport({ name, digest: a.digest, error: new Error(
81
+ `import "${name}": STL/3MF imports need the Manifold backend — this build routes to OCCT (shell or meta.backend, or a fillet/chamfer rerouted for an unsupported edge class); use the mesh import from a Manifold-routed build`) });
82
+ }
83
+ }
84
+ }
@@ -5,6 +5,7 @@
5
5
  import { meshTo3MF } from "./geometry/threemf.js";
6
6
  import { exportablePartNames } from "./export-select.js";
7
7
  import { resolveFonts } from "./fonts.js";
8
+ import { ensureImports, resolveImports } from "./imports.js";
8
9
  import { safeName } from "./safe-name.js";
9
10
  import { exportSubParts, resolveParams, buildPosed } from "./part-model.js";
10
11
  import { measure } from "./oracle/measure.js";
@@ -105,6 +106,11 @@ export async function handle(kernel, part, msg, post, opts = {}) {
105
106
  const bufs = await resolveFonts(part.fonts);
106
107
  for (const [name, buf] of bufs) if (!kernel._fonts.has(name)) kernel._fonts.set(name, opentype.parse(buf));
107
108
  }
109
+ // Register this part's declared imports on the kernel running this job — the
110
+ // import-asset sibling of the fonts preload above. See ensureImports for the
111
+ // lazy-error policy that keeps a STEP import inert until a build actually
112
+ // calls k.import on it.
113
+ if (part.imports) await ensureImports(kernel, part.imports, opts.importMeshes ?? null);
108
114
  // Inside the try so a throwing derive posts an error the UI can show,
109
115
  // instead of killing the worker turn silently (an endless spinner).
110
116
  const { p, d } = resolveParams(part, msg.params);
@@ -196,6 +202,18 @@ export async function handle(kernel, part, msg, post, opts = {}) {
196
202
  onProgress("writing 3MF file");
197
203
  const data = meshTo3MF(meshes);
198
204
  post({ type: "download", data, filename: `${fileBase}.3mf`, mime: "model/3mf", jobId: msg.jobId }, [bufferOf(data)]);
205
+ } else if (msg.type === "tessellate-imports") {
206
+ // OCCT-worker service job for the STEP-on-Manifold crossover: answer with
207
+ // print-quality triangle meshes for every STEP import, transferable.
208
+ const resolved = await resolveImports(part.imports ?? {});
209
+ const meshes = {};
210
+ for (const [name, a] of resolved) {
211
+ if (a.format !== "step") continue;
212
+ const { positions, indices } = kernel.import(name).toIndexedMesh({ quality: "print" });
213
+ meshes[name] = { digest: a.digest, positions, indices };
214
+ }
215
+ post({ type: "import-meshes", jobId: msg.jobId, meshes },
216
+ Object.values(meshes).flatMap((m) => [m.positions.buffer, m.indices.buffer]));
199
217
  } else if (msg.type === "inspect") {
200
218
  // Full geometric oracle for the current view: solid facts (volume/genus/
201
219
  // watertight), mesh facts, overlaps, and gap distances, plus the part's
@@ -246,7 +264,8 @@ export async function handle(kernel, part, msg, post, opts = {}) {
246
264
  } catch (err) {
247
265
  // `subparts` (generate jobs only) tells the reroute policy which sub-parts the
248
266
  // failed job covered, so only those latch to OCCT — not the whole part.
249
- if (err?.code === "NEEDS_OCCT") post({ type: "needs-occt", jobId: msg.jobId, subparts: msg.subparts });
267
+ if (err?.code === "NEEDS_IMPORT_MESH") post({ type: "needs-import-mesh", jobId: msg.jobId, subparts: msg.subparts });
268
+ else if (err?.code === "NEEDS_OCCT") post({ type: "needs-occt", jobId: msg.jobId, subparts: msg.subparts });
250
269
  else post({ type: "error", message: String(err?.message || err), jobId: msg.jobId });
251
270
  } finally {
252
271
  kernel.cleanup?.();
@@ -15,8 +15,9 @@ import { BUILD_RULES } from "./rules-build.js";
15
15
  import { VERIFY_RULES, resolveExpect } from "./rules-verify.js";
16
16
  import { ANIMATION_RULES } from "./rules-animations.js";
17
17
  import { PLACE_RULES } from "./rules-place.js";
18
+ import { IMPORT_RULES } from "./rules-imports.js";
18
19
 
19
- export const RULES = [...SHAPE_RULES, ...SCHEMA_RULES, ...BUILD_RULES, ...VERIFY_RULES, ...ANIMATION_RULES, ...PLACE_RULES];
20
+ export const RULES = [...SHAPE_RULES, ...SCHEMA_RULES, ...BUILD_RULES, ...VERIFY_RULES, ...ANIMATION_RULES, ...PLACE_RULES, ...IMPORT_RULES];
20
21
 
21
22
  // Every rule runs inside a guard. lintPart is called on a user-facing hosted path
22
23
  // (partforge-cloud's sandbox), and a linter that takes down the preview it exists to
@@ -0,0 +1,115 @@
1
+ // Group 5 — geometry-import well-formedness. Each condition here is a static
2
+ // early-catch for something that otherwise throws only later: `k.import()` on
3
+ // an undeclared name throws mid-build (kernel.js), a declared mesh import
4
+ // under an OCCT-routed part throws lazily from the `{error}` registration
5
+ // entry the first time `k.import` reads it (imports.js), an unknown
6
+ // `reference` breaks the deviation gate silently (measure.js just reports no
7
+ // deviation), and a `ref*` verify metric with no `reference` always reports
8
+ // status "skip" (verify-metrics.js) rather than the gate the author intended.
9
+ //
10
+ // `import-mesh-on-occt` is deliberately conservative: only extension-detectable
11
+ // sources (a `URL` or a string path) are checked. Bytes and thunks carry no
12
+ // format information without actually resolving them, which lint — geometry-
13
+ // free and synchronous — cannot do; those cases (and any per-sub-part routing
14
+ // split a whole-part `detectBackend` can't see) still fail correctly at build
15
+ // time via imports.js's lazy `{error}` entry. This rule exists to catch the
16
+ // common case early, not to replace that runtime authority.
17
+ import { err, warn } from "./finding.js";
18
+ import { detectBackend } from "../backend-select.js";
19
+ import { SUBPART_METRICS } from "../verify-metrics.js";
20
+
21
+ const MESH_EXT = /\.(stl|3mf)$/i;
22
+
23
+ const REF_METRICS = new Set(Object.keys(SUBPART_METRICS).filter((k) => k.startsWith("ref")));
24
+
25
+ const declaredImports = (part) => Object.keys(part?.imports ?? {});
26
+
27
+ // Only URL/string sources carry a statically-visible extension; a thunk or raw
28
+ // bytes source is skipped (see file header).
29
+ const meshDeclared = (part) => Object.entries(part?.imports ?? {}).filter(([, src]) => {
30
+ const path = src instanceof URL ? src.pathname : typeof src === "string" ? src.split("?")[0] : null;
31
+ return path != null && MESH_EXT.test(path);
32
+ });
33
+
34
+ export const IMPORT_RULES = [
35
+ {
36
+ id: "import-unknown-name",
37
+ run: ({ part, probe }) => {
38
+ const known = new Set(declaredImports(part));
39
+ const seen = new Set();
40
+ const out = [];
41
+ for (const call of probe().calls) {
42
+ if (call.scope !== "kernel" || call.op !== "import") continue;
43
+ let name;
44
+ try { name = JSON.parse(call.args[0]); } catch { name = null; }
45
+ if (typeof name !== "string" || known.has(name) || seen.has(name)) continue;
46
+ seen.add(name);
47
+ out.push(err("import-unknown-name",
48
+ `build calls k.import with name "${name}", which the part's imports field does not declare: ${[...known].join(", ") || "(nothing)"}`,
49
+ "Declare the file under imports: { name: source }, or fix the name to match an existing entry.",
50
+ "imports"));
51
+ }
52
+ return out;
53
+ },
54
+ },
55
+ {
56
+ id: "import-mesh-on-occt",
57
+ run: ({ part, p }) => {
58
+ const mesh = meshDeclared(part);
59
+ if (mesh.length === 0 || detectBackend(part, p) !== "occt") return [];
60
+ const cause = part?.meta?.backend === "occt"
61
+ ? "meta.backend forces OCCT"
62
+ : "a shell op routes this part to OCCT"; // post-contract-v3, fillet/chamfer no longer probe-route
63
+ return mesh.map(([name]) => err("import-mesh-on-occt",
64
+ `import "${name}" is a mesh (STL/3MF) but ${cause} — mesh imports need the Manifold backend`,
65
+ // detectBackend (this rule's own gate) is the whole-part max over every
66
+ // sub-part, same as lint/measure/single-worker export — so putting the
67
+ // import on a nominally Manifold sub-part does not clear this error
68
+ // while any OTHER sub-part routes to OCCT; that coexistence only works
69
+ // for the browser preview's per-sub-part routing. Say so explicitly —
70
+ // see ERROR-PATTERNS.md#import-mesh-on-occt for the long version.
71
+ "Split the mesh-importing sub-part into its own separate part (per-sub-part Manifold/OCCT coexistence is preview-only), replace it with a STEP source, or drop the CAD-only op / meta.backend pin so the whole part routes to Manifold.",
72
+ "imports"));
73
+ },
74
+ },
75
+ {
76
+ id: "reference-unknown",
77
+ run: ({ part }) => {
78
+ const known = new Set(declaredImports(part));
79
+ return Object.entries(part?.parts ?? {})
80
+ .filter(([, sp]) => sp?.reference && !known.has(sp.reference))
81
+ .map(([name, sp]) => err("reference-unknown",
82
+ `sub-part "${name}" declares reference: "${sp.reference}" but no such import exists`,
83
+ "reference must name a key of the part's imports field.",
84
+ `parts.${name}.reference`));
85
+ },
86
+ },
87
+ {
88
+ id: "ref-metric-without-reference",
89
+ // Deliberately resolves function-form `verify.expect` via `resolveExpectOnce()`
90
+ // (same memoized, try/caught call the Group 4 verify rules share) rather than
91
+ // skipping it — the plan's anchor guarded with a bare `typeof expect ===
92
+ // "object"` check on the assumption a function-form `expect` "can't be
93
+ // inspected statically", but `resolveExpectOnce()` already does exactly that
94
+ // inspection safely (a throw is reported separately by `verify-expect-throws`
95
+ // and short-circuits here via `!expect`). A ref* metric surfaced from a
96
+ // function-form expect with no `reference` is just as real a finding as one
97
+ // from the static-object form, so skipping it would be a false negative, not
98
+ // caution.
99
+ run: ({ part, resolveExpectOnce }) => {
100
+ const { expect } = resolveExpectOnce();
101
+ if (!expect || typeof expect !== "object") return [];
102
+ const names = new Set(Object.keys(part?.parts ?? {}));
103
+ return Object.entries(expect)
104
+ // `sub` must name a real sub-part — a typo'd name is already reported by
105
+ // `verify-unknown-subpart`; skip it here so the two rules don't double-report
106
+ // the same typo under two different ids.
107
+ .filter(([sub, metrics]) => sub !== "_view" && names.has(sub) && metrics && typeof metrics === "object" &&
108
+ Object.keys(metrics).some((k) => REF_METRICS.has(k)) && !part?.parts?.[sub]?.reference)
109
+ .map(([sub]) => warn("ref-metric-without-reference",
110
+ `verify.expect.${sub} uses a ref* metric but sub-part "${sub}" declares no reference`,
111
+ `Add reference: "<import name>" to sub-part "${sub}", or the ref* checks always report status "skip".`,
112
+ `verify.expect.${sub}`));
113
+ },
114
+ },
115
+ ];
@@ -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