partforge 0.77.0 → 0.79.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
@@ -15,6 +15,8 @@ import { bootOcctKernel } from "../src/testing/occt.js";
15
15
  import { bootManifoldKernel } from "../src/testing/manifold.js";
16
16
  import { measure } from "../src/framework/oracle/measure.js";
17
17
  import { verify } from "../src/framework/oracle/verify.js";
18
+ import { describe as describeMesh } from "../src/framework/oracle/describe.js";
19
+ import { compactDescribe } from "../src/framework/oracle/describe/report.js";
18
20
  import { renderViews } from "../src/testing/render.js";
19
21
  import {
20
22
  createPickServer, requestPicks, formatPickResult,
@@ -25,7 +27,7 @@ import { matchPattern } from "../src/testing/error-patterns.js";
25
27
  import { lintPart } from "../src/lint.js";
26
28
 
27
29
  const die = (msg) => { console.error(msg); process.exit(1); };
28
- const USAGE = "usage: partforge <lint|measure|render|pick-serve|pick> …";
30
+ const USAGE = "usage: partforge <lint|measure|render|describe|pick-serve|pick> …";
29
31
 
30
32
  // Crash contract (issue #27): with --json, a thrown error becomes structured
31
33
  // stdout JSON; either way the message is matched against ERROR-PATTERNS.md and
@@ -186,6 +188,51 @@ const commands = {
186
188
  }
187
189
  },
188
190
 
191
+ async describe(args) {
192
+ const usage = "usage: partforge describe <part-module#importName> [--surfaces] [--json] [--budget N] [--out <file>]";
193
+ const { values: flags, positionals: [target] } = parse(args, {
194
+ surfaces: { type: "boolean" },
195
+ json: { type: "boolean" },
196
+ budget: { type: "string" },
197
+ out: { type: "string" },
198
+ }, usage);
199
+ if (!target) die(usage);
200
+ // `part.js#importName` — describe reads a FILE, and a file only reaches the kernel
201
+ // through a part's `imports` declaration, so the part is how we find it. A bare mesh
202
+ // path is deliberately not accepted in v1: it would need its own resolver, its own
203
+ // format sniffing, and its own unit assumptions, all of which the import pipeline
204
+ // already owns.
205
+ const [partPath, importName] = target.split("#");
206
+ if (!importName) die(`describe needs an import name: <part-module>#<importName>\n${usage}`);
207
+ try {
208
+ const part = await loadPart(partPath, usage);
209
+ if (!part.imports?.[importName]) {
210
+ die(`describe: "${importName}" is not a declared import of ${partPath} ` +
211
+ `(have: ${Object.keys(part.imports ?? {}).join(", ") || "none"})`);
212
+ }
213
+ const kernel = await bootKernel(part);
214
+ const solid = kernel.import(importName);
215
+ const report = describeMesh(kernel, solid, {
216
+ name: importName,
217
+ digest: kernel._importDigest?.(importName) ?? null,
218
+ budget: flags.budget ? Number(flags.budget) : undefined,
219
+ });
220
+ if (flags.out) {
221
+ mkdirSync(dirname(resolve(flags.out)), { recursive: true });
222
+ writeFileSync(flags.out, JSON.stringify(report, null, 2));
223
+ }
224
+ if (flags.json) console.log(JSON.stringify(report, null, 2));
225
+ else printDescribe(report, { surfaces: !!flags.surfaces });
226
+ if (flags.out) console.log(`\nwrote ${flags.out}`);
227
+ // A closed-set error exits non-zero; LOW COVERAGE does not. Coverage is a finding
228
+ // the caller must be able to read, and an exit code that conflated the two would
229
+ // train an agent to discard exactly the reports it most needs to look at.
230
+ process.exit(report.error ? 1 : 0);
231
+ } catch (e) {
232
+ crash("describe", e, !!flags.json);
233
+ }
234
+ },
235
+
189
236
  async render(args) {
190
237
  const usage = "usage: partforge render <part-module> [view] [--views iso,front] [--out <dir>] " +
191
238
  "[--params <json>] [--animation <name>] [--at <t[,t…]>] [--step <index|label>]";
@@ -395,6 +442,83 @@ function printVerify(v) {
395
442
  console.log(` result: ${f ? `${f} gate failure(s)` : "all gates passed"}${w ? `, ${w} warning(s)` : ""}`);
396
443
  }
397
444
 
445
+ // Human/agent-readable summary. Features, patterns, symmetry, score, residual — the
446
+ // compact shape (spec §3.4), because a 24k-triangle part yields hundreds of surfaces and
447
+ // dumping them buries the reader in the noise the oracle exists to remove. `--surfaces`
448
+ // opts back in; `--json` always has everything. Reads the SAME compactDescribe() output
449
+ // a model reads (not a hand-rolled subset), so what a human sees here and what an agent
450
+ // sees over `--json` cannot drift apart.
451
+ function printDescribe(report, { surfaces }) {
452
+ if (report.error) {
453
+ console.error(`describe: ${report.error}${report.detail ? ` — ${report.detail}` : ""}`);
454
+ return;
455
+ }
456
+ const c = compactDescribe(report);
457
+ if (c.warning) console.log(`\n!! ${c.warning}\n`);
458
+ console.log(`${c.source.name ?? "mesh"} — ${c.source.triangles} triangles, ` +
459
+ `${c.bounds.size.map((v) => v.toFixed(2)).join(" x ")} mm, ${c.frame.up} up`);
460
+ // The `share` hint lives HERE, at the point of use, not several lines below beside
461
+ // Score (fix round 2, IMPORTANT 1 — a prior version printed the full `score.note`
462
+ // paragraph under Score and it dominated the report: measured at 36-43% of a typical
463
+ // run's line count, the single largest visual element, bigger than the feature list,
464
+ // banners, and score line combined — burying findings instead of clarifying them). One
465
+ // line, next to the column it explains; the full note is still in `--json` unabridged
466
+ // (`buildScore` in report.js attaches it unconditionally — nothing lost there).
467
+ console.log(`\nFeatures (${c.features.length}):` +
468
+ (c.features.length ? ` share = fraction of part volume this feature ` +
469
+ `accounts for — a size measure, not certainty` : ""));
470
+ // `volumeShareReason` (fix round 2, IMPORTANT 2): `volumeShare: null` alone doesn't
471
+ // say whether this feature type is never proposed at all, was proposed but never
472
+ // reached before the search ran out of budget, or was reached and built but simply
473
+ // didn't win — three different signals to a rebuilder deciding what to do next. See
474
+ // describe.js's own comment on the field for the exact three-way split.
475
+ const REASON_LABEL = { "not-proposed": "not proposed", budget: "budget", rejected: "rejected" };
476
+ for (const f of c.features) {
477
+ const dim = f.diameter ?? f.radius ?? f.width ?? f.thickness ?? f.depth;
478
+ const snap = f.snapped?.diameter?.note ? ` [${f.snapped.diameter.note}]` : "";
479
+ const share = f.volumeShare != null
480
+ ? `${(100 * f.volumeShare).toFixed(1)}%`
481
+ : `n/a (${REASON_LABEL[f.volumeShareReason] ?? f.volumeShareReason ?? "unknown"})`;
482
+ console.log(` ${f.id.padEnd(5)} ${f.type.padEnd(14)} ` +
483
+ `${dim != null ? dim.toFixed(3) : ""}`.padEnd(10) +
484
+ `share ${share}${snap}`);
485
+ }
486
+ if (c.patterns.length) {
487
+ console.log(`\nPatterns (${c.patterns.length}):`);
488
+ for (const p of c.patterns) {
489
+ console.log(` ${p.id.padEnd(5)} ${p.type.padEnd(9)} x${p.counts.join("x")} ` +
490
+ `pitch ${p.pitch.map((v) => v.toFixed(2)).join(", ")} [${p.members.length} members]`);
491
+ }
492
+ }
493
+ if (c.symmetry.length) {
494
+ console.log(`\nSymmetry:`);
495
+ for (const s of c.symmetry) console.log(` ${s.type} coverage ${s.coverage}`);
496
+ }
497
+ if (surfaces) {
498
+ console.log(`\nSurfaces (${report.surfaces.length}):`);
499
+ for (const s of report.surfaces) {
500
+ console.log(` ${s.id.padEnd(5)} ${s.type.padEnd(9)} area ${s.area.toFixed(2)}`.padEnd(40) +
501
+ `rms ${s.rms.toExponential(2)}`);
502
+ }
503
+ }
504
+ // TWO DIFFERENT NUMBERS, printed as two: explainedArea is how much of the mesh's
505
+ // SURFACE segmentation fitted to some primitive; explainedVolumeFraction is how much
506
+ // of the part's actual SHAPE the accepted features reconstruct. They can diverge
507
+ // totally — a dome segments to ~100% area and 0% volume, since a sphere is not a
508
+ // candidate-eligible feature type — so printing only one (or a blended "xor%") would
509
+ // hide exactly the gap the LOW COVERAGE banner above exists to catch. The wording here
510
+ // ("surface area explained" vs. "volume reconstructed") already carries that
511
+ // distinction; `score.note`'s full prose (this point, plus the volumeShare aside now
512
+ // covered at the Features header instead) stays JSON-only — fix round 2, IMPORTANT 1.
513
+ console.log(`\nScore: ${(100 * c.score.explainedArea).toFixed(1)}% surface area explained, ` +
514
+ `${(100 * c.score.explainedVolumeFraction).toFixed(1)}% volume reconstructed ` +
515
+ `(residual xor ${(100 * c.score.xorFraction).toFixed(2)}% of volume)`);
516
+ console.log(`Residual: ${(100 * c.residual.areaFraction).toFixed(2)}% of area in ` +
517
+ `${c.residual.regions.length} region(s)`);
518
+ const truncated = Object.entries(report.truncated ?? {}).filter(([, v]) => v).map(([k]) => k);
519
+ if (truncated.length) console.log(`Truncated (caps hit): ${truncated.join(", ")}`);
520
+ }
521
+
398
522
  function printLint(r) {
399
523
  const all = [...r.errors, ...r.warnings, ...(r.notes ?? [])];
400
524
  if (all.length === 0) { console.log("lint: clean"); return; }
@@ -1494,6 +1494,166 @@ build: (k, p, d) => {
1494
1494
 
1495
1495
  **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.
1496
1496
 
1497
+ ## Describing an imported mesh
1498
+
1499
+ Before you write the parametric rebuild, ask the mesh itself what's in it: `partforge
1500
+ describe <part-module>#<importName>` reads an already-declared import (`k.import`'s own
1501
+ name, not a bare file path — the `imports` field is how the file gets to a kernel at
1502
+ all, so it's how `describe` finds it too) and emits a semantic feature report — holes,
1503
+ bosses, pockets, extrusions, patterns, symmetry — rather than a triangle soup. On
1504
+ `import-demo.js`, whose `scan` import is a plain 20×14×8mm block:
1505
+
1506
+ ```
1507
+ $ npx partforge describe src/parts/import-demo.js#scan
1508
+
1509
+ scan — 12 triangles, 20.00 x 14.00 x 8.00 mm, +Z up
1510
+
1511
+ Features (1): share = fraction of part volume this feature accounts for — a size measure, not certainty
1512
+ f0 extrusion 8.000 share 100.0%
1513
+
1514
+ Score: 100.0% surface area explained, 100.0% volume reconstructed (residual xor 0.00% of volume)
1515
+ Residual: 0.00% of area in 0 region(s)
1516
+ ```
1517
+
1518
+ **Two report shapes, and which one is authoritative.** `describe()` (`src/framework/
1519
+ oracle/describe.js`) returns the FULL report — every surface, every fitted edge, every
1520
+ feature at full precision — and that is the archive: `--json` prints it whole, and
1521
+ `--out <file>` writes it whole. `compactDescribe()` (`src/framework/oracle/describe/
1522
+ report.js`) derives a second, smaller shape from it — features, patterns, symmetry,
1523
+ score, residual, and the suggested rebuild, with `surfaces`/`edges` reduced to counts —
1524
+ and that is what a model reads, and what the terminal printer above reads too: **the
1525
+ same `compactDescribe()` output**, not a hand-rolled subset, so what a human sees in the
1526
+ default summary and what an agent gets from a cloud turn cannot drift apart. The full
1527
+ report is authoritative for the facts (a fitted surface's rms, an edge's exact radius);
1528
+ the compact report is authoritative for what's worth paying attention to.
1529
+
1530
+ **Computed once, reused for the session.** `describe` depends on nothing but the mesh's
1531
+ own bytes — no part params, no derived state — so unlike `measure`/`verify` it never
1532
+ needs to re-run on every parameter edit. The worker keys its memo on the import's
1533
+ content digest (`kernel._importDigest(name)`), so re-describing the same file, or asking
1534
+ again mid-session while you iterate on the parametric rebuild, is free after the first
1535
+ call. The CLI doesn't carry a memo across process invocations — each `partforge
1536
+ describe` run is its own process — but within one browser session or one long-running
1537
+ tool loop, the mesh is segmented once.
1538
+
1539
+ **`--surfaces` and `--json`, and why the default elides.** A hand-modelled block
1540
+ segments to six surfaces; a real 24k-triangle CAD export segments to hundreds. Printing
1541
+ every one of them by default would bury the two or three that actually matter under
1542
+ exactly the noise this oracle exists to remove — so the plain-text summary shows
1543
+ features, patterns, symmetry, score, and residual only. Pass `--surfaces` to add the
1544
+ surface table back (`s0 plane area … rms …`, one line per fitted patch); pass `--json`
1545
+ to get everything, always, structured — the mode an agent should default to over
1546
+ scraping the text summary. `--out <file>` writes the full report to disk independently
1547
+ of either flag, so you can capture it once and read it multiple ways.
1548
+
1549
+ **Low coverage exits zero — it's a finding, not a failure.** A poor segmentation (a
1550
+ dome, a free-form scan, anything the four feature detectors can't reconstruct) is
1551
+ exactly the situation an agent most needs to see, not one that should make itself
1552
+ harder to see. `describe` treats "the shape isn't well explained" and "the command
1553
+ failed" as different questions: a `LOW COVERAGE` banner at the top of the summary (and
1554
+ `compactDescribe()`'s own `warning` field in `--json` output) is how a poor result
1555
+ announces itself, and the process still exits 0. Only a genuine closed-set error — the
1556
+ mesh couldn't be read or segmented at all — exits 1. Coverage is gated on the WORSE of
1557
+ two numbers, both always printed rather than blended into one: `explainedArea` (how much
1558
+ of the mesh's *surface* a fitted primitive accounts for) and `explainedVolumeFraction`
1559
+ (how much of the part's actual *shape* the accepted features reconstruct). They can
1560
+ diverge totally — a dome segments to ~100% surface area (it fits a sphere cleanly) and
1561
+ 0% volume (a sphere isn't a candidate-eligible feature type for any detector) — so
1562
+ printing only one of them, or an average, would hide precisely the gap the banner exists
1563
+ to catch. Each feature's own score is named `volumeShare`, never "confidence": it is the
1564
+ fraction of the part's volume that feature accounts for — a measure of *size* — so a
1565
+ small-but-certain feature (a precisely-fitted 3mm hole in a large plate) legitimately
1566
+ reports a small share. Read a feature's fitted surface `rms`/`maxDev` (under `--surfaces`
1567
+ or `--json`) for an actual certainty signal. The one-line hint at the `Features (N):`
1568
+ header states the size-not-certainty point at its point of use; the longer explanation
1569
+ (`score.note` — both this and the `explainedArea`/`explainedVolumeFraction` distinction
1570
+ above, spelled out for a reader who never gets this far) stays `--json`-only, since
1571
+ printing it in full under `Score:` used to be the single largest visual element in every
1572
+ plain-text run — measured at 36-43% of a typical report's line count, bigger than the
1573
+ feature list, the banners, and the score line combined.
1574
+
1575
+ **Why a null `volumeShare` isn't always the same story.** A feature can carry
1576
+ `volumeShare: null` for three different reasons, distinguished by its sibling field
1577
+ `volumeShareReason` (`--surfaces`/`--json`; the text view renders it as `share n/a
1578
+ (<reason>)`):
1579
+
1580
+ - `"not-proposed"` — this feature TYPE is never turned into an acceptance candidate at
1581
+ all. Fillets, chamfers, revolves and shells fall here today; they're still reported as
1582
+ features, just not yet reconstructable through this pipeline.
1583
+ - `"budget"` — a candidate WAS proposed, but the search ran out of `--budget` before
1584
+ reaching it. Raise `--budget` and re-describe; the feature may resolve to a real share
1585
+ once given the chance.
1586
+ - `"rejected"` — a candidate was proposed, built, and evaluated, but never won a round
1587
+ (`accept.js`'s `MIN_GAIN_FRACTION` gate, or simply never the best candidate available).
1588
+ Raising `--budget` will not change this outcome — the feature genuinely doesn't fit
1589
+ well enough to explain a meaningful share of the part.
1590
+
1591
+ `"budget"` and `"rejected"` are the two that matter most to get right, and the
1592
+ distinction is real, not cosmetic: "try a bigger budget" and "this doesn't fit" are
1593
+ different next actions for whoever is rebuilding the part.
1594
+
1595
+ **What `describe` reconstructs today, and what it doesn't.** The FACTS layer — surfaces,
1596
+ arcs, holes, dress-ups, sweeps, patterns, symmetry — covers the tool's full detection
1597
+ vocabulary; a feature can be *reported* there regardless of shape. The RECONSTRUCTION
1598
+ score (`explainedVolumeFraction`, and every accepted feature's own `volumeShare`) is
1599
+ narrower: `toCandidate` only proposes box and cylinder footprints as acceptance
1600
+ candidates today, so it currently reconstructs prismatic parts built from roughly fewer
1601
+ than eight box/cylinder features well, and does not yet reconstruct round bosses,
1602
+ revolves, fillets, chamfers, or shells — those are detected and reported (with
1603
+ `volumeShareReason: "not-proposed"`) but never turned into a candidate that could win
1604
+ volume back. Measured directly on this repo's own reference parts: `demo.js`
1605
+ reconstructs 65.7% of its volume, `filleted-box.js` 36.2%, `bracket.js` 23.6%, a plain
1606
+ tube 0.0%, and a hollow box 1.9%. The low-coverage banner fires on every one of these, so
1607
+ nothing here is misreported — but a low `explainedVolumeFraction` on a turned or
1608
+ feature-dense part means **"not yet reconstructable by this tool"**, not "not
1609
+ understood" or "broken." Read the FACTS (features, surfaces, patterns) as the ground
1610
+ truth regardless of the volume score; read the volume score as a measure of how much of
1611
+ that ground truth also comes with a working, boolean-verified rebuild recipe.
1612
+
1613
+ **Closed error set.** Anything short of a programming mistake comes back as
1614
+ `{error: "<code>", detail, diagnostic}` rather than a thrown exception — a CLI or an
1615
+ agent can act on a code far better than on a stack trace — and every code has an
1616
+ ERROR-PATTERNS.md entry:
1617
+
1618
+ - `not-manifold` — the mesh still has open edges after repair; see
1619
+ [ERROR-PATTERNS.md#describe-not-manifold](ERROR-PATTERNS.md#describe-not-manifold).
1620
+ - `too-large` — over the 400,000-triangle segmentation ceiling; see
1621
+ [ERROR-PATTERNS.md#describe-too-large](ERROR-PATTERNS.md#describe-too-large).
1622
+ - `empty` — the mesh has zero triangles; see
1623
+ [ERROR-PATTERNS.md#describe-empty](ERROR-PATTERNS.md#describe-empty).
1624
+ - `unreadable` — `solid.toMesh()` itself threw; see
1625
+ [ERROR-PATTERNS.md#describe-unreadable](ERROR-PATTERNS.md#describe-unreadable).
1626
+
1627
+ `not-manifold` and `empty` describe real states `describe()` itself checks for and can
1628
+ return through any caller that hands it a `Solid` directly, but through the CLI they are
1629
+ largely theoretical: `kernel.import()`'s own registration validation rejects an empty or
1630
+ a wide-open (non-manifold) mesh *before* a `Solid` ever exists to describe, so
1631
+ `partforge describe` on such a file fails earlier, as a generic import error (the
1632
+ `crash()` path every other verb shares — `describe: import "x": mesh is not a solid
1633
+ after repair…`) rather than as a structured `{error: "not-manifold"}` / `{error:
1634
+ "empty"}` report. See
1635
+ [ERROR-PATTERNS.md#import-mesh-not-solid](ERROR-PATTERNS.md#import-mesh-not-solid) for
1636
+ that failure. `describe-unreadable`'s own ERROR-PATTERNS entry already draws this same
1637
+ line for its own code (a `k.import` parse failure is a *different*, separately-thrown
1638
+ error, not `describe-unreadable`); this is the general version of that point.
1639
+
1640
+ A `budget-exceeded` report (the acceptance loop ran out of boolean attempts before the
1641
+ residual converged) is not one of these — it's a `warning` on an otherwise-valid report,
1642
+ same tier as low coverage, not a reason to exit non-zero; raise `--budget` and re-run.
1643
+ `compactDescribe()` surfaces it exactly where the low-coverage banner lives, and shows
1644
+ both together if a report happens to earn both — an exhausted budget is exactly the kind
1645
+ of run that also leaves coverage low, and neither warning is allowed to mask the other.
1646
+ See [ERROR-PATTERNS.md#describe-budget-exceeded](ERROR-PATTERNS.md#describe-budget-exceeded).
1647
+
1648
+ **The loop this completes.** `describe` exists to feed a specific workflow, the one
1649
+ "Importing geometry" above sets up and this closes: `describe` the import to get its
1650
+ feature list and a proposed reconstruction (`suggestion` in the full report); write a
1651
+ parametric part from that proposal; bind the rebuild's sub-part to the import with
1652
+ `reference: "scan"`; and let `verify`'s `ref*` gates (`refXorVolume`, `refVolumeDeltaPct`,
1653
+ `refBboxDelta` — "The `reference` field and the deviation gate", above) hold the rebuild
1654
+ to the scan on every future `measure`/`verify` run, long after the one-time `describe`
1655
+ call that suggested it. `describe` proposes; `verify` keeps you honest.
1656
+
1497
1657
  ---
1498
1658
 
1499
1659
  ## Wiring a part into a runnable app
@@ -1788,7 +1948,11 @@ access. It's harmless to leave in when partforge is a normal install.)
1788
1948
  ## Testing a part
1789
1949
 
1790
1950
  Tests run under **Node 24** (`nvm use` first; the default shell Node is too old) via
1791
- `npx vitest run`. Build geometry directly off your part with a Manifold kernel:
1951
+ `npx vitest run`. The oracle half of this surface `measure`, `verify`,
1952
+ `describe`, gaps, match scoring — is also published on its own as
1953
+ `partforge/oracle` (browser-safe import closure); `partforge/testing` re-exports
1954
+ it, so either import works. Build geometry directly off your part with a Manifold
1955
+ kernel:
1792
1956
 
1793
1957
  ```js
1794
1958
  import { bootManifoldKernel, resolveDerived } from "partforge/testing";
@@ -601,6 +601,36 @@ between the Manifold preview and the OCCT STEP export.
601
601
 
602
602
  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.
603
603
 
604
+ ## describe-not-manifold
605
+
606
+ - **Symptom:** `describe` returns `{"error": "not-manifold"}`.
607
+ - **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.
608
+ - **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.
609
+
610
+ ## describe-too-large
611
+
612
+ - **Symptom:** `{"error": "too-large"}` naming a triangle count.
613
+ - **Cause:** Above 400,000 triangles the segmentation pass stops being usable in an interactive loop.
614
+ - **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.
615
+
616
+ ## describe-empty
617
+
618
+ - **Symptom:** `{"error": "empty"}`.
619
+ - **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.
620
+ - **Fix:** Check the `imports` source actually resolves. See [import-unknown-name](#import-unknown-name).
621
+
622
+ ## describe-budget-exceeded
623
+
624
+ - **Symptom:** The report carries `warning: "budget-exceeded"` and `score.xorFraction` is higher than expected.
625
+ - **Cause:** The acceptance loop hit its boolean budget before the residual converged. The report is partial but honestly scored — not wrong, just incomplete.
626
+ - **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.
627
+
628
+ ## describe-unreadable
629
+
630
+ - **Symptom:** `{"error": "unreadable"}`.
631
+ - **Cause:** `solid.toMesh()` threw rather than returning geometry — a corrupt or otherwise unrepresentable solid.
632
+ - **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.
633
+
604
634
  ## control-default-not-literal
605
635
 
606
636
  - **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.77.0",
3
+ "version": "0.79.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",
@@ -45,6 +45,10 @@
45
45
  "types": "./types/derive.d.ts",
46
46
  "default": "./src/framework/derive.js"
47
47
  },
48
+ "./oracle": {
49
+ "types": "./types/oracle.d.ts",
50
+ "default": "./src/oracle.js"
51
+ },
48
52
  "./testing": {
49
53
  "types": "./types/testing.d.ts",
50
54
  "default": "./src/testing.js"
@@ -66,6 +70,9 @@
66
70
  "derive": [
67
71
  "./types/derive.d.ts"
68
72
  ],
73
+ "oracle": [
74
+ "./types/oracle.d.ts"
75
+ ],
69
76
  "testing": [
70
77
  "./types/testing.d.ts"
71
78
  ]
@@ -10,11 +10,32 @@ import { normalizeOpentype, parseFont } from "./geometry/opentype-interop.js";
10
10
  import { ensureImports, resolveImports } from "./imports.js";
11
11
  import { safeName } from "./safe-name.js";
12
12
  import { exportSubParts, resolveParams, buildPosed } from "./part-model.js";
13
- import { measure } from "./oracle/measure.js";
14
- import { verify } from "./oracle/verify.js";
15
- import { buildView } from "./oracle/build.js";
16
- import { MATCH_VIEWS, rasterizeMeshMask, rasterizeRingsMask } from "./oracle/silhouette.js";
17
- import { matchViews } from "./oracle/match.js";
13
+
14
+ // The oracle loads LAZILY, per job family, never at worker boot. It is the largest
15
+ // JS payload in the worker's graph (measure/verify/build, silhouette/match, and the
16
+ // describe stack), and only the `inspect` and `describe` jobs run any of it — the
17
+ // generate/export hot path touches none. Each family below is a literal dynamic
18
+ // import(), which Vite splits into its own chunk under `worker.format: "es"` (this
19
+ // repo's config and partforge-cloud's both), so a user who never runs an oracle job
20
+ // never downloads or parses one. The module loader caches the namespace after the
21
+ // first await, so repeat jobs pay a resolved-promise tick, not a re-fetch.
22
+ // test/worker-layering.test.js's eager-closure guard holds this in place.
23
+ const loadInspect = () => Promise.all([
24
+ import("./oracle/build.js"),
25
+ import("./oracle/measure.js"),
26
+ import("./oracle/verify.js"),
27
+ ]);
28
+ const loadDescribe = () => Promise.all([
29
+ import("./oracle/describe.js"),
30
+ import("./oracle/describe/report.js"),
31
+ ]);
32
+
33
+ // One describe memo for the life of this worker, created alongside the stack's first
34
+ // load. Deliberately NOT swept on setPart the way solid-cache is: describe is pure in
35
+ // the mesh bytes (spec §4.1), so an edit can never invalidate it, and dropping it on
36
+ // rebind would throw away the single most expensive thing this worker computes for no
37
+ // reason at all. Keyed by content digest, so a genuinely changed file misses correctly.
38
+ let DESCRIBE_MEMO = null;
18
39
 
19
40
  // Handle one geometry job, posting results/progress via `post(msg, transfer?)`.
20
41
  // Backend-agnostic and part-agnostic: every part specific comes through `part`.
@@ -38,7 +59,7 @@ const bufferOf = (data) => (ArrayBuffer.isView(data) ? data.buffer : data);
38
59
  // cannot cost the others their scores (or the caller their geometry report).
39
60
  // {kind: "profile", rings: [[[x,y], ...], ...]} — millimetres, so it carries scale
40
61
  // {kind: "image", mask: {data, width, height}} — a photo, so it carries none
41
- function referenceMask(target) {
62
+ function referenceMask(target, rasterizeRingsMask) {
42
63
  if (target?.kind === "profile") return Array.isArray(target.rings) ? rasterizeRingsMask(target.rings) : null;
43
64
  if (target?.kind === "image") {
44
65
  const m = target.mask;
@@ -61,9 +82,15 @@ function referenceMask(target) {
61
82
  //
62
83
  // The six mesh masks are rasterized ONCE and shared across every target — the targets
63
84
  // are the cheap side of this (a couple of reference masks), the part is not.
64
- function scoreMatchTargets(built, targets, onProgress) {
85
+ async function scoreMatchTargets(built, targets, onProgress) {
65
86
  if (!targets?.length) return null;
66
87
  try {
88
+ // Loaded here, past the early return: an inspect with no matchTargets — the
89
+ // common case — never pays for the rasterizer.
90
+ const [{ MATCH_VIEWS, rasterizeMeshMask, rasterizeRingsMask }, { matchViews }] = await Promise.all([
91
+ import("./oracle/silhouette.js"),
92
+ import("./oracle/match.js"),
93
+ ]);
67
94
  const meshes = built.map((b) => b.mesh);
68
95
  const viewMasks = {};
69
96
  for (const view of MATCH_VIEWS) viewMasks[view] = rasterizeMeshMask(meshes, view);
@@ -71,7 +98,7 @@ function scoreMatchTargets(built, targets, onProgress) {
71
98
  const out = [];
72
99
  for (const target of targets) {
73
100
  try {
74
- const reference = referenceMask(target);
101
+ const reference = referenceMask(target, rasterizeRingsMask);
75
102
  if (!reference) continue;
76
103
  // scaleAware is the CALLER's promise that both sides are in millimetres, and
77
104
  // this is the caller: rings are mm and the mesh masks carry mmPerPx, so a
@@ -356,6 +383,7 @@ export async function handle(kernel, part, msg, post, opts = {}) {
356
383
  // unrecognized value must never quietly buy less checking than the caller
357
384
  // asked for.
358
385
  const quick = msg.checks === "quick";
386
+ const [{ buildView }, { measure }, { verify }] = await loadInspect();
359
387
  const view = msg.view ?? Object.keys(part.views)[0];
360
388
  const built = buildView(kernel, part, view, msg.params ?? {});
361
389
  const measured = measure(kernel, part, view, msg.params ?? {},
@@ -366,15 +394,44 @@ export async function handle(kernel, part, msg, post, opts = {}) {
366
394
  // The defaulted view, not msg.view: the seed below was measured on it, and
367
395
  // verify's seed reuse is only sound when both name the same view.
368
396
  view,
397
+ // This job's own (lazily-imported) measure, not verify's static fallback:
398
+ // the two are different module instances once measure.js loads through a
399
+ // dynamic import, and the seeding test's call-count mock only sees this
400
+ // one. One binding for the whole inspect keeps that countable — and true.
401
+ measureFn: measure,
369
402
  quick,
370
403
  seed: { params: msg.params ?? {}, result: measured },
371
404
  }),
372
405
  };
373
406
  // `match` is present only when the caller asked for it AND something scored, so
374
407
  // an inspect with no `matchTargets` answers on exactly the shape it always has.
375
- const match = scoreMatchTargets(built, msg.matchTargets, onProgress);
408
+ const match = await scoreMatchTargets(built, msg.matchTargets, onProgress);
376
409
  if (match) report.match = match;
377
410
  post({ type: "report", ...report }, match?.map((m) => m.delta.data.buffer) ?? []);
411
+ } else if (msg.type === "describe") {
412
+ // Semantic description of an IMPORTED mesh — not of the built part. The two are
413
+ // different questions: `inspect` asks "what did this source build?", `describe`
414
+ // asks "what is this file?". describe never touches the part's own geometry, which
415
+ // is why it takes an import name rather than a view.
416
+ //
417
+ // Manifold only, and not by choice on this path: mesh imports on OCCT are never
418
+ // attempted, so a describe job posted to an OCCT worker is a routing bug, not a
419
+ // fallback opportunity. It surfaces as an ordinary error rather than a reroute.
420
+ const [{ describe: describeMesh, describeMemo }, { compactDescribe }] = await loadDescribe();
421
+ const solid = kernel.import(msg.importName); // throws on an unknown name
422
+ // `_importDigest` is the backend's existing underscore side-channel (KERNEL-CONTRACT
423
+ // "Conformance classes") — the same digest already folded into every import cache key.
424
+ const digest = kernel._importDigest?.(msg.importName) ?? null;
425
+ DESCRIBE_MEMO ??= describeMemo();
426
+ const full = describeMesh(kernel, solid, {
427
+ name: msg.importName,
428
+ digest,
429
+ budget: msg.budget,
430
+ memo: DESCRIBE_MEMO,
431
+ });
432
+ // The compact shape is derived, never memoised separately: one memo entry per
433
+ // mesh, two views of it, no way for the two to drift.
434
+ post({ type: "describe-report", report: msg.compact ? compactDescribe(full) : full });
378
435
  }
379
436
  } catch (err) {
380
437
  // `subparts` (generate jobs only) tells the reroute policy which sub-parts the