partforge 0.85.4 → 0.86.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/bin/cli.js CHANGED
@@ -26,25 +26,7 @@ import { lintPart } from "../src/lint.js";
26
26
 
27
27
  const die = (msg) => { console.error(msg); process.exit(1); };
28
28
 
29
- // The semantic mesh oracle (`describe`) lives in a separate, closed package — this
30
- // CLI names it only here, and resolves it at CALL time so every other verb works
31
- // without it installed. PARTFORGE_ORACLE overrides the specifier (a path or module
32
- // name) — how the oracle package's own repo points this CLI at its working tree.
33
- const ORACLE_PACKAGE = "@pixiteapps/partforge-oracle";
34
- async function loadOracle() {
35
- const spec = process.env.PARTFORGE_ORACLE ?? ORACLE_PACKAGE;
36
- const target = spec.startsWith(".") || spec.startsWith("/")
37
- ? pathToFileURL(resolve(process.cwd(), spec)).href
38
- : spec;
39
- try {
40
- return await import(target);
41
- } catch (e) {
42
- die(`describe needs the mesh-oracle package (${ORACLE_PACKAGE}), which is not installed.\n` +
43
- `Install it from the private registry (or set PARTFORGE_ORACLE to a local path) and retry.\n` +
44
- `(import of ${spec} failed: ${e?.message ?? e})`);
45
- }
46
- }
47
- const USAGE = "usage: partforge <lint|measure|render|describe|pick-serve|pick> …";
29
+ const USAGE = "usage: partforge <lint|measure|render|pick-serve|pick> …";
48
30
 
49
31
  // Crash contract (issue #27): with --json, a thrown error becomes structured
50
32
  // stdout JSON; either way the message is matched against ERROR-PATTERNS.md and
@@ -205,52 +187,6 @@ const commands = {
205
187
  }
206
188
  },
207
189
 
208
- async describe(args) {
209
- const usage = "usage: partforge describe <part-module#importName> [--surfaces] [--json] [--budget N] [--out <file>]";
210
- const { values: flags, positionals: [target] } = parse(args, {
211
- surfaces: { type: "boolean" },
212
- json: { type: "boolean" },
213
- budget: { type: "string" },
214
- out: { type: "string" },
215
- }, usage);
216
- if (!target) die(usage);
217
- // `part.js#importName` — describe reads a FILE, and a file only reaches the kernel
218
- // through a part's `imports` declaration, so the part is how we find it. A bare mesh
219
- // path is deliberately not accepted in v1: it would need its own resolver, its own
220
- // format sniffing, and its own unit assumptions, all of which the import pipeline
221
- // already owns.
222
- const [partPath, importName] = target.split("#");
223
- if (!importName) die(`describe needs an import name: <part-module>#<importName>\n${usage}`);
224
- try {
225
- const part = await loadPart(partPath, usage);
226
- if (!part.imports?.[importName]) {
227
- die(`describe: "${importName}" is not a declared import of ${partPath} ` +
228
- `(have: ${Object.keys(part.imports ?? {}).join(", ") || "none"})`);
229
- }
230
- const { describe: describeMesh, compactDescribe } = await loadOracle();
231
- const kernel = await bootKernel(part);
232
- const solid = kernel.import(importName);
233
- const report = describeMesh(kernel, solid, {
234
- name: importName,
235
- digest: kernel._importDigest?.(importName) ?? null,
236
- budget: flags.budget ? Number(flags.budget) : undefined,
237
- });
238
- if (flags.out) {
239
- mkdirSync(dirname(resolve(flags.out)), { recursive: true });
240
- writeFileSync(flags.out, JSON.stringify(report, null, 2));
241
- }
242
- if (flags.json) console.log(JSON.stringify(report, null, 2));
243
- else printDescribe(report, { surfaces: !!flags.surfaces }, compactDescribe);
244
- if (flags.out) console.log(`\nwrote ${flags.out}`);
245
- // A closed-set error exits non-zero; LOW COVERAGE does not. Coverage is a finding
246
- // the caller must be able to read, and an exit code that conflated the two would
247
- // train an agent to discard exactly the reports it most needs to look at.
248
- process.exit(report.error ? 1 : 0);
249
- } catch (e) {
250
- crash("describe", e, !!flags.json);
251
- }
252
- },
253
-
254
190
  async render(args) {
255
191
  const usage = "usage: partforge render <part-module> [view] [--views iso,front] [--out <dir>] " +
256
192
  "[--params <json>] [--animation <name>] [--at <t[,t…]>] [--step <index|label>]";
@@ -488,83 +424,6 @@ function printVerify(v) {
488
424
  console.log(` result: ${f ? `${f} gate failure(s)` : "all gates passed"}${w ? `, ${w} warning(s)` : ""}`);
489
425
  }
490
426
 
491
- // Human/agent-readable summary. Features, patterns, symmetry, score, residual — the
492
- // compact shape (spec §3.4), because a 24k-triangle part yields hundreds of surfaces and
493
- // dumping them buries the reader in the noise the oracle exists to remove. `--surfaces`
494
- // opts back in; `--json` always has everything. Reads the SAME compactDescribe() output
495
- // a model reads (not a hand-rolled subset), so what a human sees here and what an agent
496
- // sees over `--json` cannot drift apart.
497
- function printDescribe(report, { surfaces }, compactDescribe) {
498
- if (report.error) {
499
- console.error(`describe: ${report.error}${report.detail ? ` — ${report.detail}` : ""}`);
500
- return;
501
- }
502
- const c = compactDescribe(report);
503
- if (c.warning) console.log(`\n!! ${c.warning}\n`);
504
- console.log(`${c.source.name ?? "mesh"} — ${c.source.triangles} triangles, ` +
505
- `${c.bounds.size.map((v) => v.toFixed(2)).join(" x ")} mm, ${c.frame.up} up`);
506
- // The `share` hint lives HERE, at the point of use, not several lines below beside
507
- // Score (fix round 2, IMPORTANT 1 — a prior version printed the full `score.note`
508
- // paragraph under Score and it dominated the report: measured at 36-43% of a typical
509
- // run's line count, the single largest visual element, bigger than the feature list,
510
- // banners, and score line combined — burying findings instead of clarifying them). One
511
- // line, next to the column it explains; the full note is still in `--json` unabridged
512
- // (`buildScore` in report.js attaches it unconditionally — nothing lost there).
513
- console.log(`\nFeatures (${c.features.length}):` +
514
- (c.features.length ? ` share = fraction of part volume this feature ` +
515
- `accounts for — a size measure, not certainty` : ""));
516
- // `volumeShareReason` (fix round 2, IMPORTANT 2): `volumeShare: null` alone doesn't
517
- // say whether this feature type is never proposed at all, was proposed but never
518
- // reached before the search ran out of budget, or was reached and built but simply
519
- // didn't win — three different signals to a rebuilder deciding what to do next. See
520
- // describe.js's own comment on the field for the exact three-way split.
521
- const REASON_LABEL = { "not-proposed": "not proposed", budget: "budget", rejected: "rejected" };
522
- for (const f of c.features) {
523
- const dim = f.diameter ?? f.radius ?? f.width ?? f.thickness ?? f.depth;
524
- const snap = f.snapped?.diameter?.note ? ` [${f.snapped.diameter.note}]` : "";
525
- const share = f.volumeShare != null
526
- ? `${(100 * f.volumeShare).toFixed(1)}%`
527
- : `n/a (${REASON_LABEL[f.volumeShareReason] ?? f.volumeShareReason ?? "unknown"})`;
528
- console.log(` ${f.id.padEnd(5)} ${f.type.padEnd(14)} ` +
529
- `${dim != null ? dim.toFixed(3) : ""}`.padEnd(10) +
530
- `share ${share}${snap}`);
531
- }
532
- if (c.patterns.length) {
533
- console.log(`\nPatterns (${c.patterns.length}):`);
534
- for (const p of c.patterns) {
535
- console.log(` ${p.id.padEnd(5)} ${p.type.padEnd(9)} x${p.counts.join("x")} ` +
536
- `pitch ${p.pitch.map((v) => v.toFixed(2)).join(", ")} [${p.members.length} members]`);
537
- }
538
- }
539
- if (c.symmetry.length) {
540
- console.log(`\nSymmetry:`);
541
- for (const s of c.symmetry) console.log(` ${s.type} coverage ${s.coverage}`);
542
- }
543
- if (surfaces) {
544
- console.log(`\nSurfaces (${report.surfaces.length}):`);
545
- for (const s of report.surfaces) {
546
- console.log(` ${s.id.padEnd(5)} ${s.type.padEnd(9)} area ${s.area.toFixed(2)}`.padEnd(40) +
547
- `rms ${s.rms.toExponential(2)}`);
548
- }
549
- }
550
- // TWO DIFFERENT NUMBERS, printed as two: explainedArea is how much of the mesh's
551
- // SURFACE segmentation fitted to some primitive; explainedVolumeFraction is how much
552
- // of the part's actual SHAPE the accepted features reconstruct. They can diverge
553
- // totally — a dome segments to ~100% area and 0% volume, since a sphere is not a
554
- // candidate-eligible feature type — so printing only one (or a blended "xor%") would
555
- // hide exactly the gap the LOW COVERAGE banner above exists to catch. The wording here
556
- // ("surface area explained" vs. "volume reconstructed") already carries that
557
- // distinction; `score.note`'s full prose (this point, plus the volumeShare aside now
558
- // covered at the Features header instead) stays JSON-only — fix round 2, IMPORTANT 1.
559
- console.log(`\nScore: ${(100 * c.score.explainedArea).toFixed(1)}% surface area explained, ` +
560
- `${(100 * c.score.explainedVolumeFraction).toFixed(1)}% volume reconstructed ` +
561
- `(residual xor ${(100 * c.score.xorFraction).toFixed(2)}% of volume)`);
562
- console.log(`Residual: ${(100 * c.residual.areaFraction).toFixed(2)}% of area in ` +
563
- `${c.residual.regions.length} region(s)`);
564
- const truncated = Object.entries(report.truncated ?? {}).filter(([, v]) => v).map(([k]) => k);
565
- if (truncated.length) console.log(`Truncated (caps hit): ${truncated.join(", ")}`);
566
- }
567
-
568
427
  function printLint(r) {
569
428
  const all = [...r.errors, ...r.warnings, ...(r.notes ?? [])];
570
429
  if (all.length === 0) { console.log("lint: clean"); return; }
@@ -1559,30 +1559,26 @@ build: (k, p, d) => {
1559
1559
 
1560
1560
  **CLI:** `partforge measure|render|lint` work on an importing part exactly as on any other — the `imports` field resolves in the CLI's Node boot the same way `fonts` does, no extra flags.
1561
1561
 
1562
- ## Describing an imported mesh
1563
-
1564
- `npx partforge describe <part-module>#<importName>` reads an already-declared import
1565
- and emits a semantic feature report holes, bosses, pockets, extrusions, patterns,
1566
- symmetry rather than a triangle soup, so an agent can rebuild an STL parametrically.
1567
-
1568
- The engine behind it the semantic mesh oracle — is **not part of this package**: it
1569
- ships as a separate, closed package (`@pixiteapps/partforge-oracle`) whose docs carry
1570
- the full report contract (the two report shapes, feature vocabulary, `volumeShare`
1571
- semantics, coverage scores, budget behavior, and the closed error set). This repo
1572
- keeps only the seams:
1573
-
1574
- - **CLI** `partforge describe` resolves the oracle package at call time and prints
1575
- an install pointer when it is absent (`PARTFORGE_ORACLE` overrides the module
1576
- specifier, which is how the oracle's own repo points this CLI at its working tree).
1577
- - **Worker** the `describe` job runs whatever `runWorker(part, { loadOracle })`
1578
- injected; without a loader it answers a structured
1579
- `{error: "oracle-unavailable"}` report (see
1580
- [ERROR-PATTERNS.md#describe-oracle-unavailable](ERROR-PATTERNS.md#describe-oracle-unavailable)),
1581
- never a stall. The loader resolves the oracle barrel: `describe`, `describeMemo`,
1582
- `compactDescribe`.
1583
- - **Helpers** — the oracle package peer-depends on this one and consumes
1584
- `partforge/oracle`'s mesh/BVH helpers and file parsers (`bounds`, `meshArea`,
1585
- `meshTriangles`, `parseStl`, `parse3MF`); those exports are part of its contract.
1562
+ ## Host jobs: extending the worker
1563
+
1564
+ The worker's job loop handles a closed set of message types (`generate`, the exports,
1565
+ `inspect`, …). A host app can add its own: `runWorker(part, { jobs: { <type>:
1566
+ handler } })`. A message whose `type` matches no built-in is handed to the matching
1567
+ handler as `handler(kernel, part, msg, post, { isStale })` — the live kernel (so the
1568
+ handler can `kernel.import(name)` a declared import, or read `kernel._importDigest`),
1569
+ the part current when the message arrived, the message, and the poster for results
1570
+ (`post(msg, transferables?)`). A throw is posted as the ordinary `{type: "error",
1571
+ message, jobId}`; built-in types cannot be overridden; a type with no handler is
1572
+ ignored.
1573
+
1574
+ This is the seam through which a host adds a capability this open framework does not
1575
+ ship. The semantic mesh oracle imported mesh feature report, for rebuilding an
1576
+ STL parametrically is one: it is a separate, closed package with its own CLI, and
1577
+ the app that installs it registers its job here. This repo carries nothing
1578
+ oracle-shaped: no verb, no message types, no error codes. The one direction that
1579
+ does exist is the oracle peer-depending on this package for `partforge/oracle`'s
1580
+ mesh/BVH helpers and file parsers (`bounds`, `meshArea`, `meshTriangles`, `parseStl`,
1581
+ `parse3MF`); those exports are part of its contract.
1586
1582
 
1587
1583
 
1588
1584
  ## Probes: measuring geometry into the report
@@ -1947,8 +1943,8 @@ access. It's harmless to leave in when partforge is a normal install.)
1947
1943
  ## Testing a part
1948
1944
 
1949
1945
  Tests run under **Node 24** (`nvm use` first; the default shell Node is too old) via
1950
- `npx vitest run`. The oracle half of this surface — `measure`, `verify`,
1951
- `describe`, gaps, match scoring — is also published on its own as
1946
+ `npx vitest run`. The oracle half of this surface — `measure`, `verify`, gaps,
1947
+ match scoring — is also published on its own as
1952
1948
  `partforge/oracle` (browser-safe import closure); `partforge/testing` re-exports
1953
1949
  it, so either import works. Build geometry directly off your part with a Manifold
1954
1950
  kernel:
@@ -652,42 +652,6 @@ between the Manifold preview and the OCCT STEP export.
652
652
 
653
653
  The same channel carries every other degrade in a build: an `extrude` rim bevel reduced or skipped (`extrude bevel <b> …`), a `roundedBox` rim radius clamped to `round.side`, and the `Shape2D` corner-op clamps in the two entries above. A build result's `warnings` is the complete list of what the part asked for and did not get.
654
654
 
655
- ## describe-oracle-unavailable
656
-
657
- - **Symptom:** A `describe` job answers `{"error": "oracle-unavailable"}`, or `partforge describe` exits with "describe needs the mesh-oracle package".
658
- - **Cause:** The semantic mesh oracle is a separate, closed package (`@pixiteapps/partforge-oracle`), and this app or shell doesn't have it: the worker was started without `runWorker(part, { loadOracle })`, or the CLI could not import the package.
659
- - **Fix:** Install the oracle package from the private registry and inject it — `runWorker(part, { loadOracle: () => import("@pixiteapps/partforge-oracle") })` in the app's worker file, or plain `npm install` for the CLI. In the oracle package's own repo, set `PARTFORGE_ORACLE` to a local path instead. Every other job (generate, export, inspect) works without it.
660
-
661
- ## describe-not-manifold
662
-
663
- - **Symptom:** `describe` returns `{"error": "not-manifold"}`.
664
- - **Cause:** The mesh still has open edges after vertex-merge and winding repair, so it does not bound a solid and acceptance cannot diff against it.
665
- - **Fix:** Repair the mesh before describing it — Meshmixer, `meshlabserver`, or the slicer's own repair. `describe` will not repair geometry it was asked to report on; silently sealing a hole would make the report a description of a mesh the user does not have.
666
-
667
- ## describe-too-large
668
-
669
- - **Symptom:** `{"error": "too-large"}` naming a triangle count.
670
- - **Cause:** Above 400,000 triangles the segmentation pass stops being usable in an interactive loop.
671
- - **Fix:** Decimate first. A CAD-exported STL re-exported at a coarser chord tolerance loses nothing the describer uses; the feature vocabulary reads surfaces, not facets.
672
-
673
- ## describe-empty
674
-
675
- - **Symptom:** `{"error": "empty"}`.
676
- - **Cause:** The mesh has zero triangles — usually an import that resolved to an empty file, or a `k.import` name that registered as an error entry.
677
- - **Fix:** Check the `imports` source actually resolves. See [import-unknown-name](#import-unknown-name).
678
-
679
- ## describe-budget-exceeded
680
-
681
- - **Symptom:** The report carries `warning: "budget-exceeded"` and `score.xorFraction` is higher than expected.
682
- - **Cause:** The acceptance loop hit its boolean budget before the residual converged. The report is partial but honestly scored — not wrong, just incomplete.
683
- - **Fix:** Raise `--budget`, or accept the partial description. A part needing far more than the default is usually one where a residual region is being attacked by many near-identical candidates; check `residual.regions` first.
684
-
685
- ## describe-unreadable
686
-
687
- - **Symptom:** `{"error": "unreadable"}`.
688
- - **Cause:** `solid.toMesh()` threw rather than returning geometry — a corrupt or otherwise unrepresentable solid.
689
- - **Fix:** Confirm the source file parses cleanly upstream (`k.import` itself throws separately for an unparseable STL/3MF/STEP — see [import-unknown-name](#import-unknown-name)); re-export the file if the kernel cannot read back what it just built. STL is assumed to be in millimetres (the format carries no unit metadata) — see the import section of AUTHORING-PARTS.md.
690
-
691
655
  ## control-default-not-literal
692
656
 
693
657
  - **Symptom:** A control works live — the slider moves, the geometry updates — but the user's panel edits are gone when the part is reopened. Nothing throws anywhere.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.85.4",
3
+ "version": "0.86.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -26,22 +26,16 @@ const loadInspect = () => Promise.all([
26
26
  import("./oracle/verify.js"),
27
27
  ]);
28
28
 
29
- // The DESCRIBE stack is not part of this package at all: the semantic mesh oracle
30
- // lives in a separate, closed package, and this open framework never names it. A
31
- // host that has it INJECTS a loader `runWorker(part, { loadOracle })`, threaded
32
- // here as `opts.loadOracle`, resolving to the oracle package's barrel (describe,
33
- // describeMemo, compactDescribe). Injection rather than a bare import specifier is
34
- // deliberate: a literal `import("@scope/pkg")` in open source would fail every
35
- // downstream Vite build where the package isn't installed, while an injected thunk
36
- // is simply absent and an absent oracle answers the job with the structured
37
- // `oracle-unavailable` report below instead of stalling or throwing.
38
-
39
- // One describe memo for the life of this worker, created alongside the stack's first
40
- // load. Deliberately NOT swept on setPart the way solid-cache is: describe is pure in
41
- // the mesh bytes (spec §4.1), so an edit can never invalidate it, and dropping it on
42
- // rebind would throw away the single most expensive thing this worker computes for no
43
- // reason at all. Keyed by content digest, so a genuinely changed file misses correctly.
44
- let DESCRIBE_MEMO = null;
29
+ // HOST JOBS the extension seam. A host registers its own job types with
30
+ // `runWorker(part, { jobs: { <type>: handler } })`, threaded here as `opts.jobs`, and
31
+ // a message whose type matches no built-in below is handed to that handler with the
32
+ // live kernel, the current part, the message and the poster. This is how a host adds
33
+ // a capability the open framework does not ship partforge-cloud's semantic
34
+ // mesh-oracle describe job lives entirely in its own closed package and arrives here
35
+ // as one of these without this repo naming it, importing it, or knowing its message
36
+ // shapes. Built-ins win a name clash (a host cannot redefine `generate`); a type with
37
+ // no handler at all is ignored, as unknown types always were. A handler's throw is
38
+ // posted as the ordinary `{type: "error"}` any failed job posts.
45
39
 
46
40
  // Handle one geometry job, posting results/progress via `post(msg, transfer?)`.
47
41
  // Backend-agnostic and part-agnostic: every part specific comes through `part`.
@@ -414,47 +408,8 @@ export async function handle(kernel, part, msg, post, opts = {}) {
414
408
  const match = await scoreMatchTargets(built, msg.matchTargets, onProgress);
415
409
  if (match) report.match = match;
416
410
  post({ type: "report", ...report }, match?.map((m) => m.delta.data.buffer) ?? []);
417
- } else if (msg.type === "describe") {
418
- // Semantic description of an IMPORTED mesh — not of the built part. The two are
419
- // different questions: `inspect` asks "what did this source build?", `describe`
420
- // asks "what is this file?". describe never touches the part's own geometry, which
421
- // is why it takes an import name rather than a view.
422
- //
423
- // Manifold only, and not by choice on this path: mesh imports on OCCT are never
424
- // attempted, so a describe job posted to an OCCT worker is a routing bug, not a
425
- // fallback opportunity. It surfaces as an ordinary error rather than a reroute.
426
- if (!opts.loadOracle) {
427
- // Same closed-set, returned-not-thrown error contract describe itself keeps
428
- // (its errors are findings, not crashes) — shaped as the structured triple the
429
- // whole repo emits, so a caller can act on the code. ERROR-PATTERNS.md#describe-
430
- // oracle-unavailable carries the fix.
431
- post({ type: "describe-report", report: {
432
- error: "oracle-unavailable",
433
- detail: "this app was built without the mesh oracle package",
434
- diagnostic: {
435
- cause: "the describe job needs the closed oracle package, and no loadOracle loader was injected into runWorker",
436
- location: `describe "${msg.importName}"`,
437
- correctiveAction: "install the oracle package and pass runWorker(part, { loadOracle: () => import(...) }); see ERROR-PATTERNS.md#describe-oracle-unavailable",
438
- },
439
- source: { name: msg.importName, digest: null },
440
- } });
441
- return;
442
- }
443
- const { describe: describeMesh, describeMemo, compactDescribe } = await opts.loadOracle();
444
- const solid = kernel.import(msg.importName); // throws on an unknown name
445
- // `_importDigest` is the backend's existing underscore side-channel (KERNEL-CONTRACT
446
- // "Conformance classes") — the same digest already folded into every import cache key.
447
- const digest = kernel._importDigest?.(msg.importName) ?? null;
448
- DESCRIBE_MEMO ??= describeMemo();
449
- const full = describeMesh(kernel, solid, {
450
- name: msg.importName,
451
- digest,
452
- budget: msg.budget,
453
- memo: DESCRIBE_MEMO,
454
- });
455
- // The compact shape is derived, never memoised separately: one memo entry per
456
- // mesh, two views of it, no way for the two to drift.
457
- post({ type: "describe-report", report: msg.compact ? compactDescribe(full) : full });
411
+ } else if (opts.jobs && Object.hasOwn(opts.jobs, msg.type)) {
412
+ await opts.jobs[msg.type](kernel, part, msg, post, { isStale: opts.isStale });
458
413
  }
459
414
  } catch (err) {
460
415
  // `subparts` (generate jobs only) tells the reroute policy which sub-parts the
@@ -36,9 +36,9 @@ async function occtKernel() {
36
36
  return createOcctKernel(replicad);
37
37
  }
38
38
 
39
- // `opts.loadOracle` — the injection seam for the closed mesh-oracle package (see
40
- // jobs.js's describe branch): a thunk resolving to the oracle barrel. Apps without
41
- // the package simply omit it and describe jobs answer `oracle-unavailable`.
39
+ // `opts.jobs` — host-registered job handlers, `{ <type>: (kernel, part, msg, post,
40
+ // ctx) => … }` (see jobs.js's HOST JOBS comment). A message type no built-in claims
41
+ // goes to the matching handler; apps with nothing to add simply omit it.
42
42
  export function runWorker(part, opts = {}) {
43
43
  const backend = self.name === "occt" ? "occt" : "manifold";
44
44
  let manifold = null; // { preview, print }
@@ -102,7 +102,7 @@ export function runWorker(part, opts = {}) {
102
102
  const kernel = await kernelFor(job.data);
103
103
  // handle() declares each message's transferables (the big binary buffers).
104
104
  const post = (m, transfer = []) => postMessage(m, transfer);
105
- if (job.epoch === null) { await handle(kernel, job.part, job.data, post, { importMeshes, loadOracle: opts.loadOracle }); continue; }
105
+ if (job.epoch === null) { await handle(kernel, job.part, job.data, post, { importMeshes, jobs: opts.jobs }); continue; }
106
106
  const isStale = () => job.epoch !== epoch;
107
107
  // Post gate. The boundary check cannot catch a generate that goes stale during
108
108
  // its FINAL sub-part — there is no boundary after it — nor a single-sub-part
@@ -111,7 +111,7 @@ export function runWorker(part, opts = {}) {
111
111
  // contract simple: a `meshes` post is current as of the moment it is posted.
112
112
  const gated = (m, transfer = []) =>
113
113
  (m.type === "meshes" && isStale() ? post({ type: "superseded" }) : post(m, transfer));
114
- await handle(kernel, job.part, job.data, gated, { isStale, importMeshes, loadOracle: opts.loadOracle });
114
+ await handle(kernel, job.part, job.data, gated, { isStale, importMeshes, jobs: opts.jobs });
115
115
  } catch (err) {
116
116
  // Same shape jobs.js posts for a failed build, so hosts need no new branch.
117
117
  // Carry the job's jobId when it has one (capture/export are correlated by it):
package/src/oracle.js CHANGED
@@ -9,11 +9,11 @@
9
9
  // test/oracle-entry.test.js walks the closure and holds that, so the entry stays
10
10
  // importable from a worker, a browser, or Node alike.
11
11
  //
12
- // The SEMANTIC MESH ORACLE (`describe`) is NOT here: it lives in its own closed
13
- // package, which peer-depends on this one and consumes exactly this entry the
14
- // mesh/BVH helpers and file parsers below are exported for it. The framework
15
- // reaches it only through injection (`runWorker(part, { loadOracle })`, jobs.js)
16
- // and the CLI resolves it at call time; neither ever bundles it.
12
+ // The SEMANTIC MESH ORACLE (imported mesh -> feature report) is NOT here, and this
13
+ // framework has no verb, job or import for it: it is its own closed package, which
14
+ // peer-depends on this one and consumes exactly this entry the mesh/BVH helpers
15
+ // and file parsers below are exported for it. A host that installs it registers its
16
+ // job through the generic `runWorker(part, { jobs })` seam (jobs.js).
17
17
  export { assemblyGaps, meshGaps } from "./framework/oracle/gaps.js";
18
18
  export { meshVolume, bboxSize, bounds, meshArea } from "./framework/oracle/mesh.js";
19
19
  export { buildView } from "./framework/oracle/build.js";
package/types/worker.d.ts CHANGED
@@ -17,13 +17,29 @@ export interface WorkerHandle {
17
17
  setPart(newPart: PartDefinition): void;
18
18
  }
19
19
 
20
+ /**
21
+ * A host-registered job handler (`runWorker`'s `opts.jobs`): receives the live
22
+ * kernel, the part current when the message arrived, the message itself, the
23
+ * poster for results (`post(msg, transferables?)`), and a context with `isStale`
24
+ * (set for jobs that can be superseded by a rebind). A throw is posted as the
25
+ * ordinary `{type: "error", message, jobId}`.
26
+ */
27
+ export type HostJob = (
28
+ kernel: unknown,
29
+ part: PartDefinition,
30
+ msg: { type: string; jobId?: number; [key: string]: unknown },
31
+ post: (msg: object, transfer?: Transferable[]) => void,
32
+ ctx: { isStale?: () => boolean },
33
+ ) => void | Promise<void>;
34
+
20
35
  /**
21
36
  * Run the worker job loop for `part`. Call once, at worker module top level.
22
- * `opts.loadOracle` injects the closed semantic-mesh-oracle package (a thunk
23
- * resolving its barrel: `describe`, `describeMemo`, `compactDescribe`); omitted,
24
- * describe jobs answer with a structured `oracle-unavailable` report.
37
+ * `opts.jobs` registers host job types by message `type`: a message no built-in
38
+ * job claims is handed to the matching handler; built-ins are not overridable and
39
+ * a type with no handler is ignored. This is how a host adds capabilities the open
40
+ * framework does not ship.
25
41
  */
26
42
  export function runWorker(
27
43
  part: PartDefinition,
28
- opts?: { loadOracle?: () => Promise<{ describe: Function; describeMemo: () => Map<string, unknown>; compactDescribe: Function }> },
44
+ opts?: { jobs?: Record<string, HostJob> },
29
45
  ): WorkerHandle;