partforge 0.5.1 → 0.6.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
@@ -7,6 +7,7 @@ import { createManifoldKernel } from "../src/framework/geometry/manifold-backend
7
7
  import { detectBackend } from "../src/framework/geometry/probe.js";
8
8
  import { bootOcctKernel } from "../src/testing/occt.js";
9
9
  import { measure } from "../src/testing/measure.js";
10
+ import { verify } from "../src/testing/verify.js";
10
11
  import { renderViews } from "../src/testing/render.js";
11
12
  import { createPickServer, requestPicks, formatPickResult } from "../src/framework/pick-request/server.js";
12
13
 
@@ -64,11 +65,19 @@ if (["measure", "render"].includes(cmd)) {
64
65
  if (cmd === "measure") {
65
66
  const report = measure(kernel, part, view);
66
67
  printMeasure(report);
68
+ let vok = true;
69
+ const processFlag = typeof flags.process === "string" ? flags.process : undefined;
70
+ if ((part.verify || processFlag) && !flags["no-verify"]) {
71
+ const v = verify(kernel, part, { process: processFlag, view });
72
+ printVerify(v);
73
+ report.verify = v;
74
+ vok = v.ok;
75
+ }
67
76
  const file = `measure-${slug(report.part)}-${report.view}.json`;
68
77
  writeFileSync(file, JSON.stringify(report, null, 2));
69
78
  console.log(`\nwrote ${file}`);
70
79
  if (flags.json) console.log(JSON.stringify(report, null, 2));
71
- process.exit(report.ok ? 0 : 1);
80
+ process.exit(report.ok && vok ? 0 : 1);
72
81
  } else {
73
82
  const views = typeof flags.views === "string" ? flags.views.split(",") : undefined;
74
83
  const files = await renderViews(kernel, part, view, { views, out: flags.out || "render" });
@@ -93,3 +102,16 @@ function printMeasure(r) {
93
102
  console.log(` ── view bbox ${a.bbox.map((n) => n.toFixed(1)).join("×")} vol ${(a.volume / 1000).toFixed(2)}cm³ tris ${a.triangleCount}`);
94
103
  console.log(` overlaps: ${r.overlaps.length ? r.overlaps.map((o) => `${o.a}×${o.b} (${o.volume.toFixed(1)}mm³)`).join(", ") : "none"}`);
95
104
  }
105
+
106
+ function printVerify(v) {
107
+ console.log(`\nverify:`);
108
+ for (const c of v.cases) {
109
+ console.log(` ${c.name}`);
110
+ for (const ch of c.checks) {
111
+ const icon = ch.status === "pass" ? "✓" : ch.status === "fail" ? "✗" : ch.status === "warn" ? "⚠" : "·";
112
+ console.log(` ${icon} ${ch.subpart ?? "_view"} ${ch.metric} ${ch.expr} (${ch.message})`);
113
+ }
114
+ }
115
+ const f = v.failures.length, w = v.warnings.length;
116
+ console.log(` result: ${f ? `${f} gate failure(s)` : "all gates passed"}${w ? `, ${w} warning(s)` : ""}`);
117
+ }
@@ -106,7 +106,11 @@ entry pulls in the DOM viewer/controls, and your build functions run in a Web Wo
106
106
  | `s.cut(tool)` / `s.cutAll(tools[])` | boolean subtract (one / batch) |
107
107
  | `s.intersect(other)` | boolean intersection (Manifold; used by collision tests) |
108
108
  | `s.translate([x,y,z])` | move |
109
- | `s.rotate(deg, center, axis)` | rotate `deg` about `axis` through `center` |
109
+ | `s.rotate(deg, center, axis)` | **internal primitive** prefer `rotateX/Y/Z` / `rotateAbout` |
110
+ | `s.rotateX(deg)` / `s.rotateY(deg)` / `s.rotateZ(deg)` | rotate about a world axis through the origin |
111
+ | `s.rotateAbout({ axis, deg, through? })` | general rotation: `axis` = `"X"|"Y"|"Z"` or `[x,y,z]`; `through` = centre (default origin) |
112
+ | `s.along(dir)` | orient the canonical **+Z** build axis to point along `dir` (`"+X"|"-X"|"+Y"|"-Y"|"+Z"|"-Z"`) |
113
+ | `s.at([x,y,z])` | place an origin-built solid at a point (readable alias of `translate`) |
110
114
  | `s.mirror("XY"\|"XZ"\|"YZ")` | mirror across a plane |
111
115
  | `s.scale(factor, center?)` | uniform scale (single factor) about `center` (default origin) — scaling an off-origin part about the origin also moves it; pass a center (e.g. `s.boundingBox().center`) to resize in place |
112
116
  | `s.clone()` | independent copy (replicad consumes solids on transform) |
@@ -118,6 +122,41 @@ entry pulls in the DOM viewer/controls, and your build functions run in a Web Wo
118
122
  You normally only call the *make/combine/transform* ops; the framework handles
119
123
  `toMesh`/`toSTL`/`toIndexedMesh`/`toSTEP`. Units are millimetres.
120
124
 
125
+ ### Build-step style: orient → place, and batch features
126
+
127
+ Write build steps so intent is legible — an LLM (and a human) should not have to decode
128
+ magic vectors. Three habits:
129
+
130
+ - **Orient then place.** Build a primitive along its canonical **+Z** axis, point it with
131
+ `along(dir)`, then position it with `at([x,y,z])`:
132
+
133
+ ```js
134
+ // ✗ cryptic: which axis? what centre?
135
+ k.cylinder(r, r, L).rotate(-90, [0, 0, 0], [1, 0, 0]).translate([rp, y1, sz])
136
+ // ✓ legible
137
+ k.cylinder(r, r, L).along("+Y").at([rp, y1, sz])
138
+ ```
139
+
140
+ - **Rotate about a point with `rotateAbout`** when the axis isn't through the origin
141
+ (use `rotateX/Y/Z` for the common origin cases):
142
+
143
+ ```js
144
+ // ✗ .rotate(angle, [rp, 0, 0], [0, 0, 1])
145
+ // ✓
146
+ tool.rotateAbout({ axis: "Z", deg: angle, through: [rp, 0, 0] })
147
+ ```
148
+
149
+ - **Batch features** instead of reassigning through a cut-chain:
150
+
151
+ ```js
152
+ // ✗ body = body.cut(a); body = body.cut(b); body = body.cut(c);
153
+ // ✓
154
+ body.cutAll([a, b, c]) // and k.union([base, f1, f2]) for additive batches
155
+ ```
156
+
157
+ The bare `rotate(deg, center, axis)` remains available as the low-level primitive for
158
+ anything `rotateX/Y/Z`/`rotateAbout` can't express, but prefer the vocabulary above.
159
+
121
160
  ### Caching & determinism
122
161
 
123
162
  The preview kernel memoizes geometry by content hash, so editing a parameter only
@@ -409,6 +448,64 @@ The `measure` function is also exported for vitest (boot a Manifold kernel as in
409
448
 
410
449
  ---
411
450
 
451
+ ## Self-verification (the `verify` block)
452
+
453
+ A part can declare how it should be checked, co-located with its schema, so
454
+ `partforge measure` (and vitest) can prove it is both **printable** and **correct**.
455
+ Add an optional top-level `verify` block:
456
+
457
+ ```js
458
+ verify: {
459
+ process: "fdm-pla", // a DFM profile: fdm-pla | fdm-petg | resin, or an
460
+ // inline { bed:[x,y,z], minWall, clearance } object
461
+ cases: ["defaults", "M3"], // optional; default = defaults + every preset
462
+ expect: { // design intent, by sub-part name (+ "_view")
463
+ spacer: { holes: 1, bbox: "<=[60,60,60]", volume: "0.4..0.6cm3" },
464
+ _view: { overlaps: 0 },
465
+ },
466
+ }
467
+ ```
468
+
469
+ **What the profile gives you:** a hard **bed-fit** gate (the view bbox must fit `bed`)
470
+ and a **min-wall** warning. **What `expect` gives you:** per-sub-part assertions on the
471
+ facts `measure` already reports — `holes` (through-bores / genus), `volume`,
472
+ `surfaceArea`, `triangleCount`, `bbox`, `watertight`, `minWall`; and `_view` assertions
473
+ `bbox`, `volume`, `overlaps`.
474
+
475
+ **Assertion DSL:** a bare number means equality (`holes: 1`); `">=n"`, `"<=n"`, `">n"`,
476
+ `"<n"`, or a range `"a..b"`; an optional unit suffix `mm`/`cm`/`mm3`/`cm3`; and for
477
+ `bbox`, a componentwise vector `"<=[x,y,z]"` / `">=[x,y,z]"` where `*` skips an axis.
478
+ The parser is strict — a malformed assertion fails loudly.
479
+
480
+ **Gates vs. warnings:** exact facts are **gates** (a failure sets a non-zero exit code);
481
+ `minWall` is computed (a ray/shot wall-thickness measurement) and reported as a
482
+ **warning** — it flags walls below the profile's minimum but never fails the build.
483
+ `holes`/`watertight` are Manifold-only, so those assertions **skip** on OCCT parts
484
+ rather than fail.
485
+
486
+ **Running it:**
487
+
488
+ ```bash
489
+ npx partforge measure src/parts/<part>.js # auto-runs verify if a block exists
490
+ npx partforge measure src/parts/<part>.js --process resin # force/override a profile
491
+ npx partforge measure src/parts/<part>.js --no-verify # facts only
492
+ ```
493
+
494
+ …and in vitest:
495
+
496
+ ```js
497
+ import { verify } from "partforge/testing";
498
+ test("part is printable and correct", () => {
499
+ expect(verify(kernel, part).ok).toBe(true);
500
+ });
501
+ ```
502
+
503
+ Checks run across the **default config plus every preset** (or your `cases` list); a
504
+ preset that changes only parameters no on-screen sub-part reads is deduplicated, so
505
+ coverage is cheap.
506
+
507
+ ---
508
+
412
509
  ## Fillet & chamfer (automatic OCCT backend)
413
510
 
414
511
  Two backends build your part: **Manifold** (fast meshes — preview, STL, 3MF) and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.5.1",
3
+ "version": "0.6.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",
@@ -11,7 +11,13 @@
11
11
  * @property {() => {min:number[],max:number[],center:number[],size:number[]}} boundingBox axis-aligned bounds (query)
12
12
  * @property {(thickness:number, openFaces:object) => Solid} shell hollow inward (OCCT only); openFaces selector required
13
13
  * @property {(v: number[]) => Solid} translate
14
- * @property {(deg: number, center: number[], axis: number[]) => Solid} rotate
14
+ * @property {(deg: number, center: number[], axis: number[]) => Solid} rotate internal primitive — prefer rotateX/Y/Z / rotateAbout
15
+ * @property {(deg: number) => Solid} rotateX rotate about world X through the origin
16
+ * @property {(deg: number) => Solid} rotateY rotate about world Y through the origin
17
+ * @property {(deg: number) => Solid} rotateZ rotate about world Z through the origin
18
+ * @property {(o:{axis:"X"|"Y"|"Z"|number[], deg:number, through?:number[]}) => Solid} rotateAbout general rotation (legible)
19
+ * @property {(dir:"+X"|"-X"|"+Y"|"-Y"|"+Z"|"-Z") => Solid} along orient the canonical +Z build axis along dir
20
+ * @property {(v:number[]) => Solid} at place an origin-built solid at point v (alias of translate)
15
21
  * @property {(plane: "XY"|"XZ"|"YZ") => Solid} mirror
16
22
  * @property {(factor:number, center?:number[]) => Solid} scale uniform scale about center (default origin)
17
23
  * @property {() => number} volume solid volume in mm³ (Manifold; used by collision tests)
@@ -2,6 +2,7 @@ import { helixTube } from "./helix-tube.js";
2
2
  import { KernelCapabilityError } from "./errors.js";
3
3
  import { h } from "./solid-hash.js";
4
4
  import { createSolidCache } from "./solid-cache.js";
5
+ import { addSugar } from "./solid-sugar.js";
5
6
 
6
7
  const PLANE_NORMAL = { XY: [0, 0, 1], XZ: [0, 1, 0], YZ: [1, 0, 0] };
7
8
  // 'preview' = interactive view (fast); 'print' = STL export (high-res, used only
@@ -12,6 +13,18 @@ const SHARP_ANGLE = 35; // deg — same-surface edges sharper than this shade ha
12
13
  const COPLANAR_COS = Math.cos((5 * Math.PI) / 180); // edge lines: skip cut seams that bend less than 5° (coplanar)
13
14
  const MIN_EDGE2 = 0.01 * 0.01; // edge lines: drop sub-0.01mm segments (degenerate boolean slivers, not real features)
14
15
 
16
+ // true axis-angle rotation as a column-major 4x4 (manifold Mat4), translation 0
17
+ function axisAngleMat4(axis, deg) {
18
+ const len = Math.hypot(axis[0], axis[1], axis[2]) || 1;
19
+ const x = axis[0] / len, y = axis[1] / len, z = axis[2] / len;
20
+ const t = (deg * Math.PI) / 180, c = Math.cos(t), s = Math.sin(t), C = 1 - c;
21
+ const R00 = c + x*x*C, R01 = x*y*C - z*s, R02 = x*z*C + y*s;
22
+ const R10 = y*x*C + z*s, R11 = c + y*y*C, R12 = y*z*C - x*s;
23
+ const R20 = z*x*C - y*s, R21 = z*y*C + x*s, R22 = c + z*z*C;
24
+ // column-major: columns are images of the basis vectors; 4th column = translation (0)
25
+ return [R00, R10, R20, 0, R01, R11, R21, 0, R02, R12, R22, 0, 0, 0, 0, 1];
26
+ }
27
+
15
28
  export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
16
29
  const { Manifold, CrossSection } = wasm;
17
30
  const segs = SEGS[quality], tube = TUBE[quality];
@@ -58,7 +71,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
58
71
  return { positions, indices };
59
72
  }
60
73
 
61
- const wrap = (m, hash) => ({
74
+ const wrap = (m, hash) => addSugar({
62
75
  _m: m,
63
76
  _hash: hash,
64
77
  cut: (t) => cached(h("cut", hash, t._hash), () => T(m.subtract(t._m))),
@@ -80,9 +93,11 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
80
93
  isEmpty: () => m.isEmpty(),
81
94
  translate: (v) => wrap(T(m.translate(v)), h("translate", hash, v)),
82
95
  rotate: (deg, center, axis) => {
83
- const euler = [axis[0] * deg, axis[1] * deg, axis[2] * deg];
96
+ const nz = (axis[0] !== 0) + (axis[1] !== 0) + (axis[2] !== 0);
84
97
  const a = T(m.translate([-center[0], -center[1], -center[2]]));
85
- const b = T(a.rotate(euler));
98
+ const b = nz <= 1
99
+ ? T(a.rotate([axis[0] * deg, axis[1] * deg, axis[2] * deg])) // basis axis — euler is exact; unchanged
100
+ : T(a.transform(axisAngleMat4(axis, deg))); // general axis-angle
86
101
  return wrap(T(b.translate(center)), h("rotate", hash, deg, center, axis));
87
102
  },
88
103
  mirror: (plane) => wrap(T(m.mirror(PLANE_NORMAL[plane])), h("mirror", hash, plane)),
@@ -3,6 +3,7 @@
3
3
  // (makeCylinder, makeHelix+genericSweep, draw/extrude, cut/fuse) now live.
4
4
  import { toEdgeFinder } from "./edge-selector.js";
5
5
  import { toFaceFinder } from "./face-selector.js";
6
+ import { addSugar } from "./solid-sugar.js";
6
7
  const MESH = { preview: { tolerance: 0.1, angularTolerance: 0.5 }, print: { tolerance: 0.01, angularTolerance: 0.1 } };
7
8
 
8
9
  export function createOcctKernel(replicad) {
@@ -77,7 +78,7 @@ export function createOcctKernel(replicad) {
77
78
  return backup;
78
79
  };
79
80
 
80
- const wrap = (shape) => ({
81
+ const wrap = (shape) => addSugar({
81
82
  _s: shape,
82
83
  cut: (t) => wrap(shape.cut(t._s)),
83
84
  cutAll: (tools) => wrap(shape.cut(makeCompound(tools.map((t) => t._s)))),
@@ -0,0 +1,35 @@
1
+ // src/framework/geometry/solid-sugar.js
2
+ // Self-describing build-step vocabulary, defined ONCE over both geometry backends.
3
+ // Every Solid a backend's wrap() returns is passed through addSugar(), which attaches
4
+ // readable transform/placement methods composed purely from the solid's existing
5
+ // rotate()/translate() primitives — so the sugar is geometry-identical to the
6
+ // hand-written primitive calls, on Manifold and OCCT alike.
7
+ const ORIGIN = [0, 0, 0];
8
+ const AXIS = { X: [1, 0, 0], Y: [0, 1, 0], Z: [0, 0, 1] };
9
+
10
+ const SUGAR = {
11
+ rotateX(deg) { return this.rotate(deg, ORIGIN, [1, 0, 0]); },
12
+ rotateY(deg) { return this.rotate(deg, ORIGIN, [0, 1, 0]); },
13
+ rotateZ(deg) { return this.rotate(deg, ORIGIN, [0, 0, 1]); },
14
+ rotateAbout({ axis, deg, through = ORIGIN }) {
15
+ const ax = Array.isArray(axis) ? axis : AXIS[axis];
16
+ if (!ax) throw new Error(`rotateAbout: unknown axis ${JSON.stringify(axis)} (use "X"|"Y"|"Z" or a [x,y,z] vector)`);
17
+ return this.rotate(deg, through, ax);
18
+ },
19
+ along(dir) {
20
+ switch (dir) {
21
+ case "+Z": return this.translate(ORIGIN); // fresh handle, identity geometry — consistent with the other directions
22
+ case "-Z": return this.rotate(180, ORIGIN, [1, 0, 0]);
23
+ case "+Y": return this.rotate(-90, ORIGIN, [1, 0, 0]);
24
+ case "-Y": return this.rotate(90, ORIGIN, [1, 0, 0]);
25
+ case "+X": return this.rotate(90, ORIGIN, [0, 1, 0]);
26
+ case "-X": return this.rotate(-90, ORIGIN, [0, 1, 0]);
27
+ default: throw new Error(`along: unknown direction ${JSON.stringify(dir)} (use "+X"|"-X"|"+Y"|"-Y"|"+Z"|"-Z")`);
28
+ }
29
+ },
30
+ at(v) { return this.translate(v); },
31
+ };
32
+
33
+ export function addSugar(s) {
34
+ return Object.assign(s, SUGAR);
35
+ }
@@ -36,8 +36,10 @@ export function createViewer(container, part) {
36
36
  const key = new THREE.DirectionalLight(0xffffff, 1.4);
37
37
  key.position.set(8, 14, 10);
38
38
  scene.add(key);
39
- // 1 cm grid (mm units): 200 mm wide, 20 divisions -> 10 mm squares.
40
- let grid = new THREE.GridHelper(200, 20, THEME.dark.grid[0], THEME.dark.grid[1]);
39
+ // 1 cm grid (mm units): 300 mm wide, 30 divisions -> 10 mm (1 cm) squares.
40
+ const GRID_SIZE = 300, GRID_DIVS = 30;
41
+ let floorY = 0; // world Y of the grid plane; set to the part's bbox bottom in frameTo
42
+ let grid = new THREE.GridHelper(GRID_SIZE, GRID_DIVS, THEME.dark.grid[0], THEME.dark.grid[1]);
41
43
  scene.add(grid);
42
44
 
43
45
  // --- material + part groups -----------------------------------------------
@@ -146,6 +148,10 @@ export function createViewer(container, part) {
146
148
  const center = _box.getCenter(new THREE.Vector3());
147
149
  partsGroup.position.copy(center).multiplyScalar(-1); // centre assembly on the pivot
148
150
  const size = _box.getSize(new THREE.Vector3());
151
+ // Drop the grid to the bottom of the bounding box (model Z -> world Y), so it reads
152
+ // as a floor the part sits on rather than a plane through its middle.
153
+ floorY = -size.z / 2;
154
+ grid.position.y = floorY;
149
155
  const r = Math.max(size.x, size.y, size.z) || 12;
150
156
  camera.position.setLength(r * 2.6 + 6);
151
157
  controls.target.set(0, 0, 0);
@@ -179,7 +185,8 @@ export function createViewer(container, part) {
179
185
  const t = THEME[mode] ?? THEME.dark;
180
186
  scene.background = new THREE.Color(t.bg);
181
187
  scene.remove(grid);
182
- grid = new THREE.GridHelper(200, 20, t.grid[0], t.grid[1]);
188
+ grid = new THREE.GridHelper(GRID_SIZE, GRID_DIVS, t.grid[0], t.grid[1]);
189
+ grid.position.y = floorY; // keep the floor at the bbox bottom across theme swaps
183
190
  scene.add(grid);
184
191
  lineMaterial.color.set(t.line);
185
192
  }
package/src/parts/demo.js CHANGED
@@ -50,9 +50,19 @@ export default {
50
50
  build: (k, p, d) => {
51
51
  let s = k.cylinder(p.od / 2, p.od / 2, p.h);
52
52
  if (p.flange_d > 0) s = k.union([s, k.cylinder(p.flange_d / 2, p.flange_d / 2, p.flange_h)]);
53
- return s.cut(k.cylinder(d.boreR, d.boreR, d.cutH).translate([0, 0, -2]));
53
+ return s.cut(k.cylinder(d.boreR, d.boreR, d.cutH).at([0, 0, -2]));
54
54
  },
55
55
  },
56
56
  },
57
57
  views: { spacer: { label: "Spacer" } },
58
+ // Self-verification (see docs/AUTHORING-PARTS.md "Self-verification"): opt into the
59
+ // FDM-PLA process profile (bed-fit gate + min-wall warning) and pin the design intent
60
+ // — one through-bore, fits comfortably on the bed, no interpenetration.
61
+ verify: {
62
+ process: "fdm-pla",
63
+ expect: {
64
+ spacer: { holes: 1, bbox: "<=[60,60,60]" },
65
+ _view: { overlaps: 0 },
66
+ },
67
+ },
58
68
  };
@@ -37,7 +37,7 @@ export default {
37
37
  // the shortest edge it touches (here the fillets' bottom arcs), so it stops at
38
38
  // its valid maximum instead of mangling the bottom face.
39
39
  if (p.chamfer > 0) s = s.chamfer(p.chamfer, { inPlane: "XY", at: 0 }); // base edges
40
- if (p.bore > 0) s = s.cut(k.cylinder(p.bore / 2, p.bore / 2, p.h + 2).translate([p.w / 2, p.d / 2, -1]));
40
+ if (p.bore > 0) s = s.cut(k.cylinder(p.bore / 2, p.bore / 2, p.h + 2).at([p.w / 2, p.d / 2, -1]));
41
41
  return s;
42
42
  },
43
43
  },
@@ -0,0 +1,79 @@
1
+ // Assertion mini-DSL: parse a declared expectation into a normalized predicate.
2
+ // Numeric values are normalized to base units (mm for length, mm³ for volume) at
3
+ // parse time so the evaluator compares plain numbers. Strict: any unrecognized form
4
+ // throws an Error naming the offending string.
5
+
6
+ const UNIT = { mm: 1, cm: 10, mm3: 1, cm3: 1000 };
7
+
8
+ function toBase(numStr, unit) {
9
+ const n = Number(numStr);
10
+ if (!Number.isFinite(n)) throw new Error(`assertion: not a number: "${numStr}"`);
11
+ if (unit === undefined) return n;
12
+ if (!(unit in UNIT)) throw new Error(`assertion: unknown unit: "${unit}"`);
13
+ return n * UNIT[unit];
14
+ }
15
+
16
+ const NUM = "[-+]?[0-9]*\\.?[0-9]+";
17
+ const U = "(mm3|cm3|mm|cm)?";
18
+ const reScalar = new RegExp(`^(>=|<=|>|<)?\\s*(${NUM})\\s*${U}$`);
19
+ const reRange = new RegExp(`^(${NUM})\\s*\\.\\.\\s*(${NUM})\\s*${U}$`);
20
+ const reVec = /^(>=|<=)\s*\[\s*(.+?)\s*\]$/;
21
+
22
+ export function parseAssertion(expr) {
23
+ if (typeof expr === "number" || typeof expr === "boolean") return { op: "eq", value: expr };
24
+ if (typeof expr !== "string") throw new Error(`assertion: unsupported value ${JSON.stringify(expr)}`);
25
+ const s = expr.trim();
26
+
27
+ const vec = s.match(reVec);
28
+ if (vec) {
29
+ const parts = vec[2].split(",").map((t) => t.trim());
30
+ if (parts.length !== 3) throw new Error(`assertion: vector needs 3 components: "${expr}"`);
31
+ return { op: vec[1] === "<=" ? "vle" : "vge", vec: parts.map((t) => (t === "*" ? null : toBase(t, undefined))) };
32
+ }
33
+ const range = s.match(reRange);
34
+ if (range) return { op: "range", min: toBase(range[1], range[3] || undefined), max: toBase(range[2], range[3] || undefined) };
35
+
36
+ const sc = s.match(reScalar);
37
+ if (sc) {
38
+ const op = sc[1] ? { ">=": "gte", "<=": "lte", ">": "gt", "<": "lt" }[sc[1]] : "eq";
39
+ return { op, value: toBase(sc[2], sc[3] || undefined) };
40
+ }
41
+ throw new Error(`assertion: unrecognized form: "${expr}"`);
42
+ }
43
+
44
+ const EPS = 1e-6;
45
+ const approxEq = (a, b) => Math.abs(a - b) <= EPS + EPS * Math.abs(b);
46
+ const fmtVec = (v) => "[" + v.map((x) => (x === null ? "*" : x)).join(",") + "]";
47
+
48
+ export function evaluateAssertion(parsed, actual) {
49
+ switch (parsed.op) {
50
+ case "eq": {
51
+ const pass = typeof parsed.value === "boolean" ? actual === parsed.value : approxEq(actual, parsed.value);
52
+ return { pass, message: `${actual} ${pass ? "==" : "!="} ${parsed.value}` };
53
+ }
54
+ case "gte": return mk(actual >= parsed.value - EPS, actual, ">=", parsed.value);
55
+ case "lte": return mk(actual <= parsed.value + EPS, actual, "<=", parsed.value);
56
+ case "gt": return mk(actual > parsed.value, actual, ">", parsed.value);
57
+ case "lt": return mk(actual < parsed.value, actual, "<", parsed.value);
58
+ case "range": {
59
+ const pass = actual >= parsed.min - EPS && actual <= parsed.max + EPS;
60
+ return { pass, message: `${actual} ${pass ? "in" : "out of"} ${parsed.min}..${parsed.max}` };
61
+ }
62
+ case "vle":
63
+ case "vge": {
64
+ const ge = parsed.op === "vge";
65
+ let pass = true;
66
+ for (let i = 0; i < 3; i++) {
67
+ const lim = parsed.vec[i];
68
+ if (lim === null) continue;
69
+ if (ge ? actual[i] < lim - EPS : actual[i] > lim + EPS) pass = false;
70
+ }
71
+ return { pass, message: `${fmtVec(actual)} ${ge ? ">=" : "<="} ${fmtVec(parsed.vec)}` };
72
+ }
73
+ default: throw new Error(`assertion: unknown op "${parsed.op}"`);
74
+ }
75
+ }
76
+
77
+ function mk(pass, actual, opStr, value) {
78
+ return { pass, message: `${actual} ${pass ? opStr : "not " + opStr} ${value}` };
79
+ }
@@ -0,0 +1,166 @@
1
+ // src/testing/bvh.js
2
+ // Triangle BVH over a mesh in either Manifold non-indexed soup form (9 floats per
3
+ // triangle, no `indices`) or OCCT indexed form (`positions` = 3 floats/vertex +
4
+ // `indices` = 3 vertex-indices/triangle). A reusable spatial index: nearest ray hit
5
+ // (raycast) and nearest surface point (closestPoint, added alongside). AABB tree,
6
+ // median split on the widest centroid axis, slab ray–box test with pruning.
7
+
8
+ const LEAF = 4; // max triangles per leaf
9
+
10
+ // Triangles as [v0,v1,v2] coord triples, from either a Manifold non-indexed soup
11
+ // (positions = 9 floats/triangle, no indices) or an OCCT indexed mesh (positions =
12
+ // 3 floats/vertex + indices = 3 vertex-indices/triangle).
13
+ export function meshTriangles(mesh) {
14
+ const { positions, indices } = mesh;
15
+ if (indices) {
16
+ const n = indices.length / 3, out = new Array(n);
17
+ for (let t = 0; t < n; t++) {
18
+ const a = indices[3 * t] * 3, b = indices[3 * t + 1] * 3, c = indices[3 * t + 2] * 3;
19
+ out[t] = [[positions[a], positions[a + 1], positions[a + 2]],
20
+ [positions[b], positions[b + 1], positions[b + 2]],
21
+ [positions[c], positions[c + 1], positions[c + 2]]];
22
+ }
23
+ return out;
24
+ }
25
+ const n = positions.length / 9, out = new Array(n);
26
+ for (let t = 0; t < n; t++) {
27
+ const o = t * 9;
28
+ out[t] = [[positions[o], positions[o + 1], positions[o + 2]],
29
+ [positions[o + 3], positions[o + 4], positions[o + 5]],
30
+ [positions[o + 6], positions[o + 7], positions[o + 8]]];
31
+ }
32
+ return out;
33
+ }
34
+
35
+ function readTris(mesh) {
36
+ const triangles = meshTriangles(mesh);
37
+ return triangles.map(([v0, v1, v2], i) => {
38
+ const min = [Math.min(v0[0], v1[0], v2[0]), Math.min(v0[1], v1[1], v2[1]), Math.min(v0[2], v1[2], v2[2])];
39
+ const max = [Math.max(v0[0], v1[0], v2[0]), Math.max(v0[1], v1[1], v2[1]), Math.max(v0[2], v1[2], v2[2])];
40
+ return { i, v0, v1, v2, min, max, c: [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2] };
41
+ });
42
+ }
43
+
44
+ function aabbOf(items) {
45
+ const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
46
+ for (const it of items) for (let a = 0; a < 3; a++) { if (it.min[a] < min[a]) min[a] = it.min[a]; if (it.max[a] > max[a]) max[a] = it.max[a]; }
47
+ return { min, max };
48
+ }
49
+
50
+ function build(items) {
51
+ const box = aabbOf(items);
52
+ if (items.length <= LEAF) return { ...box, tris: items };
53
+ const ext = [box.max[0] - box.min[0], box.max[1] - box.min[1], box.max[2] - box.min[2]];
54
+ const axis = ext[0] >= ext[1] && ext[0] >= ext[2] ? 0 : ext[1] >= ext[2] ? 1 : 2;
55
+ const sorted = items.slice().sort((p, q) => p.c[axis] - q.c[axis]);
56
+ const mid = sorted.length >> 1;
57
+ const left = sorted.slice(0, mid), right = sorted.slice(mid);
58
+ if (left.length === 0 || right.length === 0) return { ...box, tris: items }; // degenerate split
59
+ return { ...box, left: build(left), right: build(right) };
60
+ }
61
+
62
+ // slab test: returns the entry distance if the ray meets [min,max] within (tMin,best], else Infinity
63
+ function rayBox(o, invD, min, max, tMin, best) {
64
+ let t0 = tMin, t1 = best;
65
+ for (let a = 0; a < 3; a++) {
66
+ let lo = (min[a] - o[a]) * invD[a], hi = (max[a] - o[a]) * invD[a];
67
+ if (lo > hi) { const tmp = lo; lo = hi; hi = tmp; }
68
+ if (lo > t0) t0 = lo; if (hi < t1) t1 = hi;
69
+ if (t0 > t1) return Infinity;
70
+ }
71
+ return t0;
72
+ }
73
+
74
+ // nearest point on triangle to P (Ericson), returns { point, d2 }
75
+ function closestOnTri(P, tri) {
76
+ const A = tri.v0, B = tri.v1, C = tri.v2;
77
+ const sub = (p, q) => [p[0]-q[0], p[1]-q[1], p[2]-q[2]];
78
+ const dot = (p, q) => p[0]*q[0] + p[1]*q[1] + p[2]*q[2];
79
+ const add = (p, q) => [p[0]+q[0], p[1]+q[1], p[2]+q[2]];
80
+ const mul = (p, s) => [p[0]*s, p[1]*s, p[2]*s];
81
+ const ab = sub(B,A), ac = sub(C,A), ap = sub(P,A);
82
+ const d1 = dot(ab,ap), d2 = dot(ac,ap);
83
+ let Q;
84
+ if (d1<=0&&d2<=0) Q = A;
85
+ else { const bp = sub(P,B), d3 = dot(ab,bp), d4 = dot(ac,bp);
86
+ if (d3>=0&&d4<=d3) Q = B;
87
+ else { const vc = d1*d4 - d3*d2;
88
+ if (vc<=0&&d1>=0&&d3<=0) Q = add(A, mul(ab, d1/(d1-d3)));
89
+ else { const cp = sub(P,C), d5 = dot(ab,cp), d6 = dot(ac,cp);
90
+ if (d6>=0&&d5<=d6) Q = C;
91
+ else { const vb = d5*d2 - d1*d6;
92
+ if (vb<=0&&d2>=0&&d6<=0) Q = add(A, mul(ac, d2/(d2-d6)));
93
+ else { const va = d3*d6 - d5*d4;
94
+ if (va<=0&&(d4-d3)>=0&&(d5-d6)>=0) Q = add(B, mul(sub(C,B), (d4-d3)/((d4-d3)+(d5-d6))));
95
+ else { const denom = 1/(va+vb+vc); Q = add(add(A, mul(ab, vb*denom)), mul(ac, vc*denom)); } } } } } }
96
+ const pq = sub(P, Q);
97
+ return { point: Q, d2: dot(pq, pq) };
98
+ }
99
+
100
+ // squared distance from point to an AABB (0 inside)
101
+ function distSqBox(p, min, max) {
102
+ let s = 0;
103
+ for (let a = 0; a < 3; a++) { const v = p[a] < min[a] ? min[a] - p[a] : p[a] > max[a] ? p[a] - max[a] : 0; s += v * v; }
104
+ return s;
105
+ }
106
+
107
+ // Möller–Trumbore; returns t>tMin or Infinity
108
+ function rayTri(o, d, tri, tMin) {
109
+ const e1 = [tri.v1[0] - tri.v0[0], tri.v1[1] - tri.v0[1], tri.v1[2] - tri.v0[2]];
110
+ const e2 = [tri.v2[0] - tri.v0[0], tri.v2[1] - tri.v0[1], tri.v2[2] - tri.v0[2]];
111
+ const p = [d[1] * e2[2] - d[2] * e2[1], d[2] * e2[0] - d[0] * e2[2], d[0] * e2[1] - d[1] * e2[0]];
112
+ const det = e1[0] * p[0] + e1[1] * p[1] + e1[2] * p[2];
113
+ if (det > -1e-12 && det < 1e-12) return Infinity;
114
+ const inv = 1 / det;
115
+ const tv = [o[0] - tri.v0[0], o[1] - tri.v0[1], o[2] - tri.v0[2]];
116
+ const u = (tv[0] * p[0] + tv[1] * p[1] + tv[2] * p[2]) * inv;
117
+ if (u < 0 || u > 1) return Infinity;
118
+ const q = [tv[1] * e1[2] - tv[2] * e1[1], tv[2] * e1[0] - tv[0] * e1[2], tv[0] * e1[1] - tv[1] * e1[0]];
119
+ const v = (d[0] * q[0] + d[1] * q[1] + d[2] * q[2]) * inv;
120
+ if (v < 0 || u + v > 1) return Infinity;
121
+ const t = (e2[0] * q[0] + e2[1] * q[1] + e2[2] * q[2]) * inv;
122
+ return t > tMin ? t : Infinity;
123
+ }
124
+
125
+ export function buildBVH(mesh) {
126
+ const tris = readTris(mesh);
127
+ const root = build(tris);
128
+
129
+ function raycast(origin, dir, { tMin = 1e-6, tMax = Infinity, skipTri = -1 } = {}) {
130
+ const invD = [1 / dir[0], 1 / dir[1], 1 / dir[2]];
131
+ let best = tMax, bestTri = -1;
132
+ const stack = [root];
133
+ while (stack.length) {
134
+ const node = stack.pop();
135
+ if (rayBox(origin, invD, node.min, node.max, tMin, best) === Infinity) continue;
136
+ if (node.tris) {
137
+ for (const tri of node.tris) {
138
+ if (tri.i === skipTri) continue;
139
+ const t = rayTri(origin, dir, tri, tMin);
140
+ if (t < best) { best = t; bestTri = tri.i; }
141
+ }
142
+ } else { stack.push(node.left, node.right); }
143
+ }
144
+ return bestTri === -1 ? null : { t: best, tri: bestTri };
145
+ }
146
+
147
+ // No production consumer yet — pre-built + tested as the reusable primitive for the deferred clearance/min-feature gate.
148
+ function closestPoint(p) {
149
+ let best2 = Infinity, bestPt = null, bestTri = -1;
150
+ const stack = [root];
151
+ while (stack.length) {
152
+ const node = stack.pop();
153
+ if (distSqBox(p, node.min, node.max) > best2) continue;
154
+ if (node.tris) {
155
+ for (const tri of node.tris) { const r = closestOnTri(p, tri); if (r.d2 < best2) { best2 = r.d2; bestPt = r.point; bestTri = tri.i; } }
156
+ } else {
157
+ // visit the nearer child first for better pruning
158
+ const dl = distSqBox(p, node.left.min, node.left.max), dr = distSqBox(p, node.right.min, node.right.max);
159
+ if (dl < dr) { stack.push(node.right, node.left); } else { stack.push(node.left, node.right); }
160
+ }
161
+ }
162
+ return { point: bestPt, dist: Math.sqrt(best2), tri: bestTri };
163
+ }
164
+
165
+ return { raycast, closestPoint };
166
+ }
@@ -0,0 +1,25 @@
1
+ // Enumerate the parameter configurations verify() checks: the default config plus
2
+ // every declared preset (or an explicit part.verify.cases list).
3
+
4
+ function presetMap(part) {
5
+ const map = {};
6
+ for (const section of part.parameters ?? []) {
7
+ if (!section.presets) continue;
8
+ for (const [name, overrides] of Object.entries(section.presets)) {
9
+ if (name in map) throw new Error(`duplicate preset name across sections: "${name}"`);
10
+ map[name] = overrides;
11
+ }
12
+ }
13
+ return map;
14
+ }
15
+
16
+ export function expandCases(part) {
17
+ const presets = presetMap(part);
18
+ const make = (name) => {
19
+ if (name === "defaults") return { name, params: { ...part.defaults } };
20
+ if (!(name in presets)) throw new Error(`unknown verify case "${name}" (not "defaults" or a preset)`);
21
+ return { name, params: { ...part.defaults, ...presets[name] } };
22
+ };
23
+ const names = part.verify?.cases ?? ["defaults", ...Object.keys(presets)];
24
+ return names.map(make);
25
+ }
@@ -0,0 +1,23 @@
1
+ // Reusable design-for-manufacturing process profiles. `bed` is the build volume
2
+ // [x,y,z] in mm (a hard bbox-fit gate); `minWall` mm (a warn); `clearance` mm is
3
+ // carried for a future gap check (not enforced yet).
4
+ export const PROFILES = {
5
+ "fdm-pla": { bed: [220, 220, 250], minWall: 1.2, clearance: 0.2 },
6
+ "fdm-petg": { bed: [220, 220, 250], minWall: 1.5, clearance: 0.3 },
7
+ "resin": { bed: [120, 68, 160], minWall: 0.6, clearance: 0.1 },
8
+ };
9
+
10
+ export function resolveProfile(spec) {
11
+ if (typeof spec === "string") {
12
+ if (!(spec in PROFILES)) {
13
+ throw new Error(`unknown process profile: "${spec}" (known: ${Object.keys(PROFILES).join(", ")})`);
14
+ }
15
+ return { ...PROFILES[spec] };
16
+ }
17
+ if (spec && typeof spec === "object") {
18
+ const base = spec.base ? resolveProfile(spec.base) : {};
19
+ const { base: _drop, ...overrides } = spec;
20
+ return { ...base, ...overrides };
21
+ }
22
+ throw new Error(`invalid process profile: ${JSON.stringify(spec)}`);
23
+ }
@@ -1,6 +1,7 @@
1
1
  import { buildView } from "./build.js";
2
2
  import { assemblyOverlaps } from "../framework/assembly.js";
3
3
  import { bounds, meshArea } from "./mesh.js";
4
+ import { minWall } from "./min-wall.js";
4
5
 
5
6
  const size = ({ min, max }) => [max[0] - min[0], max[1] - min[1], max[2] - min[2]];
6
7
  const unionBounds = (list) => list.reduce(
@@ -13,7 +14,7 @@ const unionBounds = (list) => list.reduce(
13
14
  // the assembly overlap check. All solid facts are read BEFORE assemblyOverlaps,
14
15
  // which frees the shared kernel's objects at its end.
15
16
  // → { part, view, subparts[], aggregate, overlaps[], ok }
16
- export function measure(kernel, part, view = Object.keys(part.views)[0], params = {}) {
17
+ export function measure(kernel, part, view = Object.keys(part.views)[0], params = {}, opts = {}) {
17
18
  const built = buildView(kernel, part, view, params);
18
19
  const subBounds = [];
19
20
  const subparts = built.map(({ name, solid, mesh }) => {
@@ -27,6 +28,7 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
27
28
  triangleCount: mesh.triangles,
28
29
  watertight: typeof solid.isEmpty === "function" ? !solid.isEmpty() : null,
29
30
  holes: typeof solid.genus === "function" ? solid.genus() : null,
31
+ minWall: opts.minWall ? (minWall(mesh)?.value ?? null) : null,
30
32
  };
31
33
  });
32
34
 
@@ -0,0 +1,38 @@
1
+ // src/testing/min-wall.js
2
+ // Min wall thickness by ray/shot on a triangle BVH (see the spec's spike: this beat the
3
+ // voxel/SDF approach on both accuracy and speed). For each surface triangle, cast a ray
4
+ // inward (reverse of its outward normal) from the centroid; the nearest hit is the local
5
+ // material thickness. The minimum across samples is the reported min wall.
6
+ // Works with both Manifold non-indexed meshes and OCCT indexed meshes (via meshTriangles).
7
+ import { buildBVH, meshTriangles } from "./bvh.js";
8
+
9
+ export function minWall(mesh, { maxThickness } = {}) {
10
+ const pos = mesh.positions;
11
+ const tris = meshTriangles(mesh);
12
+ if (tris.length === 0) return null;
13
+
14
+ // bbox diagonal as the default cap (a ray exiting into open air gets no hit anyway).
15
+ if (maxThickness == null) {
16
+ const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
17
+ for (let i = 0; i < pos.length; i += 3) for (let a = 0; a < 3; a++) { if (pos[i + a] < min[a]) min[a] = pos[i + a]; if (pos[i + a] > max[a]) max[a] = pos[i + a]; }
18
+ maxThickness = Math.hypot(max[0] - min[0], max[1] - min[1], max[2] - min[2]) + 1;
19
+ }
20
+
21
+ const bvh = buildBVH(mesh);
22
+ let best = Infinity, loc = null;
23
+ for (let t = 0; t < tris.length; t++) {
24
+ const [v0, v1, v2] = tris[t];
25
+ const e1 = [v1[0] - v0[0], v1[1] - v0[1], v1[2] - v0[2]];
26
+ const e2 = [v2[0] - v0[0], v2[1] - v0[1], v2[2] - v0[2]];
27
+ let nx = e1[1] * e2[2] - e1[2] * e2[1], ny = e1[2] * e2[0] - e1[0] * e2[2], nz = e1[0] * e2[1] - e1[1] * e2[0];
28
+ const len = Math.hypot(nx, ny, nz);
29
+ if (len < 1e-9) continue; // degenerate triangle
30
+ nx /= len; ny /= len; nz /= len; // outward normal (manifold winding)
31
+ const c = [(v0[0] + v1[0] + v2[0]) / 3, (v0[1] + v1[1] + v2[1]) / 3, (v0[2] + v1[2] + v2[2]) / 3];
32
+ const dir = [-nx, -ny, -nz]; // inward
33
+ const origin = [c[0] + dir[0] * 1e-4, c[1] + dir[1] * 1e-4, c[2] + dir[2] * 1e-4];
34
+ const hit = bvh.raycast(origin, dir, { tMax: maxThickness, skipTri: t });
35
+ if (hit && hit.t < best) { best = hit.t; loc = c; }
36
+ }
37
+ return best === Infinity ? null : { value: best, location: loc };
38
+ }
@@ -0,0 +1,89 @@
1
+ import { parseAssertion, evaluateAssertion } from "./assert-dsl.js";
2
+ import { measure as defaultMeasure } from "./measure.js";
3
+ import { resolveProfile } from "./dfm-profiles.js";
4
+ import { expandCases } from "./cases.js";
5
+ import { subPartReadKeys, relevanceHash, RELEVANT_ALL } from "../framework/param-deps.js";
6
+
7
+ // Metric registry: name → how to pull the value out of facts, and whether a failure
8
+ // is a hard gate or a warning. `manifoldOnly` facts are null on OCCT parts.
9
+ const SUBPART_METRICS = {
10
+ holes: { kind: "gate", manifoldOnly: true, extract: (s) => s.holes },
11
+ watertight: { kind: "gate", manifoldOnly: true, extract: (s) => s.watertight },
12
+ volume: { kind: "gate", extract: (s) => s.volume },
13
+ surfaceArea: { kind: "gate", extract: (s) => s.surfaceArea },
14
+ triangleCount: { kind: "gate", extract: (s) => s.triangleCount },
15
+ bbox: { kind: "gate", extract: (s) => s.bbox },
16
+ minWall: { kind: "warn", extract: (s) => s.minWall },
17
+ };
18
+ const VIEW_METRICS = {
19
+ bbox: { kind: "gate", extract: (r) => r.aggregate.bbox },
20
+ volume: { kind: "gate", extract: (r) => r.aggregate.volume },
21
+ overlaps: { kind: "gate", extract: (r) => r.overlaps.length },
22
+ };
23
+
24
+ function check(scope, subpart, metric, expr, registry, factsObj) {
25
+ const reg = registry[metric];
26
+ if (!reg) throw new Error(`unknown ${scope} metric "${metric}"${subpart ? ` on sub-part "${subpart}"` : ""}`);
27
+ const actual = reg.extract(factsObj);
28
+ const base = { scope, subpart, metric, kind: reg.kind, expr: String(expr) };
29
+ if (actual === null || actual === undefined) {
30
+ if (reg.manifoldOnly) return { ...base, actual, status: "skip", pass: null, message: "n/a (OCCT backend)" };
31
+ if (metric === "minWall") return { ...base, actual, status: "warn", pass: null, message: "min wall unavailable" };
32
+ return { ...base, actual, status: "skip", pass: null, message: "unavailable" };
33
+ }
34
+ const { pass, message } = evaluateAssertion(parseAssertion(expr), actual);
35
+ const status = pass ? "pass" : reg.kind === "warn" ? "warn" : "fail";
36
+ return { ...base, actual, status, pass, message };
37
+ }
38
+
39
+ // Pure policy: profile rules + per-part expect → checks for one case's facts.
40
+ export function evaluateCase(facts, { profile, expect }) {
41
+ const checks = [];
42
+ const viewExp = {
43
+ ...(profile?.bed ? { bbox: `<=[${profile.bed.join(",")}]` } : {}),
44
+ ...(expect?._view ?? {}),
45
+ };
46
+ for (const [metric, expr] of Object.entries(viewExp)) checks.push(check("view", null, metric, expr, VIEW_METRICS, facts));
47
+
48
+ for (const s of facts.subparts) {
49
+ const merged = {
50
+ ...(profile?.minWall != null ? { minWall: `>=${profile.minWall}` } : {}),
51
+ ...(expect?.[s.name] ?? {}),
52
+ };
53
+ for (const [metric, expr] of Object.entries(merged)) checks.push(check("subpart", s.name, metric, expr, SUBPART_METRICS, s));
54
+ }
55
+ return checks;
56
+ }
57
+
58
+ export function verify(kernel, part, { process, view, measureFn = defaultMeasure } = {}) {
59
+ view = view ?? Object.keys(part.views)[0];
60
+ const profileSpec = process ?? part.verify?.process;
61
+ const profile = profileSpec ? resolveProfile(profileSpec) : null;
62
+ const expect = part.verify?.expect ?? {};
63
+ const expectMentionsMinWall = Object.values(expect).some((o) => o && typeof o === "object" && "minWall" in o);
64
+ const needMinWall = profile?.minWall != null || expectMentionsMinWall;
65
+
66
+ const cases = expandCases(part);
67
+ const readKeys = subPartReadKeys(part, view, part.defaults);
68
+ const signature = (params) =>
69
+ readKeys === RELEVANT_ALL
70
+ ? JSON.stringify(params)
71
+ : [...readKeys.entries()].map(([name, keys]) => `${name}:${relevanceHash([...keys], params)}`).join("|");
72
+
73
+ const memo = new Map();
74
+ const measureCase = (params) => {
75
+ const key = signature(params);
76
+ if (!memo.has(key)) memo.set(key, measureFn(kernel, part, view, params, { minWall: needMinWall }));
77
+ return memo.get(key);
78
+ };
79
+
80
+ const caseResults = cases.map(({ name, params }) => ({ name, params, checks: evaluateCase(measureCase(params), { profile, expect }) }));
81
+ const all = caseResults.flatMap((c) => c.checks.map((ch) => ({ case: c.name, ...ch })));
82
+ return {
83
+ ok: !all.some((c) => c.status === "fail"),
84
+ view,
85
+ cases: caseResults,
86
+ failures: all.filter((c) => c.status === "fail"),
87
+ warnings: all.filter((c) => c.status === "warn"),
88
+ };
89
+ }
package/src/testing.js CHANGED
@@ -9,3 +9,6 @@ export { meshVolume, bboxSize } from "./testing/mesh.js";
9
9
  export { buildView } from "./testing/build.js";
10
10
  export { measure } from "./testing/measure.js";
11
11
  export { renderViews } from "./testing/render.js";
12
+ export { verify } from "./testing/verify.js";
13
+ export { buildBVH } from "./testing/bvh.js";
14
+ export { minWall } from "./testing/min-wall.js";