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 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) {
@@ -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()` against a geometry-free probe
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`, `build-runaway` (errors);
2039
- `nondeterministic-build` (warning, from diffing two probe runs).
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.81.0",
3
+ "version": "0.82.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",
@@ -155,21 +155,34 @@ export function createValidatingProbe({ maxOps = MAX_PROBE_OPS } = {}) {
155
155
  }
156
156
 
157
157
  /**
158
- * Execute every sub-part's build() against a validating probe.
159
- * Never throws: a build error becomes an entry in `throws`, a runaway sets `runaway`.
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
- for (const [name, sp] of Object.entries(part?.parts ?? {})) {
166
- if (typeof sp?.build !== "function") continue; // no-buildable-parts already reports this
168
+ const run = (fn, onThrow) => {
167
169
  try {
168
- sp.build(probe.kernel, p, d);
170
+ fn(probe.kernel, p, d);
169
171
  } catch (e) {
170
- if (e instanceof ProbeRunawayError) { runaway = true; break; }
171
- throws.push({ subpart: name, message: e?.message || String(e) });
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 build can't be compared
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
- memo.set(key, measureFn(kernel, part, view, params, { minWall: needMinWall }));
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
 
@@ -1,5 +1,7 @@
1
1
  // Reference part for docs/AUTHORING-PARTS.md's "Importing geometry" section —
2
- // the worked example for BOTH import uses in one part:
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: {