partforge 0.77.0 → 0.79.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +125 -1
- package/docs/AUTHORING-PARTS.md +165 -1
- package/docs/ERROR-PATTERNS.md +30 -0
- package/package.json +8 -1
- package/src/framework/jobs.js +66 -9
- 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/oracle.js +27 -0
- package/src/testing.js +4 -12
- package/types/oracle.d.ts +27 -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
|
|
@@ -1788,7 +1948,11 @@ access. It's harmless to leave in when partforge is a normal install.)
|
|
|
1788
1948
|
## Testing a part
|
|
1789
1949
|
|
|
1790
1950
|
Tests run under **Node 24** (`nvm use` first; the default shell Node is too old) via
|
|
1791
|
-
`npx vitest run`.
|
|
1951
|
+
`npx vitest run`. The oracle half of this surface — `measure`, `verify`,
|
|
1952
|
+
`describe`, gaps, match scoring — is also published on its own as
|
|
1953
|
+
`partforge/oracle` (browser-safe import closure); `partforge/testing` re-exports
|
|
1954
|
+
it, so either import works. Build geometry directly off your part with a Manifold
|
|
1955
|
+
kernel:
|
|
1792
1956
|
|
|
1793
1957
|
```js
|
|
1794
1958
|
import { bootManifoldKernel, resolveDerived } from "partforge/testing";
|
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "partforge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.79.0",
|
|
4
4
|
"description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -45,6 +45,10 @@
|
|
|
45
45
|
"types": "./types/derive.d.ts",
|
|
46
46
|
"default": "./src/framework/derive.js"
|
|
47
47
|
},
|
|
48
|
+
"./oracle": {
|
|
49
|
+
"types": "./types/oracle.d.ts",
|
|
50
|
+
"default": "./src/oracle.js"
|
|
51
|
+
},
|
|
48
52
|
"./testing": {
|
|
49
53
|
"types": "./types/testing.d.ts",
|
|
50
54
|
"default": "./src/testing.js"
|
|
@@ -66,6 +70,9 @@
|
|
|
66
70
|
"derive": [
|
|
67
71
|
"./types/derive.d.ts"
|
|
68
72
|
],
|
|
73
|
+
"oracle": [
|
|
74
|
+
"./types/oracle.d.ts"
|
|
75
|
+
],
|
|
69
76
|
"testing": [
|
|
70
77
|
"./types/testing.d.ts"
|
|
71
78
|
]
|
package/src/framework/jobs.js
CHANGED
|
@@ -10,11 +10,32 @@ import { normalizeOpentype, parseFont } from "./geometry/opentype-interop.js";
|
|
|
10
10
|
import { ensureImports, resolveImports } from "./imports.js";
|
|
11
11
|
import { safeName } from "./safe-name.js";
|
|
12
12
|
import { exportSubParts, resolveParams, buildPosed } from "./part-model.js";
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
|
|
14
|
+
// The oracle loads LAZILY, per job family, never at worker boot. It is the largest
|
|
15
|
+
// JS payload in the worker's graph (measure/verify/build, silhouette/match, and the
|
|
16
|
+
// describe stack), and only the `inspect` and `describe` jobs run any of it — the
|
|
17
|
+
// generate/export hot path touches none. Each family below is a literal dynamic
|
|
18
|
+
// import(), which Vite splits into its own chunk under `worker.format: "es"` (this
|
|
19
|
+
// repo's config and partforge-cloud's both), so a user who never runs an oracle job
|
|
20
|
+
// never downloads or parses one. The module loader caches the namespace after the
|
|
21
|
+
// first await, so repeat jobs pay a resolved-promise tick, not a re-fetch.
|
|
22
|
+
// test/worker-layering.test.js's eager-closure guard holds this in place.
|
|
23
|
+
const loadInspect = () => Promise.all([
|
|
24
|
+
import("./oracle/build.js"),
|
|
25
|
+
import("./oracle/measure.js"),
|
|
26
|
+
import("./oracle/verify.js"),
|
|
27
|
+
]);
|
|
28
|
+
const loadDescribe = () => Promise.all([
|
|
29
|
+
import("./oracle/describe.js"),
|
|
30
|
+
import("./oracle/describe/report.js"),
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
// One describe memo for the life of this worker, created alongside the stack's first
|
|
34
|
+
// load. Deliberately NOT swept on setPart the way solid-cache is: describe is pure in
|
|
35
|
+
// the mesh bytes (spec §4.1), so an edit can never invalidate it, and dropping it on
|
|
36
|
+
// rebind would throw away the single most expensive thing this worker computes for no
|
|
37
|
+
// reason at all. Keyed by content digest, so a genuinely changed file misses correctly.
|
|
38
|
+
let DESCRIBE_MEMO = null;
|
|
18
39
|
|
|
19
40
|
// Handle one geometry job, posting results/progress via `post(msg, transfer?)`.
|
|
20
41
|
// Backend-agnostic and part-agnostic: every part specific comes through `part`.
|
|
@@ -38,7 +59,7 @@ const bufferOf = (data) => (ArrayBuffer.isView(data) ? data.buffer : data);
|
|
|
38
59
|
// cannot cost the others their scores (or the caller their geometry report).
|
|
39
60
|
// {kind: "profile", rings: [[[x,y], ...], ...]} — millimetres, so it carries scale
|
|
40
61
|
// {kind: "image", mask: {data, width, height}} — a photo, so it carries none
|
|
41
|
-
function referenceMask(target) {
|
|
62
|
+
function referenceMask(target, rasterizeRingsMask) {
|
|
42
63
|
if (target?.kind === "profile") return Array.isArray(target.rings) ? rasterizeRingsMask(target.rings) : null;
|
|
43
64
|
if (target?.kind === "image") {
|
|
44
65
|
const m = target.mask;
|
|
@@ -61,9 +82,15 @@ function referenceMask(target) {
|
|
|
61
82
|
//
|
|
62
83
|
// The six mesh masks are rasterized ONCE and shared across every target — the targets
|
|
63
84
|
// are the cheap side of this (a couple of reference masks), the part is not.
|
|
64
|
-
function scoreMatchTargets(built, targets, onProgress) {
|
|
85
|
+
async function scoreMatchTargets(built, targets, onProgress) {
|
|
65
86
|
if (!targets?.length) return null;
|
|
66
87
|
try {
|
|
88
|
+
// Loaded here, past the early return: an inspect with no matchTargets — the
|
|
89
|
+
// common case — never pays for the rasterizer.
|
|
90
|
+
const [{ MATCH_VIEWS, rasterizeMeshMask, rasterizeRingsMask }, { matchViews }] = await Promise.all([
|
|
91
|
+
import("./oracle/silhouette.js"),
|
|
92
|
+
import("./oracle/match.js"),
|
|
93
|
+
]);
|
|
67
94
|
const meshes = built.map((b) => b.mesh);
|
|
68
95
|
const viewMasks = {};
|
|
69
96
|
for (const view of MATCH_VIEWS) viewMasks[view] = rasterizeMeshMask(meshes, view);
|
|
@@ -71,7 +98,7 @@ function scoreMatchTargets(built, targets, onProgress) {
|
|
|
71
98
|
const out = [];
|
|
72
99
|
for (const target of targets) {
|
|
73
100
|
try {
|
|
74
|
-
const reference = referenceMask(target);
|
|
101
|
+
const reference = referenceMask(target, rasterizeRingsMask);
|
|
75
102
|
if (!reference) continue;
|
|
76
103
|
// scaleAware is the CALLER's promise that both sides are in millimetres, and
|
|
77
104
|
// this is the caller: rings are mm and the mesh masks carry mmPerPx, so a
|
|
@@ -356,6 +383,7 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
356
383
|
// unrecognized value must never quietly buy less checking than the caller
|
|
357
384
|
// asked for.
|
|
358
385
|
const quick = msg.checks === "quick";
|
|
386
|
+
const [{ buildView }, { measure }, { verify }] = await loadInspect();
|
|
359
387
|
const view = msg.view ?? Object.keys(part.views)[0];
|
|
360
388
|
const built = buildView(kernel, part, view, msg.params ?? {});
|
|
361
389
|
const measured = measure(kernel, part, view, msg.params ?? {},
|
|
@@ -366,15 +394,44 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
366
394
|
// The defaulted view, not msg.view: the seed below was measured on it, and
|
|
367
395
|
// verify's seed reuse is only sound when both name the same view.
|
|
368
396
|
view,
|
|
397
|
+
// This job's own (lazily-imported) measure, not verify's static fallback:
|
|
398
|
+
// the two are different module instances once measure.js loads through a
|
|
399
|
+
// dynamic import, and the seeding test's call-count mock only sees this
|
|
400
|
+
// one. One binding for the whole inspect keeps that countable — and true.
|
|
401
|
+
measureFn: measure,
|
|
369
402
|
quick,
|
|
370
403
|
seed: { params: msg.params ?? {}, result: measured },
|
|
371
404
|
}),
|
|
372
405
|
};
|
|
373
406
|
// `match` is present only when the caller asked for it AND something scored, so
|
|
374
407
|
// an inspect with no `matchTargets` answers on exactly the shape it always has.
|
|
375
|
-
const match = scoreMatchTargets(built, msg.matchTargets, onProgress);
|
|
408
|
+
const match = await scoreMatchTargets(built, msg.matchTargets, onProgress);
|
|
376
409
|
if (match) report.match = match;
|
|
377
410
|
post({ type: "report", ...report }, match?.map((m) => m.delta.data.buffer) ?? []);
|
|
411
|
+
} else if (msg.type === "describe") {
|
|
412
|
+
// Semantic description of an IMPORTED mesh — not of the built part. The two are
|
|
413
|
+
// different questions: `inspect` asks "what did this source build?", `describe`
|
|
414
|
+
// asks "what is this file?". describe never touches the part's own geometry, which
|
|
415
|
+
// is why it takes an import name rather than a view.
|
|
416
|
+
//
|
|
417
|
+
// Manifold only, and not by choice on this path: mesh imports on OCCT are never
|
|
418
|
+
// attempted, so a describe job posted to an OCCT worker is a routing bug, not a
|
|
419
|
+
// fallback opportunity. It surfaces as an ordinary error rather than a reroute.
|
|
420
|
+
const [{ describe: describeMesh, describeMemo }, { compactDescribe }] = await loadDescribe();
|
|
421
|
+
const solid = kernel.import(msg.importName); // throws on an unknown name
|
|
422
|
+
// `_importDigest` is the backend's existing underscore side-channel (KERNEL-CONTRACT
|
|
423
|
+
// "Conformance classes") — the same digest already folded into every import cache key.
|
|
424
|
+
const digest = kernel._importDigest?.(msg.importName) ?? null;
|
|
425
|
+
DESCRIBE_MEMO ??= describeMemo();
|
|
426
|
+
const full = describeMesh(kernel, solid, {
|
|
427
|
+
name: msg.importName,
|
|
428
|
+
digest,
|
|
429
|
+
budget: msg.budget,
|
|
430
|
+
memo: DESCRIBE_MEMO,
|
|
431
|
+
});
|
|
432
|
+
// The compact shape is derived, never memoised separately: one memo entry per
|
|
433
|
+
// mesh, two views of it, no way for the two to drift.
|
|
434
|
+
post({ type: "describe-report", report: msg.compact ? compactDescribe(full) : full });
|
|
378
435
|
}
|
|
379
436
|
} catch (err) {
|
|
380
437
|
// `subparts` (generate jobs only) tells the reroute policy which sub-parts the
|