partforge 0.81.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 +28 -0
- package/docs/AUTHORING-PARTS.md +80 -5
- package/package.json +1 -1
- package/src/framework/geometry/probe.js +21 -8
- 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/parts/import-demo.js +22 -1
package/bin/cli.js
CHANGED
|
@@ -438,6 +438,34 @@ function printMeasure(r) {
|
|
|
438
438
|
console.log(` near-misses: ${r.nearMisses.length
|
|
439
439
|
? r.nearMisses.map((g) => `${g.a}×${g.b} (${g.distance.toFixed(2)}mm at [${g.at.map((n) => n.toFixed(1)).join(", ")}])`).join(", ")
|
|
440
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
|
+
}
|
|
441
469
|
}
|
|
442
470
|
|
|
443
471
|
function printVerify(v) {
|
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
|
|
|
@@ -1520,6 +1522,76 @@ keeps only the seams:
|
|
|
1520
1522
|
`meshTriangles`, `parseStl`, `parse3MF`); those exports are part of its contract.
|
|
1521
1523
|
|
|
1522
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:
|
|
1531
|
+
|
|
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
|
+
}),
|
|
1546
|
+
|
|
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
|
+
}
|
|
1554
|
+
```
|
|
1555
|
+
|
|
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).
|
|
1594
|
+
|
|
1523
1595
|
## Wiring a part into a runnable app
|
|
1524
1596
|
|
|
1525
1597
|
Three tiny glue files per part (copy from the demo). The worker statically imports
|
|
@@ -1953,8 +2025,9 @@ previously didn't; that's the fix working as intended, not a regression.
|
|
|
1953
2025
|
### Rule catalog
|
|
1954
2026
|
|
|
1955
2027
|
**Definition shape** — `missing-meta-title`, `missing-defaults`, `no-buildable-parts`,
|
|
1956
|
-
`missing-views`, `part-view-unknown` (all errors); `view-unused`,
|
|
1957
|
-
`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).
|
|
1958
2031
|
|
|
1959
2032
|
**Parameter schema** — `features-requires-sliders`, `features-requires-on`,
|
|
1960
2033
|
`control-key-not-in-defaults`, `control-default-not-primitive`,
|
|
@@ -2033,10 +2106,12 @@ internals (`hidden: true`). Grouping controls organizes them but does not
|
|
|
2033
2106
|
reduce the count — the check recurses into groups — so a group alone doesn't
|
|
2034
2107
|
bring a section back under budget.
|
|
2035
2108
|
|
|
2036
|
-
**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 —
|
|
2037
2111
|
`unknown-kernel-op`, `unknown-solid-op`, `invalid-op-options`, `build-throws`,
|
|
2038
|
-
`derive-throws`, `manifold-backend-uses-occt-op`,
|
|
2039
|
-
`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).
|
|
2040
2115
|
|
|
2041
2116
|
**Verify block** — `verify-unknown-metric`, `verify-unknown-subpart`,
|
|
2042
2117
|
`verify-bad-expr`, `verify-bad-pair-check`, `verify-unknown-process`,
|
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
|
}
|
|
@@ -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 ? [] : [
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { buildView } from "./build.js";
|
|
2
2
|
import { cachedBVH } from "./bvh.js";
|
|
3
3
|
import { assemblyOverlaps } from "../assembly.js";
|
|
4
|
+
import { resolveParams } from "../part-model.js";
|
|
4
5
|
import { meshGaps, pairKey, CONTACT_EPS, GAP_THRESHOLD } from "./gaps.js";
|
|
5
6
|
import { bounds, meshArea, meshCentroid } from "./mesh.js";
|
|
6
7
|
import { minWall, DIAGNOSTIC_SAMPLES } from "./min-wall.js";
|
|
@@ -12,6 +13,80 @@ const unionBounds = (list) => list.reduce(
|
|
|
12
13
|
{ min: [Infinity, Infinity, Infinity], max: [-Infinity, -Infinity, -Infinity] },
|
|
13
14
|
);
|
|
14
15
|
|
|
16
|
+
// ── probes ──────────────────────────────────────────────────────────────────
|
|
17
|
+
// Part-declared measurements: `probes: { name: (k, p, d) => Solid | JSON }`,
|
|
18
|
+
// pure functions with build's exact contract but whose result lands in the
|
|
19
|
+
// REPORT instead of the scene. The instrument a rebuild-against-reference
|
|
20
|
+
// workflow needs — before this, getting a cross-section's numbers out of the
|
|
21
|
+
// pipeline meant authoring throwaway `exportable: false` sub-parts and fishing
|
|
22
|
+
// their facts out of the sub-part list (the "Probes" feedback report).
|
|
23
|
+
// A Solid anywhere in the return value (duck-typed on volume+toMesh, the two
|
|
24
|
+
// queries the facts need) is replaced by a fact object; scalars/arrays/objects
|
|
25
|
+
// pass through; a throw becomes `{ error }` — probes are instrumentation, so
|
|
26
|
+
// they never crash the measurement and never gate `ok`.
|
|
27
|
+
|
|
28
|
+
const isSolid = (v) => v !== null && typeof v === "object"
|
|
29
|
+
&& typeof v.volume === "function" && typeof v.toMesh === "function";
|
|
30
|
+
|
|
31
|
+
function solidProbeFacts(solid) {
|
|
32
|
+
const mesh = solid.toMesh();
|
|
33
|
+
// Empty = the probe's boolean found nothing (a slab that misses the part).
|
|
34
|
+
// A first-class answer, not degenerate infinite bounds: "the reference has no
|
|
35
|
+
// material here" is exactly what a localizing probe is asked.
|
|
36
|
+
const empty = typeof solid.isEmpty === "function" ? solid.isEmpty() : mesh.triangles === 0;
|
|
37
|
+
if (empty) {
|
|
38
|
+
return { empty: true, bbox: null, bounds: null, centerOfMass: null,
|
|
39
|
+
volume: 0, surfaceArea: 0, triangleCount: 0, watertight: null, holes: null };
|
|
40
|
+
}
|
|
41
|
+
const b = bounds(mesh.positions);
|
|
42
|
+
return {
|
|
43
|
+
empty: false,
|
|
44
|
+
bbox: size(b),
|
|
45
|
+
bounds: { min: b.min, max: b.max },
|
|
46
|
+
centerOfMass: meshCentroid(mesh.positions, mesh.indices),
|
|
47
|
+
volume: solid.volume(),
|
|
48
|
+
surfaceArea: meshArea(mesh.positions, mesh.indices),
|
|
49
|
+
triangleCount: mesh.triangles,
|
|
50
|
+
// Mirrors the sub-part fact: answered by isEmpty where the backend has it
|
|
51
|
+
// (and this branch already means it said false), null where it can't say.
|
|
52
|
+
watertight: typeof solid.isEmpty === "function" ? true : null,
|
|
53
|
+
holes: typeof solid.genus === "function" ? solid.genus() : null,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Bounded so a self-referential or absurdly deep return value can't hang the
|
|
58
|
+
// report; past the cap the value is summarized rather than walked.
|
|
59
|
+
const MAX_PROBE_VALUE_DEPTH = 4;
|
|
60
|
+
function resolveProbeValue(v, depth = 0) {
|
|
61
|
+
if (isSolid(v)) return solidProbeFacts(v);
|
|
62
|
+
if (v === null || typeof v !== "object") {
|
|
63
|
+
return typeof v === "function" ? { error: "probe returned a function — return a Solid or plain JSON" } : v;
|
|
64
|
+
}
|
|
65
|
+
if (depth >= MAX_PROBE_VALUE_DEPTH) return { error: `probe value deeper than ${MAX_PROBE_VALUE_DEPTH} levels` };
|
|
66
|
+
if (Array.isArray(v)) return v.map((x) => resolveProbeValue(x, depth + 1));
|
|
67
|
+
return Object.fromEntries(Object.entries(v).map(([key, x]) => [key, resolveProbeValue(x, depth + 1)]));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Evaluate every declared probe with resolved (p, d). Reads all solid facts
|
|
71
|
+
// eagerly, so the caller may free the kernel's objects afterwards. Never
|
|
72
|
+
// throws: each probe's failure is its own `{ error }` entry.
|
|
73
|
+
function evaluateProbes(kernel, part, params) {
|
|
74
|
+
const { p, d } = resolveParams(part, params);
|
|
75
|
+
// Oracle-owned cache round, same reasoning as buildView's: probe geometry must
|
|
76
|
+
// not evict what the viewer is showing, and the next round evicts this one.
|
|
77
|
+
kernel.beginSubPart?.("oracle:probes");
|
|
78
|
+
try {
|
|
79
|
+
return Object.fromEntries(Object.entries(part.probes).map(([name, fn]) => {
|
|
80
|
+
try {
|
|
81
|
+
if (typeof fn !== "function") throw new Error("probe must be a function (k, p, d)");
|
|
82
|
+
return [name, resolveProbeValue(fn(kernel, p, d))];
|
|
83
|
+
} catch (e) {
|
|
84
|
+
return [name, { error: e?.message || String(e) }];
|
|
85
|
+
}
|
|
86
|
+
}));
|
|
87
|
+
} finally { kernel.endSubPart?.(); }
|
|
88
|
+
}
|
|
89
|
+
|
|
15
90
|
// Headless geometric report for one view of a part (Manifold-only). Reads exact
|
|
16
91
|
// solid facts (volume/genus/emptiness) and mesh facts (bbox/area/triangles), plus
|
|
17
92
|
// the assembly overlap check plus pair gap distances (near misses are reported,
|
|
@@ -100,6 +175,15 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
|
|
|
100
175
|
};
|
|
101
176
|
});
|
|
102
177
|
|
|
178
|
+
// Declared probes, evaluated regardless of view (they are part-level facts —
|
|
179
|
+
// per-view probes were exactly the annoyance this replaces) and before
|
|
180
|
+
// assemblyOverlaps/cleanup below frees the kernel's objects. `opts.probes:
|
|
181
|
+
// false` skips them: verify's per-case re-measures pass it because no gate
|
|
182
|
+
// reads probe values, so re-running their booleans per case buys nothing.
|
|
183
|
+
const probes = opts.probes !== false && part.probes && Object.keys(part.probes).length
|
|
184
|
+
? evaluateProbes(kernel, part, params)
|
|
185
|
+
: undefined;
|
|
186
|
+
|
|
103
187
|
// Pair surface distances from the meshes already built — no kernel dependency,
|
|
104
188
|
// so this reads on OCCT too. nearMisses = the issue-#29 signal: pairs that
|
|
105
189
|
// *almost* touch; overlapping pairs are excluded by name (a fully-contained
|
|
@@ -155,6 +239,9 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
|
|
|
155
239
|
overlaps,
|
|
156
240
|
gaps,
|
|
157
241
|
nearMisses,
|
|
242
|
+
// Present only when the part declares probes AND this run evaluated them —
|
|
243
|
+
// a probe error stays inside its own entry and never reaches `ok` below.
|
|
244
|
+
...(probes ? { probes } : {}),
|
|
158
245
|
ok: subparts.every((s) => s.watertight !== false) && overlaps.length === 0,
|
|
159
246
|
};
|
|
160
247
|
}
|
|
@@ -266,7 +266,10 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
|
|
|
266
266
|
const key = signature(params);
|
|
267
267
|
if (memo.has(key)) return memo.get(key);
|
|
268
268
|
if (quick) return null; // a case the seed does not cover — reported, never built
|
|
269
|
-
|
|
269
|
+
// `probes: false` — no gate reads probe values, so re-running their booleans
|
|
270
|
+
// for every case buys nothing. (A seed measured WITH probes is a superset in
|
|
271
|
+
// the same way a min-wall seed is: the extra key is simply never read here.)
|
|
272
|
+
memo.set(key, measureFn(kernel, part, view, params, { minWall: needMinWall, probes: false }));
|
|
270
273
|
return memo.get(key);
|
|
271
274
|
};
|
|
272
275
|
|
package/src/parts/import-demo.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// Reference part for docs/AUTHORING-PARTS.md's "Importing geometry" section —
|
|
2
|
-
//
|
|
2
|
+
// and for its "Probes" section (the `probes` block below measures the import
|
|
3
|
+
// live into the measure report) — the worked example for BOTH import uses in
|
|
4
|
+
// one part:
|
|
3
5
|
// • reference — `ref` is a translucent ghost of the imported scan (never
|
|
4
6
|
// exported); `body` is a parametric rebuild of the same block, bound to
|
|
5
7
|
// the scan via `reference: "scan"` and held to it by the three ref*
|
|
@@ -108,6 +110,25 @@ export default {
|
|
|
108
110
|
// socket never touches `body` (see `mountOffsetX`). "reference" is the
|
|
109
111
|
// ghost-overlay view, browsed by hand or with an explicit view argument.
|
|
110
112
|
views: { assembly: { label: "Assembly" }, reference: { label: "Reference overlay" } },
|
|
113
|
+
// Probes — measurements that land in the `measure` report instead of the
|
|
114
|
+
// scene (docs/AUTHORING-PARTS.md "Probes"). Pure (k, p, d) functions like
|
|
115
|
+
// build; never rendered, never exported, reported for every view.
|
|
116
|
+
probes: {
|
|
117
|
+
// Paired 1 mm cross-sections of the rebuild and the scan at the same X
|
|
118
|
+
// station — the localizing instrument for the deviation gate above: when
|
|
119
|
+
// refXorVolume creeps up, slide the slab along X to find WHERE the two
|
|
120
|
+
// solids disagree instead of guessing from one whole-part number.
|
|
121
|
+
midSlab: (k, p) => {
|
|
122
|
+
const slab = () => k.box({ min: [9.5, -50, -50], max: [10.5, 50, 50] });
|
|
123
|
+
return {
|
|
124
|
+
body: k.box({ min: [0, 0, 0], max: [p.scanW, p.scanD, p.scanH] }).intersect(slab()),
|
|
125
|
+
scan: k.import("scan").intersect(slab()),
|
|
126
|
+
};
|
|
127
|
+
},
|
|
128
|
+
// A live reading straight off the import — the numbers `defaults` were
|
|
129
|
+
// measured from. Plain JSON passes through the report verbatim.
|
|
130
|
+
scanBounds: (k) => k.import("scan").boundingBox(),
|
|
131
|
+
},
|
|
111
132
|
verify: {
|
|
112
133
|
process: "fdm-pla",
|
|
113
134
|
expect: {
|