partforge 0.80.0 → 0.82.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/bin/cli.js +50 -4
  2. package/docs/AUTHORING-PARTS.md +99 -161
  3. package/docs/ERROR-PATTERNS.md +6 -0
  4. package/package.json +1 -1
  5. package/src/framework/geometry/probe.js +21 -8
  6. package/src/framework/jobs.js +35 -12
  7. package/src/framework/lint/rules-build.js +12 -2
  8. package/src/framework/lint/rules-shape.js +19 -0
  9. package/src/framework/oracle/measure.js +87 -0
  10. package/src/framework/oracle/verify.js +4 -1
  11. package/src/framework/worker.js +6 -3
  12. package/src/oracle.js +18 -14
  13. package/src/parts/import-demo.js +22 -1
  14. package/types/oracle.d.ts +6 -11
  15. package/types/testing.d.ts +15 -177
  16. package/types/worker.d.ts +10 -2
  17. package/src/framework/oracle/describe/accept.js +0 -188
  18. package/src/framework/oracle/describe/features/dressups.js +0 -173
  19. package/src/framework/oracle/describe/features/holes.js +0 -129
  20. package/src/framework/oracle/describe/features/prismatic.js +0 -454
  21. package/src/framework/oracle/describe/features/sweeps.js +0 -233
  22. package/src/framework/oracle/describe/fit.js +0 -535
  23. package/src/framework/oracle/describe/hints.js +0 -91
  24. package/src/framework/oracle/describe/limits.js +0 -19
  25. package/src/framework/oracle/describe/patterns.js +0 -494
  26. package/src/framework/oracle/describe/ransac.js +0 -391
  27. package/src/framework/oracle/describe/report.js +0 -217
  28. package/src/framework/oracle/describe/segment.js +0 -498
  29. package/src/framework/oracle/describe/snap.js +0 -83
  30. package/src/framework/oracle/describe/surface-graph.js +0 -396
  31. package/src/framework/oracle/describe/topology.js +0 -121
  32. package/src/framework/oracle/describe.js +0 -643
package/bin/cli.js CHANGED
@@ -15,8 +15,6 @@ 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";
20
18
  import { renderViews } from "../src/testing/render.js";
21
19
  import {
22
20
  createPickServer, requestPicks, formatPickResult,
@@ -27,6 +25,25 @@ import { matchPattern } from "../src/testing/error-patterns.js";
27
25
  import { lintPart } from "../src/lint.js";
28
26
 
29
27
  const die = (msg) => { console.error(msg); process.exit(1); };
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
+ }
30
47
  const USAGE = "usage: partforge <lint|measure|render|describe|pick-serve|pick> …";
31
48
 
32
49
  // Crash contract (issue #27): with --json, a thrown error becomes structured
@@ -210,6 +227,7 @@ const commands = {
210
227
  die(`describe: "${importName}" is not a declared import of ${partPath} ` +
211
228
  `(have: ${Object.keys(part.imports ?? {}).join(", ") || "none"})`);
212
229
  }
230
+ const { describe: describeMesh, compactDescribe } = await loadOracle();
213
231
  const kernel = await bootKernel(part);
214
232
  const solid = kernel.import(importName);
215
233
  const report = describeMesh(kernel, solid, {
@@ -222,7 +240,7 @@ const commands = {
222
240
  writeFileSync(flags.out, JSON.stringify(report, null, 2));
223
241
  }
224
242
  if (flags.json) console.log(JSON.stringify(report, null, 2));
225
- else printDescribe(report, { surfaces: !!flags.surfaces });
243
+ else printDescribe(report, { surfaces: !!flags.surfaces }, compactDescribe);
226
244
  if (flags.out) console.log(`\nwrote ${flags.out}`);
227
245
  // A closed-set error exits non-zero; LOW COVERAGE does not. Coverage is a finding
228
246
  // the caller must be able to read, and an exit code that conflated the two would
@@ -420,6 +438,34 @@ function printMeasure(r) {
420
438
  console.log(` near-misses: ${r.nearMisses.length
421
439
  ? r.nearMisses.map((g) => `${g.a}×${g.b} (${g.distance.toFixed(2)}mm at [${g.at.map((n) => n.toFixed(1)).join(", ")}])`).join(", ")
422
440
  : "none"}`);
441
+ if (r.probes) {
442
+ // Solid-fact probes get the sub-part line treatment (in mm³ — probes are
443
+ // localization instruments, a slab's volume in cm³ rounds to noise), one
444
+ // level deep too so the common paired shape ({ mine, ref }) reads as lines
445
+ // rather than a JSON wall; anything else prints as JSON; a failed probe
446
+ // prints its error where the reader is.
447
+ const isFacts = (v) => v && typeof v === "object" && "empty" in v && "volume" in v;
448
+ const factsLine = (v) => (v.empty
449
+ ? `empty (no material in the probed region)`
450
+ : `bbox ${v.bbox.map((n) => n.toFixed(2)).join("×")} ` +
451
+ `bounds [${v.bounds.min.map((n) => n.toFixed(2)).join(", ")}]…[${v.bounds.max.map((n) => n.toFixed(2)).join(", ")}] ` +
452
+ `vol ${v.volume.toFixed(2)}mm³`);
453
+ console.log(` probes:`);
454
+ for (const [name, v] of Object.entries(r.probes)) {
455
+ if (v && typeof v === "object" && typeof v.error === "string") {
456
+ console.log(` ${name} ERROR: ${v.error}`);
457
+ } else if (isFacts(v)) {
458
+ console.log(` ${name} ${factsLine(v)}`);
459
+ } else if (v && typeof v === "object" && !Array.isArray(v) && Object.values(v).some(isFacts)) {
460
+ console.log(` ${name}:`);
461
+ for (const [key, sub] of Object.entries(v)) {
462
+ console.log(` ${key} ${isFacts(sub) ? factsLine(sub) : JSON.stringify(sub)}`);
463
+ }
464
+ } else {
465
+ console.log(` ${name} ${JSON.stringify(v)}`);
466
+ }
467
+ }
468
+ }
423
469
  }
424
470
 
425
471
  function printVerify(v) {
@@ -448,7 +494,7 @@ function printVerify(v) {
448
494
  // opts back in; `--json` always has everything. Reads the SAME compactDescribe() output
449
495
  // a model reads (not a hand-rolled subset), so what a human sees here and what an agent
450
496
  // sees over `--json` cannot drift apart.
451
- function printDescribe(report, { surfaces }) {
497
+ function printDescribe(report, { surfaces }, compactDescribe) {
452
498
  if (report.error) {
453
499
  console.error(`describe: ${report.error}${report.detail ? ` — ${report.detail}` : ""}`);
454
500
  return;
@@ -88,6 +88,8 @@ export default {
88
88
  },
89
89
  },
90
90
  views: { <name>: { label, default?, animations? } }, // view tabs; a view may own animations (below)
91
+ probes?, // { name: (k, p, d) => Solid | plain JSON } — measurements reported by
92
+ // measure/inspect, never rendered or exported (see "Probes" below)
91
93
  };
92
94
  ```
93
95
 
@@ -1496,166 +1498,99 @@ build: (k, p, d) => {
1496
1498
 
1497
1499
  ## Describing an imported mesh
1498
1500
 
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, symmetryrather than a triangle soup. On
1504
- `import-demo.js`, whose `scan` import is a plain 20×14×8mm block:
1501
+ `npx partforge describe <part-module>#<importName>` reads an already-declared import
1502
+ and emits a semantic feature report holes, bosses, pockets, extrusions, patterns,
1503
+ symmetry rather than a triangle soup, so an agent can rebuild an STL parametrically.
1504
+
1505
+ The engine behind it the semantic mesh oracle is **not part of this package**: it
1506
+ ships as a separate, closed package (`@pixiteapps/partforge-oracle`) whose docs carry
1507
+ the full report contract (the two report shapes, feature vocabulary, `volumeShare`
1508
+ semantics, coverage scores, budget behavior, and the closed error set). This repo
1509
+ keeps only the seams:
1510
+
1511
+ - **CLI** — `partforge describe` resolves the oracle package at call time and prints
1512
+ an install pointer when it is absent (`PARTFORGE_ORACLE` overrides the module
1513
+ specifier, which is how the oracle's own repo points this CLI at its working tree).
1514
+ - **Worker** — the `describe` job runs whatever `runWorker(part, { loadOracle })`
1515
+ injected; without a loader it answers a structured
1516
+ `{error: "oracle-unavailable"}` report (see
1517
+ [ERROR-PATTERNS.md#describe-oracle-unavailable](ERROR-PATTERNS.md#describe-oracle-unavailable)),
1518
+ never a stall. The loader resolves the oracle barrel: `describe`, `describeMemo`,
1519
+ `compactDescribe`.
1520
+ - **Helpers** — the oracle package peer-depends on this one and consumes
1521
+ `partforge/oracle`'s mesh/BVH helpers and file parsers (`bounds`, `meshArea`,
1522
+ `meshTriangles`, `parseStl`, `parse3MF`); those exports are part of its contract.
1523
+
1524
+
1525
+ ## Probes: measuring geometry into the report
1526
+
1527
+ A `probes` block turns the measure report into an instrument panel. Each probe is
1528
+ a pure `(k, p, d)` function with **build's exact contract** — same kernel handle,
1529
+ same resolved params and derived values — but its result lands in the **report**
1530
+ instead of the scene:
1505
1531
 
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%
1532
+ ```js
1533
+ probes: {
1534
+ // A Solid anywhere in the return value is measured into a fact object:
1535
+ // { empty, bbox, bounds, centerOfMass, volume, surfaceArea, triangleCount,
1536
+ // watertight, holes }
1537
+ slabX12: (k, p, d) => buildBody(k, p, d)
1538
+ .intersect(k.box({ min: [12, -25, -4], max: [13, 25, 4] })),
1539
+
1540
+ // The paired form — the localizing workhorse when a rebuild drifts from its
1541
+ // imported reference: the same thin slab through both solids, side by side.
1542
+ slabPair: (k, p, d) => ({
1543
+ mine: buildBody(k, p, d).intersect(k.box({ min: [12, -25, -4], max: [13, 25, 4] })),
1544
+ ref: k.import("scan").intersect(k.box({ min: [12, -25, -4], max: [13, 25, 4] })),
1545
+ }),
1513
1546
 
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)
1547
+ // Plain JSON passes through verbatim compute any number the solid queries
1548
+ // can reach (volume/boundingBox booleans, arc fits, whatever).
1549
+ xor: (k, p, d) => {
1550
+ const mine = buildBody(k, p, d), ref = k.import("scan");
1551
+ return mine.volume() + ref.volume() - 2 * mine.intersect(ref).volume();
1552
+ },
1553
+ }
1516
1554
  ```
1517
1555
 
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` proposes prismatic candidates from each feature's own measured
1600
- footprint — the cap's boundary loops extruded directly, arbitrary polygon outlines and
1601
- interior holes included, alongside the circle/cylinder path — so ordinary prismatic
1602
- parts now reconstruct well regardless of footprint shape. It does not yet reconstruct
1603
- revolves, fillets, chamfers, or shells — those are detected and reported (with
1604
- `volumeShareReason: "not-proposed"`) but never turned into a candidate that could win
1605
- volume back. Measured directly on this repo's own reference parts: `demo.js`
1606
- reconstructs 100% of its volume, `filleted-box.js` 94.9%, `bracket.js` 100%; a plain
1607
- tube and a hollow box still score ~0%, because curved-wall extrusions and shells remain
1608
- unproposed. The low-coverage banner fires wherever the worse score is low, so nothing
1609
- here is misreported — but a low `explainedVolumeFraction` on a turned or shelled part
1610
- means **"not yet reconstructable by this tool"**, not "not understood" or "broken." Read the FACTS (features, surfaces, patterns) as the ground
1611
- truth regardless of the volume score; read the volume score as a measure of how much of
1612
- that ground truth also comes with a working, boolean-verified rebuild recipe.
1613
-
1614
- **Closed error set.** Anything short of a programming mistake comes back as
1615
- `{error: "<code>", detail, diagnostic}` rather than a thrown exception — a CLI or an
1616
- agent can act on a code far better than on a stack trace — and every code has an
1617
- ERROR-PATTERNS.md entry:
1618
-
1619
- - `not-manifold` — the mesh still has open edges after repair; see
1620
- [ERROR-PATTERNS.md#describe-not-manifold](ERROR-PATTERNS.md#describe-not-manifold).
1621
- - `too-large` — over the 400,000-triangle segmentation ceiling; see
1622
- [ERROR-PATTERNS.md#describe-too-large](ERROR-PATTERNS.md#describe-too-large).
1623
- - `empty` — the mesh has zero triangles; see
1624
- [ERROR-PATTERNS.md#describe-empty](ERROR-PATTERNS.md#describe-empty).
1625
- - `unreadable` — `solid.toMesh()` itself threw; see
1626
- [ERROR-PATTERNS.md#describe-unreadable](ERROR-PATTERNS.md#describe-unreadable).
1627
-
1628
- `not-manifold` and `empty` describe real states `describe()` itself checks for and can
1629
- return through any caller that hands it a `Solid` directly, but through the CLI they are
1630
- largely theoretical: `kernel.import()`'s own registration validation rejects an empty or
1631
- a wide-open (non-manifold) mesh *before* a `Solid` ever exists to describe, so
1632
- `partforge describe` on such a file fails earlier, as a generic import error (the
1633
- `crash()` path every other verb shares — `describe: import "x": mesh is not a solid
1634
- after repair…`) rather than as a structured `{error: "not-manifold"}` / `{error:
1635
- "empty"}` report. See
1636
- [ERROR-PATTERNS.md#import-mesh-not-solid](ERROR-PATTERNS.md#import-mesh-not-solid) for
1637
- that failure. `describe-unreadable`'s own ERROR-PATTERNS entry already draws this same
1638
- line for its own code (a `k.import` parse failure is a *different*, separately-thrown
1639
- error, not `describe-unreadable`); this is the general version of that point.
1640
-
1641
- A `budget-exceeded` report (the acceptance loop ran out of boolean attempts before the
1642
- residual converged) is not one of these — it's a `warning` on an otherwise-valid report,
1643
- same tier as low coverage, not a reason to exit non-zero; raise `--budget` and re-run.
1644
- `compactDescribe()` surfaces it exactly where the low-coverage banner lives, and shows
1645
- both together if a report happens to earn both — an exhausted budget is exactly the kind
1646
- of run that also leaves coverage low, and neither warning is allowed to mask the other.
1647
- See [ERROR-PATTERNS.md#describe-budget-exceeded](ERROR-PATTERNS.md#describe-budget-exceeded).
1648
-
1649
- **The loop this completes.** `describe` exists to feed a specific workflow, the one
1650
- "Importing geometry" above sets up and this closes: `describe` the import to get its
1651
- feature list and a proposed reconstruction (`suggestion` in the full report); write a
1652
- parametric part from that proposal; bind the rebuild's sub-part to the import with
1653
- `reference: "scan"`; and let `verify`'s `ref*` gates (`refXorVolume`, `refVolumeDeltaPct`,
1654
- `refBboxDelta` — "The `reference` field and the deviation gate", above) hold the rebuild
1655
- to the scan on every future `measure`/`verify` run, long after the one-time `describe`
1656
- call that suggested it. `describe` proposes; `verify` keeps you honest.
1657
-
1658
- ---
1556
+ **Where they show up.** `npx partforge measure` prints a `probes:` section and
1557
+ includes `probes: { name: value }` in `--json`; the worker's `inspect` job carries
1558
+ the same key, so any host reporting measure output (e.g. an agent's check loop)
1559
+ sees probe values on every edit with no extra wiring. Probes are **part-level,
1560
+ not per-view**: every measured view reports them, so they never disappear because
1561
+ the "wrong" tab was measured.
1562
+
1563
+ **What they replace.** Before probes, getting a cross-section's numbers out of
1564
+ the pipeline meant authoring throwaway `exportable: false` sub-parts and fishing
1565
+ their facts out of the sub-part list polluting views, the control panel's
1566
+ mental model, and the overlap check. Probes are invisible to the viewer, the
1567
+ exporter, the assembly checks, and `verify` gates; they exist only in the report.
1568
+
1569
+ **Failure is contained.** A probe that throws reports `{ error: "…" }` in its
1570
+ own slot it never crashes the measurement and never flips the report's `ok`.
1571
+ An empty boolean result (a slab that misses the part) reports
1572
+ `{ empty: true, volume: 0 }` rather than degenerate infinite bounds "no
1573
+ material here" is a first-class answer for a localizing probe. Lint covers
1574
+ probes with the same pass as builds: a throwing probe is `probe-throws`, a
1575
+ malformed block is `invalid-probes`, and unknown ops / bad options / impurity
1576
+ are caught exactly as in `build`.
1577
+
1578
+ **Driving geometry from a live measurement.** Probes get numbers *out*. To feed
1579
+ a measurement *into* geometry, remember that `build` already holds a real
1580
+ kernel: `k.import("scan").boundingBox()` (and `.volume()`, and booleans between
1581
+ solids) work live inside any build, so a sub-part can size itself off another
1582
+ solid directly no probe needed. Keep it pure: the measurement is deterministic
1583
+ for a given import + params, which is exactly what the geometry cache assumes.
1584
+ To set parameter **defaults** from a reference (the "rebuild this STL" flow),
1585
+ declare a probe that reads the value, run `measure`, and bake the reported
1586
+ number into `defaults` — the probe then keeps watching it on every regen, so a
1587
+ swapped import shows up as a probe delta instead of silently stale defaults.
1588
+
1589
+ **Cost.** Probes run on every `measure`/`inspect` (including quick checks the
1590
+ agent loop is exactly who reads them), so keep them proportionate: a handful of
1591
+ thin-slab booleans is cheap; a dense sweep of whole-part XORs is not. `verify`'s
1592
+ per-case re-measures skip probes entirely (no gate reads them). The reference
1593
+ part for probes is [`src/parts/import-demo.js`](../src/parts/import-demo.js).
1659
1594
 
1660
1595
  ## Wiring a part into a runnable app
1661
1596
 
@@ -2090,8 +2025,9 @@ previously didn't; that's the fix working as intended, not a regression.
2090
2025
  ### Rule catalog
2091
2026
 
2092
2027
  **Definition shape** — `missing-meta-title`, `missing-defaults`, `no-buildable-parts`,
2093
- `missing-views`, `part-view-unknown` (all errors); `view-unused`,
2094
- `default-view-ambiguous` (warnings).
2028
+ `missing-views`, `part-view-unknown`, `invalid-probes` (all errors); `view-unused`,
2029
+ `default-view-ambiguous` (warnings). `invalid-probes` fires when a declared
2030
+ `probes` block isn't an object of functions (see "Probes" above).
2095
2031
 
2096
2032
  **Parameter schema** — `features-requires-sliders`, `features-requires-on`,
2097
2033
  `control-key-not-in-defaults`, `control-default-not-primitive`,
@@ -2170,10 +2106,12 @@ internals (`hidden: true`). Grouping controls organizes them but does not
2170
2106
  reduce the count — the check recurses into groups — so a group alone doesn't
2171
2107
  bring a section back under budget.
2172
2108
 
2173
- **Kernel API**, found by executing `build()` against a geometry-free probe
2109
+ **Kernel API**, found by executing `build()` and every declared probe, which
2110
+ shares build's `(k, p, d)` contract — against a geometry-free probe —
2174
2111
  `unknown-kernel-op`, `unknown-solid-op`, `invalid-op-options`, `build-throws`,
2175
- `derive-throws`, `manifold-backend-uses-occt-op`, `build-runaway` (errors);
2176
- `nondeterministic-build` (warning, from diffing two probe runs).
2112
+ `probe-throws`, `derive-throws`, `manifold-backend-uses-occt-op`,
2113
+ `build-runaway` (errors); `nondeterministic-build` (warning, from diffing two
2114
+ probe runs).
2177
2115
 
2178
2116
  **Verify block** — `verify-unknown-metric`, `verify-unknown-subpart`,
2179
2117
  `verify-bad-expr`, `verify-bad-pair-check`, `verify-unknown-process`,
@@ -601,6 +601,12 @@ 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-oracle-unavailable
605
+
606
+ - **Symptom:** A `describe` job answers `{"error": "oracle-unavailable"}`, or `partforge describe` exits with "describe needs the mesh-oracle package".
607
+ - **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.
608
+ - **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.
609
+
604
610
  ## describe-not-manifold
605
611
 
606
612
  - **Symptom:** `describe` returns `{"error": "not-manifold"}`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.80.0",
3
+ "version": "0.82.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",
@@ -155,21 +155,34 @@ export function createValidatingProbe({ maxOps = MAX_PROBE_OPS } = {}) {
155
155
  }
156
156
 
157
157
  /**
158
- * Execute every sub-part's build() against a validating probe.
159
- * Never throws: a build error becomes an entry in `throws`, a runaway sets `runaway`.
158
+ * Execute every sub-part's build() and every declared probe (`part.probes`,
159
+ * same (k, p, d) contract, see oracle/measure.js) against a validating probe.
160
+ * Never throws: a build error becomes an entry in `throws` (probe entries land
161
+ * in `probeThrows`), a runaway sets `runaway`.
160
162
  */
161
163
  export function runValidatingProbe(part, p, d, { maxOps = MAX_PROBE_OPS } = {}) {
162
164
  const probe = createValidatingProbe({ maxOps });
163
165
  const throws = [];
166
+ const probeThrows = [];
164
167
  let runaway = false;
165
- for (const [name, sp] of Object.entries(part?.parts ?? {})) {
166
- if (typeof sp?.build !== "function") continue; // no-buildable-parts already reports this
168
+ const run = (fn, onThrow) => {
167
169
  try {
168
- sp.build(probe.kernel, p, d);
170
+ fn(probe.kernel, p, d);
169
171
  } catch (e) {
170
- if (e instanceof ProbeRunawayError) { runaway = true; break; }
171
- throws.push({ subpart: name, message: e?.message || String(e) });
172
+ if (e instanceof ProbeRunawayError) { runaway = true; return false; }
173
+ onThrow(e?.message || String(e));
174
+ }
175
+ return true;
176
+ };
177
+ for (const [name, sp] of Object.entries(part?.parts ?? {})) {
178
+ if (typeof sp?.build !== "function") continue; // no-buildable-parts already reports this
179
+ if (!run(sp.build, (m) => throws.push({ subpart: name, message: m }))) break;
180
+ }
181
+ if (!runaway) {
182
+ for (const [name, fn] of Object.entries(part?.probes ?? {})) {
183
+ if (typeof fn !== "function") continue; // invalid-probes already reports this
184
+ if (!run(fn, (m) => probeThrows.push({ probe: name, message: m }))) break;
172
185
  }
173
186
  }
174
- return { calls: probe.calls, issues: probe.issues, used: probe.used, solidUsed: probe.solidUsed, throws, runaway };
187
+ return { calls: probe.calls, issues: probe.issues, used: probe.used, solidUsed: probe.solidUsed, throws, probeThrows, runaway };
175
188
  }
@@ -12,23 +12,29 @@ import { safeName } from "./safe-name.js";
12
12
  import { exportSubParts, resolveParams, buildPosed } from "./part-model.js";
13
13
 
14
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.
15
+ // JS payload in the worker's graph (measure/verify/build and silhouette/match), and
16
+ // only the `inspect` job runs any of it — the generate/export hot path touches none.
17
+ // Each family below is a literal dynamic import(), which Vite splits into its own
18
+ // chunk under `worker.format: "es"` (this repo's config and partforge-cloud's both),
19
+ // so a user who never runs an oracle job never downloads or parses one. The module
20
+ // loader caches the namespace after the first await, so repeat jobs pay a
21
+ // resolved-promise tick, not a re-fetch.
22
22
  // test/worker-layering.test.js's eager-closure guard holds this in place.
23
23
  const loadInspect = () => Promise.all([
24
24
  import("./oracle/build.js"),
25
25
  import("./oracle/measure.js"),
26
26
  import("./oracle/verify.js"),
27
27
  ]);
28
- const loadDescribe = () => Promise.all([
29
- import("./oracle/describe.js"),
30
- import("./oracle/describe/report.js"),
31
- ]);
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.
32
38
 
33
39
  // One describe memo for the life of this worker, created alongside the stack's first
34
40
  // load. Deliberately NOT swept on setPart the way solid-cache is: describe is pure in
@@ -417,7 +423,24 @@ export async function handle(kernel, part, msg, post, opts = {}) {
417
423
  // Manifold only, and not by choice on this path: mesh imports on OCCT are never
418
424
  // attempted, so a describe job posted to an OCCT worker is a routing bug, not a
419
425
  // fallback opportunity. It surfaces as an ordinary error rather than a reroute.
420
- const [{ describe: describeMesh, describeMemo }, { compactDescribe }] = await loadDescribe();
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();
421
444
  const solid = kernel.import(msg.importName); // throws on an unknown name
422
445
  // `_importDigest` is the backend's existing underscore side-channel (KERNEL-CONTRACT
423
446
  // "Conformance classes") — the same digest already folded into every import cache key.
@@ -50,6 +50,16 @@ export const BUILD_RULES = [
50
50
  "Fix the error in build(). This was raised with no kernel attached, so it is a fault in the build's own logic (bad arithmetic, a missing param, a null dereference) rather than a geometry failure.",
51
51
  `parts.${subpart}.build`)),
52
52
  },
53
+ {
54
+ // Same class of fault as build-throws, located at the probe. A throwing
55
+ // probe degrades at runtime to `{ error }` in the measure report rather
56
+ // than crashing anything — this rule is what makes it loud anyway.
57
+ id: "probe-throws",
58
+ run: ({ probe }) => probe().probeThrows.map(({ probe: name, message }) =>
59
+ err("probe-throws", `probe "${name}" threw during a geometry-free run: ${message}`,
60
+ "Fix the error in the probe function. This was raised with no kernel attached, so it is a fault in the probe's own logic (bad arithmetic, a missing param, a null dereference) rather than a geometry failure — at runtime it would report `{ error }` instead of a measurement.",
61
+ `probes.${name}`)),
62
+ },
53
63
  {
54
64
  id: "manifold-backend-uses-occt-op",
55
65
  run: ({ part, probe }) => {
@@ -75,9 +85,9 @@ export const BUILD_RULES = [
75
85
  id: "nondeterministic-build",
76
86
  run: ({ probe, probeAgain }) => {
77
87
  const a = probe();
78
- if (a.runaway || a.throws.length > 0) return []; // an aborted build can't be compared
88
+ if (a.runaway || a.throws.length > 0 || a.probeThrows.length > 0) return []; // an aborted run can't be compared
79
89
  const b = probeAgain();
80
- if (b.runaway || b.throws.length > 0) return [];
90
+ if (b.runaway || b.throws.length > 0 || b.probeThrows.length > 0) return [];
81
91
  if (JSON.stringify(a.calls) === JSON.stringify(b.calls)) return [];
82
92
  return [warn("nondeterministic-build",
83
93
  "two builds with identical parameters produced different kernel calls",
@@ -40,6 +40,25 @@ export const SHAPE_RULES = [
40
40
  `parts.${name}.build`));
41
41
  },
42
42
  },
43
+ {
44
+ // `probes` is optional; when present it must be an object of (k, p, d)
45
+ // functions — same contract as build, but the result lands in the measure
46
+ // report instead of the scene (see AUTHORING-PARTS.md "Probes").
47
+ id: "invalid-probes",
48
+ run: ({ part }) => {
49
+ if (part?.probes === undefined) return [];
50
+ if (!isPlainObject(part.probes)) {
51
+ return [err("invalid-probes", "`probes` must be an object mapping names to functions",
52
+ "Declare probes as `probes: { name: (k, p, d) => Solid | plain JSON }` — each is measured into the report by `partforge measure` and the inspect job.",
53
+ "probes")];
54
+ }
55
+ return Object.entries(part.probes)
56
+ .filter(([, fn]) => typeof fn !== "function")
57
+ .map(([name]) => err("invalid-probes", `probe "${name}" is not a function`,
58
+ "Every entry in `probes` must be a `(k, p, d)` function returning a Solid (measured into facts) or plain JSON (reported verbatim).",
59
+ `probes.${name}`));
60
+ },
61
+ },
43
62
  {
44
63
  id: "missing-views",
45
64
  run: ({ part }) => (isPlainObject(part?.views) && Object.keys(part.views).length > 0 ? [] : [