partforge 0.76.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
@@ -6,8 +6,8 @@
6
6
  import { parseArgs } from "node:util";
7
7
  import { spawnSync } from "node:child_process";
8
8
  import { pathToFileURL } from "node:url";
9
- import { resolve, dirname } from "node:path";
10
- import { writeFileSync, mkdirSync } from "node:fs";
9
+ import { resolve, dirname, basename } from "node:path";
10
+ import { writeFileSync, mkdirSync, readFileSync } from "node:fs";
11
11
  import { detectBackend } from "../src/framework/backend-select.js";
12
12
  import { fontsFor } from "../src/framework/fonts.js";
13
13
  import { viewAnimations, evaluate, cueAt } from "../src/framework/animation.js";
@@ -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
@@ -77,6 +79,21 @@ async function loadPart(partPath, usage) {
77
79
  return part;
78
80
  }
79
81
 
82
+ // The source rules (group 9) read the part's TEXT, which `loadPart` never sees —
83
+ // it imports the module, and evaluation is exactly what erases the defects those
84
+ // rules exist for (`13 / 3` is just a number by then). The entry module's own file
85
+ // is all we hand over: following relative imports is a deliberate non-goal, and a
86
+ // missing/unreadable file simply leaves `sources` off, which turns the group into
87
+ // a no-op rather than failing the command.
88
+ const readSources = (partPath) => {
89
+ try {
90
+ const path = basename(partPath);
91
+ return { files: { [path]: readFileSync(resolve(process.cwd(), partPath), "utf8") }, entrypoint: path };
92
+ } catch {
93
+ return undefined;
94
+ }
95
+ };
96
+
80
97
  // Pass the part's declared fonts through, mirroring the worker path (jobs.js) —
81
98
  // otherwise a part using a named font builds in the browser but dies headlessly
82
99
  // with `text2d: unknown font …`. A function-form `fonts` is resolved against
@@ -102,7 +119,8 @@ const commands = {
102
119
  try {
103
120
  const part = await loadPart(partPath, usage);
104
121
  const params = flags.params ? JSON.parse(flags.params) : undefined;
105
- const report = lintPart(part, { params });
122
+ const sources = readSources(partPath);
123
+ const report = lintPart(part, { params, sources });
106
124
  if (!flags.json) printLint(report);
107
125
  if (flags.out) {
108
126
  mkdirSync(dirname(resolve(flags.out)), { recursive: true });
@@ -131,7 +149,7 @@ const commands = {
131
149
  // milliseconds with a precise message rather than after a WASM boot and a
132
150
  // downstream error that doesn't name the cause. Warnings never gate measure.
133
151
  if (!flags["no-lint"]) {
134
- const lint = lintPart(part);
152
+ const lint = lintPart(part, { sources: readSources(partPath) });
135
153
  if (!lint.ok) {
136
154
  if (flags.json) console.log(JSON.stringify({ ok: false, lint }, null, 2));
137
155
  else printLint(lint);
@@ -170,6 +188,51 @@ const commands = {
170
188
  }
171
189
  },
172
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
+
173
236
  async render(args) {
174
237
  const usage = "usage: partforge render <part-module> [view] [--views iso,front] [--out <dir>] " +
175
238
  "[--params <json>] [--animation <name>] [--at <t[,t…]>] [--step <index|label>]";
@@ -379,13 +442,94 @@ function printVerify(v) {
379
442
  console.log(` result: ${f ? `${f} gate failure(s)` : "all gates passed"}${w ? `, ${w} warning(s)` : ""}`);
380
443
  }
381
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
+
382
522
  function printLint(r) {
383
523
  const all = [...r.errors, ...r.warnings, ...(r.notes ?? [])];
384
524
  if (all.length === 0) { console.log("lint: clean"); return; }
385
525
  console.log("lint:");
386
526
  for (const f of all) {
387
527
  const icon = f.severity === "error" ? "✗" : f.severity === "warning" ? "⚠" : "·";
388
- console.log(` ${icon} ${f.rule}${f.path ? ` ${f.path}` : ""}`);
528
+ // A source-rule finding carries file+line — the only location a reader can
529
+ // open. Print it alongside the accessor path (which is `""` for a finding
530
+ // about a token anywhere in the file) rather than instead of it.
531
+ const at = f.file ? ` ${f.file}:${f.line ?? "?"}` : "";
532
+ console.log(` ${icon} ${f.rule}${f.path ? ` ${f.path}` : ""}${at}`);
389
533
  console.log(` ${f.message}`);
390
534
  console.log(` hint: ${f.hint}${f.pattern ? ` (ERROR-PATTERNS.md#${f.pattern})` : ""}`);
391
535
  }
@@ -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
@@ -1878,6 +2038,16 @@ import { lintPart } from "partforge/lint";
1878
2038
  const { ok, errors, warnings } = lintPart(part, { params });
1879
2039
  ```
1880
2040
 
2041
+ `lintPart(part, { sources })` optionally takes the part's own source files
2042
+ (`{ files: { path: text }, entrypoint }` — `entrypoint` names the file holding the
2043
+ `PartDefinition`, defaulting to the first key) and unlocks a ninth rule group that
2044
+ reads the source itself, catching the defects evaluation erases. The CLI passes the
2045
+ module's own file automatically, so `partforge lint`/`measure` always run it; a
2046
+ programmatic caller that omits `sources` (or hands over a malformed one) just gets
2047
+ no findings from that group. Source findings carry `file` and `line` on top of the
2048
+ standard shape, and `SOURCE_RULE_IDS` names them — a host that gates rendering on
2049
+ lint errors uses it to keep them reported but non-blocking.
2050
+
1881
2051
  `partforge/lint` has **zero runtime dependencies** and never imports a geometry
1882
2052
  kernel or the DOM viewer, so it runs unchanged in Node, a Web Worker, a sandboxed
1883
2053
  iframe, and Deno. A worker also answers `{ type: "lint", params }` with
@@ -2056,6 +2226,32 @@ control that the control's own `allow` list would refuse — at build time it's
2056
2226
  swapped for `defaults[key]`, i.e. itself, so the part boots with no usable
2057
2227
  font; use a source `allow` accepts, or widen `allow`) (warning).
2058
2228
 
2229
+ **Source rules** — the ninth group, which runs only when the caller hands over
2230
+ `sources` (above) — `control-default-not-literal` (a control's `defaults` entry is
2231
+ written as something other than a plain literal: an expression like `13 / 3`, an
2232
+ array or object, a template literal, a `0x10`/`1_000` spelling. Hosts persist a
2233
+ panel edit by rewriting that value's span in the source, so a spelling the
2234
+ rewriter cannot read means the user's edit is silently lost on reload — write a
2235
+ plain decimal/string/boolean literal, or move the computation into `derive()`)
2236
+ (error); `impure-source-token` (the source contains `Math.random`, `Date.now`,
2237
+ `performance.now`, or an argless `new Date()` — replace it with a parameter or a
2238
+ `derive()` output) (warning). Only a default a **visible** control is actually
2239
+ **bound** to is checked: an unbound non-primitive default (a lookup table, an
2240
+ array of hole positions) is never rewritten by a panel save and stays legal and
2241
+ unflagged, and so is the default of a statically hidden control (`hidden: true`
2242
+ on the control, or on an enclosing group or section) — it renders no widget, so
2243
+ there is no panel edit to lose, and `hidden: true` is the documented idiom for an
2244
+ internal constant. A `when`-conditioned control is *not* hidden — it can appear,
2245
+ so its default is checked.
2246
+ `impure-source-token` is warning-tier because the behavioral
2247
+ `nondeterministic-build` probe stays the error authority on impurity — the source
2248
+ scan is the wider net that also catches an impure value stable within one probe
2249
+ pass. It scans `.js`/`.mjs` files only (prose in a `README.md` is not a build),
2250
+ and code only within them (comments and string/template *interiors* are blanked
2251
+ first), so an impurity token inside a `${…}` interpolation is not seen. It emits
2252
+ one finding per (file, token) pair, carrying the occurrence count and the first
2253
+ occurrence's line, rather than one per occurrence.
2254
+
2059
2255
  A rule that itself throws yields an `internal-rule-error` **warning** and the run
2060
2256
  continues: `lintPart` never throws and never blocks a part because of a linter bug.
2061
2257
 
@@ -601,6 +601,48 @@ 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
+
634
+ ## control-default-not-literal
635
+
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.
637
+ - **Cause:** The control's `defaults` entry is written as something other than a plain literal — an expression (`13 / 3`), an array or object, a template literal, a hex/`1_000` spelling. Hosts persist a panel edit by rewriting that value's span in the source, so a value the rewriter cannot read is skipped and the edit is silently lost. The evaluated-object lint cannot see this (`13 / 3` evaluates to an ordinary number); only the source says.
638
+ - **Fix:** Write the computed value as a plain decimal/string/boolean literal, or move the computation into `derive()`. `lintPart(part, { sources })` and the CLI report this as the error `control-default-not-literal` with file and line. Only a **visible** control's default is checked — a statically hidden one (`hidden: true` on the control, group or section) renders no widget, so there is no panel edit to lose, and an expression there is legitimate. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Linting" (Rule catalog → Source rules).
639
+
640
+ ## impure-source-token
641
+
642
+ - **Symptom:** The preview shows stale geometry after a parameter edit, or a part behaves differently across rebuilds with identical params — often intermittent.
643
+ - **Cause:** The source contains `Math.random`, `Date.now`, `performance.now`, or an argless `new Date()`. A build must be a pure function of `(k, p, d)`; the memoizing kernel hashes inputs, so an impure value silently serves stale geometry (see impure-build-stale-preview, above). The behavioral lint probe catches impurity only when it changes the recorded call sequence between two probe runs; a value stable within one pass escapes it, which is why the source scan warns on the token itself.
644
+ - **Fix:** Replace the impure value with a parameter or a `derive()` output. `new Date(0)` and other argument-carrying forms are deterministic and not flagged; only `.js`/`.mjs` files are scanned, so the same words in a `README.md` are prose. One finding is emitted per (file, token) pair, carrying the occurrence count and the first occurrence's line. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Caching & determinism".
645
+
604
646
  # Hardware library
605
647
 
606
648
  Reserved for `hardware-*` patterns (issue #30). No entries yet.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.76.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
@@ -17,8 +17,32 @@ import { ANIMATION_RULES } from "./rules-animations.js";
17
17
  import { PLACE_RULES } from "./rules-place.js";
18
18
  import { IMPORT_RULES } from "./rules-imports.js";
19
19
  import { FONT_RULES } from "./rules-fonts.js";
20
+ import { SOURCE_RULES } from "./rules-source.js";
20
21
 
21
- export const RULES = [...SHAPE_RULES, ...SCHEMA_RULES, ...BUILD_RULES, ...VERIFY_RULES, ...ANIMATION_RULES, ...PLACE_RULES, ...IMPORT_RULES, ...FONT_RULES];
22
+ export const RULES = [...SHAPE_RULES, ...SCHEMA_RULES, ...BUILD_RULES, ...VERIFY_RULES, ...ANIMATION_RULES, ...PLACE_RULES, ...IMPORT_RULES, ...FONT_RULES, ...SOURCE_RULES];
23
+
24
+ // A usable sources input, or null. Deliberately forgiving: lintPart's callers
25
+ // include hosted paths handing over user/LLM-authored trees, so a malformed
26
+ // shape means "no source rules", never a throw. Non-string file values are
27
+ // dropped per entry rather than voiding the whole map.
28
+ function normalizeSources(sources) {
29
+ if (!sources || typeof sources !== "object") return null;
30
+ const rawFiles = sources.files;
31
+ if (!rawFiles || typeof rawFiles !== "object") return null;
32
+ // Null-prototype so a file literally keyed `__proto__` is KEPT as an own
33
+ // property: `{}["__proto__"] = text` would set the prototype instead, quietly
34
+ // dropping that file from the scan while still counting toward `any`.
35
+ const files = Object.create(null);
36
+ let any = false;
37
+ for (const [path, text] of Object.entries(rawFiles)) {
38
+ if (typeof text !== "string") continue;
39
+ files[path] = text;
40
+ any = true;
41
+ }
42
+ if (!any) return null;
43
+ const entrypoint = typeof sources.entrypoint === "string" ? sources.entrypoint : Object.keys(files)[0];
44
+ return { files, entrypoint };
45
+ }
22
46
 
23
47
  // Every rule runs inside a guard. lintPart is called on a user-facing hosted path
24
48
  // (partforge-cloud's sandbox), and a linter that takes down the preview it exists to
@@ -74,7 +98,9 @@ export function lintContext(part, params) {
74
98
  /**
75
99
  * Lint a PartDefinition. Never throws.
76
100
  * @param {object} part the default-exported PartDefinition
77
- * @param {{params?: object}} [opts] params layered over part.defaults for the probe pass
101
+ * @param {{params?: object, sources?: {files?: Record<string, string>, entrypoint?: string}}} [opts]
102
+ * `params` are layered over part.defaults for the probe pass; `sources` is the part's own
103
+ * source text, which unlocks the source rules (Group 9) — omit it and lint behaves as before.
78
104
  * @returns {{ok: boolean, errors: object[], warnings: object[], notes: object[]}}
79
105
  */
80
106
  export function lintPart(part, opts) {
@@ -82,7 +108,7 @@ export function lintPart(part, opts) {
82
108
  // parameter only fires on `undefined` — a caller passing `lintPart(part, null)`
83
109
  // (a plausible downstream-harness call) would otherwise throw destructuring
84
110
  // `{ params }` out of `null` before this function's body ever runs.
85
- const { params } = opts ?? {};
111
+ const { params, sources } = opts ?? {};
86
112
  // lintContext already guards its own internals (see its comment above), but it
87
113
  // is user-authored data all the way down — wrap the call itself too, so a
88
114
  // failure mode neither of us has thought of still degrades to a report instead
@@ -102,6 +128,14 @@ export function lintPart(part, opts) {
102
128
  notes: [],
103
129
  };
104
130
  }
131
+ // Deliberately its OWN guard, outside the lintContext try above: a malformed
132
+ // `sources` input means "no source rules", never a broken part. `sources` is
133
+ // caller-supplied data (a throwing `files`/`entrypoint` getter, a Proxy whose
134
+ // `ownKeys` trap throws), and folding it into the block above would turn that
135
+ // into a `lint-context-error` — an id that is NOT in SOURCE_RULE_IDS, so a
136
+ // host filtering source findings out to keep them non-blocking would instead
137
+ // refuse to render a part that builds fine.
138
+ try { ctx.sources = normalizeSources(sources); } catch { ctx.sources = null; }
105
139
  const findings = runRules(RULES, ctx);
106
140
  // `p` (params merged from `defaults`) failed to build — every rule still ran
107
141
  // against the `{}` fallback (each guarded individually by runRules), but the