partforge 0.77.0 → 0.78.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
@@ -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.78.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",
@@ -15,6 +15,15 @@ import { verify } from "./oracle/verify.js";
15
15
  import { buildView } from "./oracle/build.js";
16
16
  import { MATCH_VIEWS, rasterizeMeshMask, rasterizeRingsMask } from "./oracle/silhouette.js";
17
17
  import { matchViews } from "./oracle/match.js";
18
+ import { describe as describeMesh, describeMemo } from "./oracle/describe.js";
19
+ import { compactDescribe } from "./oracle/describe/report.js";
20
+
21
+ // One describe memo for the life of this worker. Deliberately NOT swept on setPart the
22
+ // way solid-cache is: describe is pure in the mesh bytes (spec §4.1), so an edit can
23
+ // never invalidate it, and dropping it on rebind would throw away the single most
24
+ // expensive thing this worker computes for no reason at all. Keyed by content digest, so
25
+ // a genuinely changed file misses correctly.
26
+ const DESCRIBE_MEMO = describeMemo();
18
27
 
19
28
  // Handle one geometry job, posting results/progress via `post(msg, transfer?)`.
20
29
  // Backend-agnostic and part-agnostic: every part specific comes through `part`.
@@ -375,6 +384,28 @@ export async function handle(kernel, part, msg, post, opts = {}) {
375
384
  const match = scoreMatchTargets(built, msg.matchTargets, onProgress);
376
385
  if (match) report.match = match;
377
386
  post({ type: "report", ...report }, match?.map((m) => m.delta.data.buffer) ?? []);
387
+ } else if (msg.type === "describe") {
388
+ // Semantic description of an IMPORTED mesh — not of the built part. The two are
389
+ // different questions: `inspect` asks "what did this source build?", `describe`
390
+ // asks "what is this file?". describe never touches the part's own geometry, which
391
+ // is why it takes an import name rather than a view.
392
+ //
393
+ // Manifold only, and not by choice on this path: mesh imports on OCCT are never
394
+ // attempted, so a describe job posted to an OCCT worker is a routing bug, not a
395
+ // fallback opportunity. It surfaces as an ordinary error rather than a reroute.
396
+ const solid = kernel.import(msg.importName); // throws on an unknown name
397
+ // `_importDigest` is the backend's existing underscore side-channel (KERNEL-CONTRACT
398
+ // "Conformance classes") — the same digest already folded into every import cache key.
399
+ const digest = kernel._importDigest?.(msg.importName) ?? null;
400
+ const full = describeMesh(kernel, solid, {
401
+ name: msg.importName,
402
+ digest,
403
+ budget: msg.budget,
404
+ memo: DESCRIBE_MEMO,
405
+ });
406
+ // The compact shape is derived, never memoised separately: one memo entry per
407
+ // mesh, two views of it, no way for the two to drift.
408
+ post({ type: "describe-report", report: msg.compact ? compactDescribe(full) : full });
378
409
  }
379
410
  } catch (err) {
380
411
  // `subparts` (generate jobs only) tells the reroute policy which sub-parts the
@@ -0,0 +1,188 @@
1
+ // The confirm half of propose-then-confirm (spec §2.8). Segmentation and the feature
2
+ // rules produce CANDIDATES; this decides which are real, in what order, and how sure
3
+ // we are — by building each one and measuring it against the source mesh.
4
+ //
5
+ // Three properties are load-bearing.
6
+ //
7
+ // ONE CACHE BRACKET. geometry/solid-cache.js scopes retention to the current build's
8
+ // graph: each begin()/end() pair rebuilds the retained set and DISPOSES anything not
9
+ // re-used that round. A search loop that opened a bracket per candidate would evict its
10
+ // own shared subtrees on every iteration — quadratic rebuilds and WASM churn on a part
11
+ // that should be nearly free. So the whole loop runs inside exactly one bracket, and
12
+ // every candidate's geometry stays warm and shared for its duration.
13
+ //
14
+ // HARD BUDGET. Booleans are the cost centre and the candidate list is attacker-shaped
15
+ // (it grows with mesh complexity, not with anything we control). The budget counts
16
+ // CANDIDATE ATTEMPTS, not raw boolean calls — see the loop's own comment below for
17
+ // exactly what one attempt costs in real booleans, which varies by case — and running
18
+ // out DEGRADES INTO RESIDUAL rather than throwing: an over-budget describe returns a
19
+ // partial, honestly-scored report, which is exactly what a caller can act on.
20
+ //
21
+ // CONFIDENCE IS THE GAIN. A feature's confidence is the marginal xor reduction that
22
+ // admitted it, not a separate estimate invented afterwards. That is what makes the
23
+ // number falsifiable — it is a measurement of how much of the part that feature
24
+ // explains.
25
+ //
26
+ // The ONLY kernel-touching file in describe/.
27
+
28
+ // Named for what it actually counts (see the loop's own comment): CANDIDATE ATTEMPTS,
29
+ // not boolean operations. One attempt costs 0-2 real booleans depending on the
30
+ // candidate's op and whether a base body exists yet, so this is a bound on search
31
+ // WORK, not a boolean-op budget a caller could size against a WASM-call cost model.
32
+ export const DEFAULT_ATTEMPT_BUDGET = 48;
33
+ // A candidate must explain at least this fraction of the source volume to be worth a
34
+ // line in the report. Below it, the "feature" is tessellation noise.
35
+ const MIN_GAIN_FRACTION = 1e-4;
36
+
37
+ // Symmetric-difference volume — the same measure measure.js uses for the `reference`
38
+ // deviation fact, so a describe score and a verify ref-gate are directly comparable.
39
+ // One boolean and two volume reads; no meshing, no rasterisation. `cut`/`union`/
40
+ // `intersect` are binary methods ON A SOLID (`a.intersect(b)`), not kernel-level free
41
+ // functions — kernel.js's own JSDoc has the full Solid method table; there is no
42
+ // `kernel.intersect(a, b)`. Neither operand needs `.clone()` first: unlike the OCCT
43
+ // backend (whose replicad shapes ARE consumed by a transform — see AGENTS.md),
44
+ // Manifold's boolean methods return a new solid and leave both operands live and
45
+ // reusable, exactly as measure.js's own `solid.intersect(ref).volume()` and
46
+ // assembly.js's pairwise overlap check already rely on.
47
+ function xorVolume(a, b) {
48
+ const inter = a.intersect(b).volume();
49
+ return a.volume() + b.volume() - 2 * inter;
50
+ }
51
+
52
+ export function acceptCandidates(kernel, source, candidates, opts = {}) {
53
+ const budget = opts.budget ?? DEFAULT_ATTEMPT_BUDGET;
54
+ const sourceVolume = source.volume();
55
+ const accepted = [];
56
+ // Counts CANDIDATE ATTEMPTS (one per pass through the `for` loop below), not real
57
+ // boolean calls — see that loop's own comment for the exact per-attempt cost, which
58
+ // is 0, 1, or 2 real booleans depending on the candidate's op and whether `current`
59
+ // is null. Reported back as `budgetSpent` (name kept as-is — see that field's own
60
+ // comment on why) rather than renamed to `attemptsSpent`.
61
+ let attempts = 0;
62
+ // Every candidate object that reached the loop body at least once — keyed by
63
+ // reference, not by `cand.key`/`cand.featureKey`, since this file never assumes a
64
+ // candidate carries either (test/describe-accept.test.js's own fixtures only give
65
+ // theirs a bare `key`, and other callers may give none at all). This is what lets a
66
+ // caller (describe.js) tell "budget ran out before this candidate ever got a turn"
67
+ // apart from "this candidate got a turn — every round it was in — and never won
68
+ // one" for whatever's left in `pending` at the end (fix round 2, IMPORTANT 2): a
69
+ // rejected feature and a budget-starved one both report `volumeShare: null` and
70
+ // are otherwise indistinguishable, which matters to a rebuilder deciding whether to
71
+ // retry with a bigger `--budget` or accept that a feature genuinely doesn't fit.
72
+ // NOTE on the one case this deliberately does NOT collapse (round 3 CRITICAL fix): a
73
+ // `cut` candidate that gets a turn while `current === null` (`"nothing to cut from
74
+ // yet"`, below) never has a gain computed for it, so it is NOT added here — only a
75
+ // candidate that actually ran a boolean (a real gain measurement) or whose `.build()`
76
+ // genuinely threw counts as attempted. Earlier this Set included the no-base-yet case
77
+ // too, which made a starved `--budget` report `"rejected"` for a feature the search
78
+ // simply never reached with a base to cut from — provably wrong, since raising the
79
+ // budget alone (no code change) turned that same feature into a real, positive share.
80
+ const attempted = new Set();
81
+
82
+ // The single bracket. `describe:accept` is deliberately its own partition name, not a
83
+ // display sub-part's: the cross-partition hash index still lets it ADOPT geometry the
84
+ // viewer already built, while its own eviction at end() cannot throw away what the
85
+ // viewer is showing. Same reasoning as oracle/build.js's `oracle:view:` naming.
86
+ kernel.beginSubPart?.("describe:accept");
87
+ try {
88
+ let current = null; // the reconstruction so far
89
+ let currentXor = sourceVolume; // an empty reconstruction differs by the whole part
90
+ const pending = [...candidates];
91
+
92
+ while (pending.length && attempts < budget) {
93
+ let best = null;
94
+ for (const cand of pending) {
95
+ if (attempts >= budget) break;
96
+ // Real boolean cost of THIS attempt, not the `attempts` counter below (that
97
+ // counts the attempt itself, always by 1, regardless of how many WASM
98
+ // booleans it took) — spelled out here because it is not uniform and a
99
+ // reader sizing the budget against boolean-call cost needs the real number:
100
+ // • op "cut", current === null → 0 booleans (trial is set to null with
101
+ // no kernel call at all — "nothing to cut from yet" — and skipped below)
102
+ // • op "union", current === null → 1 boolean (no union call needed either,
103
+ // trial IS piece; the only boolean is xorVolume's own intersect below)
104
+ // • either op, current !== null → 2 booleans (the cut/union that builds
105
+ // `trial`, plus xorVolume's intersect)
106
+ // So budget=N bounds attempts, and — once any candidate has been accepted,
107
+ // which is the common case for a multi-feature part — real boolean work at
108
+ // roughly 2N, not N. Verified directly: a 4-candidate, 2-op-type search
109
+ // (1 accepted union then 1 accepted cut) reports `budgetSpent: 9` against
110
+ // 12 real boolean calls counted by wrapping the kernel.
111
+ let trial;
112
+ let noBaseYet = false; // "nothing to cut from yet" — no gain measured
113
+ try {
114
+ const piece = cand.build();
115
+ if (current === null && cand.op === "cut") {
116
+ trial = null;
117
+ noBaseYet = true;
118
+ } else {
119
+ trial = current === null ? piece
120
+ : cand.op === "cut" ? current.cut(piece)
121
+ : current.union(piece);
122
+ }
123
+ } catch {
124
+ // A candidate whose geometry will not build is not an error — it is simply
125
+ // not a description of this mesh. Drop it and keep going.
126
+ trial = null;
127
+ }
128
+ attempts++;
129
+ // Only count this as a real attempt (accept.js's own contract with describe.js
130
+ // — see the Set's declaration comment) when a gain was actually measured or the
131
+ // candidate's own geometry genuinely failed to build. `noBaseYet` is neither: no
132
+ // boolean ever ran and no verdict was reached, so leaving it out of `attempted`
133
+ // is what lets describe.js report `"budget"` instead of `"rejected"` for a cut
134
+ // candidate that only ever got a turn before any base body existed — round 3's
135
+ // CRITICAL finding: with the old blanket `attempted.add(cand)` above, THIS is
136
+ // exactly the case that reported `"rejected"` (`--budget 2`, the washer fixture)
137
+ // for a feature that becomes a real 19% share at `--budget 3` — the search never
138
+ // rejected it, it just never got there.
139
+ if (!noBaseYet) attempted.add(cand);
140
+ if (!trial) continue;
141
+ const xor = xorVolume(trial, source);
142
+ const gain = currentXor - xor;
143
+ if (gain > sourceVolume * MIN_GAIN_FRACTION && (!best || gain > best.gain)) {
144
+ best = { cand, trial, xor, gain };
145
+ }
146
+ }
147
+ if (!best) break; // nothing left improves the reconstruction
148
+
149
+ current = best.trial;
150
+ currentXor = best.xor;
151
+ accepted.push({
152
+ candidate: best.cand,
153
+ gain: best.gain / sourceVolume, // normalised: comparable across parts
154
+ cumulativeXor: currentXor,
155
+ order: accepted.length,
156
+ });
157
+ pending.splice(pending.indexOf(best.cand), 1);
158
+ }
159
+
160
+ const xorFraction = sourceVolume > 0 ? currentXor / sourceVolume : 1;
161
+ return {
162
+ accepted,
163
+ residual: { xorVolume: currentXor, xorFraction },
164
+ score: {
165
+ explainedVolumeFraction: Math.max(0, 1 - xorFraction),
166
+ xorFraction,
167
+ xorVolume: currentXor,
168
+ },
169
+ // Candidate attempts, not real boolean calls — see `attempts`'s own comment
170
+ // above and the per-attempt cost breakdown in the loop. Kept as `budgetSpent`
171
+ // (not renamed to `attemptsSpent`) because it is a documented cross-task
172
+ // interface field T12's orchestrator consumes by this exact name (SDD
173
+ // progress ledger, T10→T12 interface row); the field's MEANING is what moved,
174
+ // not its shape, so a rename here would be a breaking, undocumented surprise
175
+ // for that consumer rather than a fix.
176
+ budgetSpent: attempts,
177
+ budgetExceeded: attempts >= budget && pending.length > 0,
178
+ // Candidate OBJECTS (not keys) that reached at least one build+evaluate attempt
179
+ // — see this Set's own declaration comment above for why by-reference and what
180
+ // it does and doesn't distinguish. Every accepted candidate is trivially a
181
+ // member too (it can't have been accepted without at least one attempt); the
182
+ // caller only needs to consult this for candidates NOT in `accepted`.
183
+ attempted,
184
+ };
185
+ } finally {
186
+ kernel.endSubPart?.();
187
+ }
188
+ }