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 +1 -142
- package/docs/AUTHORING-PARTS.md +22 -26
- package/docs/ERROR-PATTERNS.md +0 -36
- package/package.json +1 -1
- package/src/framework/jobs.js +12 -57
- package/src/framework/worker.js +5 -5
- package/src/oracle.js +5 -5
- package/types/worker.d.ts +20 -4
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
|
-
|
|
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; }
|
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -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
|
-
##
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
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
|
-
|
|
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:
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -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
package/src/framework/jobs.js
CHANGED
|
@@ -26,22 +26,16 @@ const loadInspect = () => Promise.all([
|
|
|
26
26
|
import("./oracle/verify.js"),
|
|
27
27
|
]);
|
|
28
28
|
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
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
|
|
418
|
-
|
|
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
|
package/src/framework/worker.js
CHANGED
|
@@ -36,9 +36,9 @@ async function occtKernel() {
|
|
|
36
36
|
return createOcctKernel(replicad);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
// `opts.
|
|
40
|
-
// jobs.js's
|
|
41
|
-
// the
|
|
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,
|
|
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,
|
|
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 (
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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.
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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?: {
|
|
44
|
+
opts?: { jobs?: Record<string, HostJob> },
|
|
29
45
|
): WorkerHandle;
|