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