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 +125 -1
- package/docs/AUTHORING-PARTS.md +160 -0
- package/docs/ERROR-PATTERNS.md +30 -0
- package/package.json +1 -1
- package/src/framework/jobs.js +31 -0
- package/src/framework/oracle/describe/accept.js +188 -0
- package/src/framework/oracle/describe/features/dressups.js +173 -0
- package/src/framework/oracle/describe/features/holes.js +129 -0
- package/src/framework/oracle/describe/features/prismatic.js +454 -0
- package/src/framework/oracle/describe/features/sweeps.js +233 -0
- package/src/framework/oracle/describe/fit.js +535 -0
- package/src/framework/oracle/describe/hints.js +91 -0
- package/src/framework/oracle/describe/limits.js +19 -0
- package/src/framework/oracle/describe/patterns.js +494 -0
- package/src/framework/oracle/describe/ransac.js +391 -0
- package/src/framework/oracle/describe/report.js +217 -0
- package/src/framework/oracle/describe/segment.js +498 -0
- package/src/framework/oracle/describe/snap.js +83 -0
- package/src/framework/oracle/describe/surface-graph.js +396 -0
- package/src/framework/oracle/describe/topology.js +121 -0
- package/src/framework/oracle/describe.js +538 -0
- package/src/testing.js +5 -0
- package/types/testing.d.ts +178 -0
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; }
|
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -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
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -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
package/src/framework/jobs.js
CHANGED
|
@@ -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
|
+
}
|