partforge 0.9.0 → 0.11.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
@@ -150,6 +150,9 @@ function printMeasure(r) {
150
150
  console.log(` overlaps: ${r.overlaps.length
151
151
  ? r.overlaps.map((o) => `${o.a}×${o.b} (${o.volume.toFixed(1)}mm³ at [${o.location.map((n) => n.toFixed(1)).join(", ")}])`).join(", ")
152
152
  : "none"}`);
153
+ console.log(` near-misses: ${r.nearMisses.length
154
+ ? r.nearMisses.map((g) => `${g.a}×${g.b} (${g.distance.toFixed(2)}mm at [${g.at.map((n) => n.toFixed(1)).join(", ")}])`).join(", ")
155
+ : "none"}`);
153
156
  }
154
157
 
155
158
  function printVerify(v) {
@@ -44,7 +44,7 @@ export default {
44
44
  meta: { title, units, background? }, // title string; units e.g. "mm"; background = 0xRRGGBB scene colour
45
45
  parameters, // the control-panel schema (array of sections — see below)
46
46
  defaults, // flat { paramKey: value } — seeds params + control values
47
- derive?, // (p) => d optional dependent values computed once per build
47
+ derive?, // (p) => d, or { group: (p, d) => {…}, … } — dependent values computed once per build
48
48
  parts: { // named sub-parts; each builds ONE solid
49
49
  <name>: {
50
50
  label?, // display name (tabs/progress); defaults to the key
@@ -71,6 +71,14 @@ export default {
71
71
  `"display"` or `"export"`; `ctx.view` is the active view. Default is identity, so simple
72
72
  parts omit it. **Display placement must not depend on `view`** — display meshes are built
73
73
  once per sub-part and cached across views (the viewer re-centres per view).
74
+ **Any difference between the display and export pose must be a rigid motion** —
75
+ `translate`/`rotate`/`rotateAbout`/`along`/`at` only. Never put a `mirror` or a
76
+ non-identity `scale` on one purpose but not the other: the exported (printed) part is the
77
+ same physical object you show in the assembly, and a reflection or resize there makes the
78
+ two silently disagree — you print the mirror image of what the viewer showed
79
+ ([place-not-rigid](ERROR-PATTERNS.md#place-not-rigid)). If a part genuinely needs a
80
+ reflected or resized form (e.g. a block that seats flipped), bake that into `build` so
81
+ both purposes share one canonical solid, then pose it rigidly.
74
82
  - `enabled(p)` gates a conditional sub-part (e.g. only present when a feature is on).
75
83
  - A view's sub-parts are derived, never hard-coded: those whose `views` include the view
76
84
  and whose `enabled(p)` is true.
@@ -81,8 +89,10 @@ export default {
81
89
 
82
90
  `build` receives a backend-agnostic `kernel` (`k`). It returns and combines `Solid`
83
91
  handles. The same code runs on **Manifold** (fast meshes — preview + STL + 3MF) and
84
- **OCCT/replicad** (exact B-rep — STEP). Contract lives in
85
- `src/framework/geometry/kernel.js`.
92
+ **OCCT/replicad** (exact B-rep — STEP). Op lists live in
93
+ `src/framework/geometry/kernel.js`; the normative semantics (conventions, value
94
+ semantics, conformance classes, versioning) are in `docs/KERNEL-CONTRACT.md` — the
95
+ tables below are the authoring-side view of that contract.
86
96
 
87
97
  **Kernel — make solids:**
88
98
 
@@ -204,9 +214,16 @@ anything `rotateX/Y/Z`/`rotateAbout` can't express, but prefer the vocabulary ab
204
214
 
205
215
  ### Naming features (`.label()`)
206
216
 
207
- Give build-step solids human-readable namesthe viewer's hover tooltip, the
208
- highlight, and pick selections all use them, so you, the app user, and an agent
209
- share the same vocabulary ("Make the Drainage hole 10 mm").
217
+ Label your part's features, and label them **thoroughly** this is how a user points
218
+ at what they want changed. The viewer's hover tooltip, highlight, and pick selection
219
+ all show a feature's label, so you, the app user, and an agent editing on their behalf
220
+ share one vocabulary: "make the Drainage hole 10 mm", "raise the Motor upright". A
221
+ feature with no name can't be referred to — it reads as the whole part, so the request
222
+ has nowhere to land.
223
+
224
+ Treat comprehensive labeling as the default, not a finishing touch. Name every feature
225
+ a user could reasonably want to change: the base body, and each functional feature —
226
+ grooves, mounts, bores, pockets, distinct structural members.
210
227
 
211
228
  ```js
212
229
  const body = k.prism(d.outerPts, p.height, { scaleTop: p.taper }).label("Faceted wall");
@@ -214,18 +231,26 @@ let s = body.cut(cavity.label("Cavity"));
214
231
  if (p.drain > 0) s = s.cut(k.cylinder(d.drainR, d.drainR, p.floor + 4).at([0, 0, -2]).label("Drainage hole"));
215
232
  ```
216
233
 
234
+ - **Aim for functional groups.** Label at the granularity a user would name a thing
235
+ ("Rope groove", "Tensioner pockets", "Bearing seat"), grouping repeated or related
236
+ faces under one name. Fine enough to reference any feature; coarse enough that
237
+ near-identical surfaces don't fragment into dozens of near-duplicates.
217
238
  - A label names the solid's **surface** wherever it survives into the final part —
218
239
  a cutting tool's label lands on the faces it leaves behind (the hole's wall).
219
240
  - Label **after** shaping compound tools (e.g. after an `intersect` clip) and
220
241
  either before or after transforms — labels ride through `at`/`rotate`/etc.
221
- - The **same label on several solids merges into one feature** label a pattern
222
- of four holes `"Mounting holes"` and they hover/highlight as one.
242
+ - **Same label merges; distinct siblings need distinct names.** The same label on
243
+ several solids merges into one feature label a ring of four bolt holes
244
+ `"Mounting holes"` and they hover/highlight as one. Conversely, when two similar
245
+ features are things a user would tell apart, name them apart — two uprights as
246
+ `"Drum upright"` and `"Motor upright"`, not both `"Upright"`.
223
247
  - Unlabeled geometry falls back to the sub-part's `label`. Faces created by
224
248
  `fillet`/`chamfer`/`shell` are new surfaces, so they use the fallback too.
225
249
  - Works on both backends. On OCCT each label keeps a geometry snapshot for
226
- mesh-time classification label a handful of features, not hundreds.
227
- - Names should describe intent ("Drainage hole", not "cylinder2"); keep them
228
- unique per sub-part unless you want the merge behavior.
250
+ mesh-time classification, so label meaningful features (functional groups a
251
+ handful to a couple dozen per part), not hundreds of individual faces.
252
+ - Names should describe intent ("Drainage hole", not "cylinder2"); make them
253
+ unique per sub-part unless you specifically want the merge behavior.
229
254
 
230
255
  ### Caching & determinism
231
256
 
@@ -324,6 +349,33 @@ coherently:
324
349
  thicknesses — so a single input feeds everything downstream. In the demo, `derive`
325
350
  turns the nominal `bore` into `boreR` (with a fixed print clearance) and `h` into the
326
351
  cut-tool height `cutH`; `build(k, p, d)` reads those.
352
+ - **Grouped `derive` (recommended once it grows):** `derive` may instead be an object of
353
+ named group functions, run in declaration order; each group receives `(p, d)` where `d`
354
+ holds the merged outputs of the groups **before** it:
355
+
356
+ ```js
357
+ derive: {
358
+ core: (p) => ({ boreR: p.bore / 2 + 0.15 }),
359
+ stand: (p, d) => ({ postH: d.boreR * 4 + p.base_t }), // may read earlier groups
360
+ }
361
+ ```
362
+
363
+ Builds see the same merged `d` either way. The point is the **control panel's
364
+ relevance dimming** (and the rebuild cache): with a single function, a sub-part that
365
+ reads *any* derived value is assumed to depend on *every* param `derive` touches, so
366
+ e.g. stand-only controls stay lit in a drum-only view. With groups, each derived key
367
+ is attributed to just its own group's inputs (plus, transitively, those of the groups
368
+ it read), so unrelated controls dim correctly. Group along your sub-part seams:
369
+ values only one sub-part family reads belong in their own group.
370
+
371
+ Grouped-form rules: a group reading a key **no earlier group produced** throws
372
+ immediately (misordered groups / typos would otherwise surface as silent NaN
373
+ geometry) — this includes optional-chaining reads like `d.maybe?.x`, so probe for a
374
+ conditionally-produced key with `"maybe" in d`, not `?.`. Prefer returning values
375
+ over mutating `d` in place — mutation works and is tracked, but returned keys read
376
+ clearer. Outside the part definition (helpers, tests), merge groups with
377
+ `resolveDerived(part, p)` from **`partforge/derive`** — a lean, DOM-free entry safe
378
+ to import from part modules; don't hand-roll the merge.
327
379
  - **Reuse a param `key`** across sub-parts/features so one slider moves all of them.
328
380
  - **`enabled(p)`** gates a whole sub-part on a toggle param (the part appears/disappears
329
381
  with the control).
@@ -458,11 +510,11 @@ Tests run under **Node 24** (`nvm use` first; the default shell Node is too old)
458
510
  `npx vitest run`. Build geometry directly off your part with a Manifold kernel:
459
511
 
460
512
  ```js
461
- import { bootManifoldKernel } from "partforge/testing";
513
+ import { bootManifoldKernel, resolveDerived } from "partforge/testing";
462
514
  import part from "../src/parts/<part>.js";
463
515
 
464
516
  const k = await bootManifoldKernel();
465
- const solid = part.parts.<name>.build(k, part.defaults, part.derive?.(part.defaults) ?? {});
517
+ const solid = part.parts.<name>.build(k, part.defaults, resolveDerived(part, part.defaults));
466
518
  expect(solid.toMesh().triangles).toBeGreaterThan(0);
467
519
  ```
468
520
 
@@ -498,7 +550,10 @@ check it without opening the app:
498
550
 
499
551
  `measure` prints a report: per sub-part and per view it reports bounding box,
500
552
  volume, surface area, triangle count, whether the solid is watertight, and the
501
- number of through-holes (genus), plus an assembly overlap check. It exits non-zero
553
+ number of through-holes (genus), plus an assembly overlap check, and a
554
+ **near-miss** check — sub-part pairs whose surfaces come closer than 0.5 mm
555
+ without touching (`near-misses:` in the output; reported for judgment, never an
556
+ exit-code gate by itself). It exits non-zero
502
557
  if any sub-part isn't watertight or any parts interpenetrate — so it doubles as a
503
558
  CI/agent gate. Add `--json` to also dump the report as JSON on stdout, or
504
559
  `--out report.json` to write it to a file (nothing is written otherwise). (Manifold output is
@@ -531,13 +586,17 @@ carries:
531
586
  - `location` — `[x, y, z]` in mm where the metric has one: `minWall` (thinnest
532
587
  sample point) and `overlaps` (the center of the first offending intersection's
533
588
  *bounding box* — a nearby indicator, not an exact point: when a pair overlaps in
534
- more than one place the bbox center can fall in the empty space between regions).
535
- Whole-solid metrics (bbox, volume, …) have none.
589
+ more than one place the bbox center can fall in the empty space between regions)
590
+ and the pair checks `contact` / `clearance` / `nearMiss` (the midpoint between
591
+ the pair's closest surface points). Whole-solid metrics (bbox, volume, …) have
592
+ none.
536
593
 
537
594
  Subpart facts include `minWall` (number or `null` — null exactly when no reading
538
595
  exists, e.g. the OCCT backend or min-wall measurement turned off, matching
539
596
  `minWallAt`'s null) and `minWallAt` (`[x,y,z]` or `null`); overlap entries are
540
- `{ a, b, volume, location }`.
597
+ `{ a, b, volume, location }`. Pair-distance facts are `gaps` (every sub-part
598
+ pair: `{ a, b, distance, at }`, distance 0 = touching or overlapping) and
599
+ `nearMisses` (the pairs with an unintended-looking gap under 0.5 mm).
541
600
 
542
601
  A **thrown** error (bad part module, kernel failure) with `--json` prints pure
543
602
  JSON to stdout and exits 1:
@@ -582,7 +641,9 @@ verify: {
582
641
  cases: ["defaults", "M3"], // optional; default = defaults + every preset
583
642
  expect: { // design intent, by sub-part name (+ "_view")
584
643
  spacer: { holes: 1, bbox: "<=[60,60,60]", volume: "0.4..0.6cm3" },
585
- _view: { overlaps: 0 },
644
+ _view: { overlaps: 0,
645
+ contacts: [["drum", "flange"]], // these pairs must touch
646
+ clearance: { "lid×body": ">=0.3" } }, // intended free fits
586
647
  },
587
648
  }
588
649
  ```
@@ -591,7 +652,7 @@ verify: {
591
652
  and a **min-wall** warning. **What `expect` gives you:** per-sub-part assertions on the
592
653
  facts `measure` already reports — `holes` (through-bores / genus), `volume`,
593
654
  `surfaceArea`, `triangleCount`, `bbox`, `watertight`, `minWall`; and `_view` assertions
594
- `bbox`, `volume`, `overlaps`.
655
+ `bbox`, `volume`, `overlaps`, plus the pair-wise `contacts` / `clearance` below.
595
656
 
596
657
  **Assertion DSL:** a bare number means equality (`holes: 1`); `">=n"`, `"<=n"`, `">n"`,
597
658
  `"<n"`, or a range `"a..b"`; an optional unit suffix `mm`/`cm`/`mm3`/`cm3`; and for
@@ -604,6 +665,53 @@ The parser is strict — a malformed assertion fails loudly.
604
665
  `holes`/`watertight` are Manifold-only, so those assertions **skip** on OCCT parts
605
666
  rather than fail.
606
667
 
668
+ **Per-case expectations.** Checks run across defaults **and every preset**, so a
669
+ static `expect` breaks the moment a preset legitimately changes an asserted fact —
670
+ a "cup" preset that turns the drainage hole off flips the genus from 1 to 0.
671
+ For that, declare `expect` as a **pure function of the case's resolved params**,
672
+ `(p, d) => ({ … })` (same `p`/`d` your `build` sees, `d` from `derive`):
673
+
674
+ ```js
675
+ verify: {
676
+ process: "fdm-pla",
677
+ expect: (p) => ({
678
+ planter: { holes: p.drain > 0 ? 1 : 0, bbox: "<=[220,220,250]" },
679
+ _view: { overlaps: 0 },
680
+ }),
681
+ }
682
+ ```
683
+
684
+ `src/parts/planter.js` is the worked example — its "Pen cup" and "Vase" presets
685
+ disable the drain, so the hole count is pinned per case. Keep the function pure
686
+ (no clock/randomness), like every other part function.
687
+
688
+ **Contacts & clearance (near-miss gaps).** Volume, bbox, and render checks all miss
689
+ sub-parts that *almost* touch — a flange floating 0.3 mm off its drum body passes
690
+ every one of them. `measure` therefore reports `nearMisses` (pairs with a
691
+ surface-to-surface gap under 0.5 mm), and `_view` accepts two pair-wise gates:
692
+
693
+ - `contacts: [["drum", "flange"]]` — each listed pair must touch. The gate fails
694
+ with the measured gap and the closest-point location when the surfaces don't
695
+ meet. Interpenetration counts as contact — the separate `overlaps` gate owns
696
+ *excessive* interpenetration. A pair naming an `enabled()`-gated sub-part
697
+ **skips** in cases where that sub-part is off; a name that exists nowhere in
698
+ the part still throws.
699
+ - `clearance: { "lid×body": ">=0.3" }` — an intended free fit. Keys are `"a×b"`
700
+ (order doesn't matter); values take the same assertion DSL as any metric (and
701
+ the `{ expr, hint }` form), evaluated against the pair's minimum surface
702
+ distance in mm.
703
+
704
+ Any pair *not* declared either way that sits closer than 0.5 mm becomes a
705
+ **warning** — the "did you mean these to touch?" signal. Declare the pair to
706
+ silence it. Distances are measured mesh-to-mesh (exact triangle distance, so it
707
+ works on both backends with no kernel booleans); contact tolerates ~1 µm, so a
708
+ tessellation-limited curved contact (e.g. equal-radius cylinder-in-bore built with
709
+ different facet counts) may read a few hundredths of a millimetre — prefer a tight
710
+ `clearance` bound like `"<=0.05"` over `contacts` for those. One OCCT caveat: with
711
+ no overlap detection there (`Solid.intersect` is Manifold-only), a sub-part
712
+ *fully contained* inside another reads as its surface-to-surface distance, so it
713
+ can surface as a near miss — check containment cases on Manifold.
714
+
607
715
  **Running it:**
608
716
 
609
717
  ```bash
@@ -645,7 +753,9 @@ whole part to OCCT — no declaration needed:
645
753
  - `{ dir: "X"|"Y"|"Z" }` — edges running along an axis (e.g. `{dir:"Z"}` = the vertical edges)
646
754
  - `{ inPlane: "XY"|"XZ"|"YZ", at }` — edges lying in a plane (e.g. base edges: `{inPlane:"XY", at:0}`)
647
755
  - `{ near: [x,y,z] }` — edges passing through a point
648
- - a raw `(edgeFinder) => edgeFinder` replicad finder, for anything fancier
756
+ - a raw `(edgeFinder) => edgeFinder` replicad finder, for anything fancier — **OCCT-only
757
+ escape hatch**: fine for a part that's happy to stay in this repo, but non-portable
758
+ (parts meant to travel must use the object forms — see `KERNEL-CONTRACT.md`)
649
759
 
650
760
  ```js
651
761
  let s = k.box([0,0,0],[40,30,16]);
@@ -73,6 +73,12 @@ The framework itself rebuilds each sub-part fresh per job and applies `place` on
73
73
  - **Cause:** A `place` that depends on `ctx.view` for `purpose: "display"` — display meshes are built once per sub-part and cached across views.
74
74
  - **Fix:** Make display placement view-independent; only `place(..., { purpose: "export" })` may branch on `view`. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "The `PartDefinition` contract".
75
75
 
76
+ ## place-not-rigid
77
+
78
+ - **Symptom:** The exported/printed part is a mirror image of — or a different size than — the same part shown in the assembly/display view. Nothing throws: the preview looks right and only the STL/STEP is wrong, or vice-versa.
79
+ - **Cause:** A `place` whose `purpose: "display"` and `"export"` branches differ by a non-rigid transform — `mirror` (flips handedness) or a non-identity `scale` (changes size) — so display and export are no longer the same solid, only its reflection/resize.
80
+ - **Fix:** Keep the display-vs-export `place` difference a rigid motion (`translate`/`rotate`/`rotateAbout`/`along`/`at`) only. If the part genuinely needs a reflected or resized form, bake that into `build` so both purposes share one canonical solid and pose it rigidly. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "The `PartDefinition` contract".
81
+
76
82
  ## wrong-node-version
77
83
 
78
84
  - **Symptom:** Confusing failures during `npm install`, tests, or CLI runs — WASM load errors, syntax errors in dependencies, or kernels that never boot — on a machine that built fine before.
@@ -91,6 +97,18 @@ The framework itself rebuilds each sub-part fresh per job and applies `place` on
91
97
  - **Cause:** The ray-shot wall-thickness measurement can catch sliver triangles at facet seams, reading a near-zero "wall" that isn't a designed wall.
92
98
  - **Fix:** Check where the reported thin spot is: at a facet seam or chamfer transition it's a sliver artifact (minWall is a warning, never a gate — safe to note and move on); along a real wall, thicken the wall. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Self-verification (the `verify` block)".
93
99
 
100
+ ## near-miss-gap
101
+
102
+ - **Symptom:** A `⚠ … nearMiss` warning or `✗ … contact` failure from `verify` reporting sub-parts `N mm apart, expected touching`, or a `near-misses:` line in `measure` output for parts that look joined in the preview.
103
+ - **Cause:** Two sub-parts that should meet don't quite — a boss shorter than the gap it must bridge, a mis-placed mating datum in `derive()`, or a union that silently missed. Renders and volume/bbox checks cannot see sub-mm joint gaps; this check exists precisely for them.
104
+ - **Fix:** If the pair should touch, grow the joining feature or fix the datum math so the faces meet, then declare the pair in `verify.expect._view.contacts`; if a free fit is intended, declare it under `clearance`. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Self-verification (the `verify` block)".
105
+
106
+ ## expect-static-across-presets
107
+
108
+ - **Symptom:** A `verify` exact gate (`holes`, `volume`, …) fails on SOME presets only — e.g. `✗ planter holes 1 (0 != 1)` on two cases while defaults pass — and the preview looks right for every preset.
109
+ - **Cause:** `verify` runs `expect` across defaults + every preset, and a preset legitimately changes the asserted fact (an optional feature like a drain/bore toggles the genus), while the expectation is one static value.
110
+ - **Fix:** Declare `expect` as a pure function of the case's resolved params — `expect: (p, d) => ({ body: { holes: p.drain > 0 ? 1 : 0 } })` — or restrict `verify.cases`. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Self-verification (the `verify` block)".
111
+
94
112
  ## param-key-missing-from-defaults
95
113
 
96
114
  - **Symptom:** The affected control's number box renders empty/blank (internally `numStr(undefined)` produces the string `NaN`, which a number input sanitizes to empty), or its range slider sits at a browser-default position and edits don't drive the geometry — no error is thrown — and if the key is `hidden`, no control is rendered for it at all.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.9.0",
3
+ "version": "0.11.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",
@@ -25,7 +25,9 @@
25
25
  ".": "./src/index.js",
26
26
  "./worker": "./src/framework/worker.js",
27
27
  "./geometry": "./src/framework/geometry/polygon.js",
28
- "./testing": "./src/testing.js"
28
+ "./derive": "./src/framework/derive.js",
29
+ "./testing": "./src/testing.js",
30
+ "./tokens.css": "./src/framework/tokens.css"
29
31
  },
30
32
  "bin": {
31
33
  "partforge": "./bin/cli.js"
@@ -2,22 +2,8 @@
2
2
  busy overlay) + the light/dark palettes. Imported by framework/mount.js, so
3
3
  every part-app gets it; each part's HTML only carries structural markup.
4
4
  See docs/AUTHORING-PARTS.md. */
5
+ @import "./tokens.css"; /* palette + light overrides; also exported standalone as partforge/tokens.css */
5
6
 
6
- :root {
7
- color-scheme: dark;
8
- --bg: #15181d; --surface: #1f242c; --surface-2: #20262e; --border: #2c333d;
9
- --text: #d6dbe2; --text-strong: #e7ebf1; --text-2: #cdd4dd;
10
- --muted: #7d8794; --muted-2: #aab2bd; --status: #8b94a0; --hint: #6b7480;
11
- --accent: #3f7bf0; --accent-soft: #26314a; --on-accent: #fff; --input-bg: #161a20; --err: #f8746c;
12
- --mono: ui-monospace, "SF Mono", SFMono-Regular, "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace;
13
- }
14
- :root[data-theme="light"] {
15
- color-scheme: light;
16
- --bg: #eef1f5; --surface: #ffffff; --surface-2: #f4f6f9; --border: #d4dae2;
17
- --text: #2b333d; --text-strong: #1a2129; --text-2: #3a434e;
18
- --muted: #6b7480; --muted-2: #59636f; --status: #6b7480; --hint: #8a93a0;
19
- --accent: #1f5bd6; --accent-soft: #e6edfc; --on-accent: #fff; --input-bg: #ffffff; --err: #d8453d;
20
- }
21
7
  * { box-sizing: border-box; }
22
8
  html, body { margin: 0; height: 100%; overflow: hidden;
23
9
  font: 13px/1.4 -apple-system, system-ui, sans-serif; }
@@ -0,0 +1,28 @@
1
+ // Resolve a part's `derive` into the derived-values object `d` that builds receive.
2
+ //
3
+ // Two authoring forms:
4
+ // derive: (p) => d — one function, computed in a single pass.
5
+ // derive: { name: (p, d) => {...}, … } — named GROUPS, run in declaration order;
6
+ // each group gets the params plus the merged outputs of the groups before it.
7
+ // The grouped form exists so the relevance layer (param-deps.js) can attribute each
8
+ // derived value to just its own group's inputs instead of every param derive touches.
9
+ export function resolveDerived(part, p) {
10
+ const derive = part.derive;
11
+ if (!derive) return {};
12
+ if (typeof derive === "function") return derive(p) ?? {};
13
+ const d = {};
14
+ // Groups read earlier groups' outputs through this guard: a key nothing has
15
+ // produced yet is a wiring mistake (group order / typo), and silently reading
16
+ // undefined would surface as NaN geometry far downstream — throw here instead.
17
+ // (Builds still receive the plain merged object, unguarded.)
18
+ const guard = new Proxy(d, {
19
+ get(t, key) {
20
+ if (typeof key === "string" && key !== "then" && !(key in t)) {
21
+ throw new Error(`derive: group read "${key}" before any earlier group produced it`);
22
+ }
23
+ return Reflect.get(t, key);
24
+ },
25
+ });
26
+ for (const fn of Object.values(derive)) Object.assign(d, fn(p, guard) ?? {});
27
+ return d;
28
+ }
@@ -3,7 +3,14 @@
3
3
  // test/occt-backend.test.js) assert each backend exposes exactly these ops, so the
4
4
  // contract can't silently drift from the implementations — the drift class that
5
5
  // once broke the probe kernel (see probe.js). The @typedefs document signatures.
6
- // (2-D polygon helpers live in ./polygon.js.)
6
+ // The prose half of the contract — conventions, value semantics, conformance
7
+ // classes, versioning policy — is docs/KERNEL-CONTRACT.md; change either side and
8
+ // you must update the other. (2-D polygon helpers live in ./polygon.js.)
9
+
10
+ // The prose half's version: docs/KERNEL-CONTRACT.md's "Contract version" header
11
+ // must match this number (asserted in kernel-contract.test.js). Bump only on a
12
+ // breaking contract change — see the doc's Versioning section.
13
+ export const CONTRACT_VERSION = 1;
7
14
 
8
15
  // Ops every backend kernel must implement.
9
16
  export const KERNEL_OPS = [
@@ -3,6 +3,7 @@
3
3
  // if an OCCT-only op was used, the part needs the OCCT backend. The op list lives
4
4
  // in kernel.js — the same list generates the Manifold backend's throwing stubs.
5
5
  import { OCCT_ONLY_OPS } from "./kernel.js";
6
+ import { resolveDerived } from "../derive.js";
6
7
 
7
8
  const OCCT_ONLY = new Set(OCCT_ONLY_OPS);
8
9
 
@@ -49,7 +50,11 @@ export function createProbeKernel() {
49
50
  export function detectBackend(part, params = {}) {
50
51
  if (part.meta?.backend) return part.meta.backend;
51
52
  const p = { ...part.defaults, ...params };
52
- const d = part.derive ? part.derive(p) : {};
53
+ let d = {};
54
+ // A throwing derive must not escape here — this runs on the main thread mid
55
+ // regen (after the busy spinner goes up). Probe with an empty `d`; the worker
56
+ // build hits the same throw and posts a proper error for the UI.
57
+ try { d = resolveDerived(part, p); } catch { /* fall through with d = {} */ }
53
58
  const { kernel, used } = createProbeKernel();
54
59
  for (const name of Object.keys(part.parts)) {
55
60
  try { part.parts[name].build(kernel, p, d); } catch { /* probe miss → capability backstop covers it */ }
@@ -1,4 +1,5 @@
1
1
  import { meshTo3MF } from "./geometry/threemf.js";
2
+ import { resolveDerived } from "./derive.js";
2
3
 
3
4
  // Names of the sub-parts a view shows: declared in the view and enabled for these
4
5
  // params. Order follows Object.keys(part.parts) (definition order).
@@ -23,8 +24,7 @@ export function exportSubParts(part, view, params) {
23
24
  // layered over the part defaults, and derive() run once over the result.
24
25
  export function resolveParams(part, params) {
25
26
  const p = { ...part.defaults, ...params };
26
- const d = part.derive ? part.derive(p) : {};
27
- return { p, d };
27
+ return { p, d: resolveDerived(part, p) };
28
28
  }
29
29
 
30
30
  // Build one sub-part and apply its optional place() for the given purpose/view.
@@ -52,13 +52,16 @@ const bufferOf = (data) => (ArrayBuffer.isView(data) ? data.buffer : data);
52
52
 
53
53
  export async function handle(kernel, part, msg, post) {
54
54
  const onProgress = (phase) => post({ type: "progress", phase });
55
- const { p, d } = resolveParams(part, msg.params);
56
55
  const label = (name) => part.parts[name].label ?? name;
57
56
  const exportName = (name) => part.parts[name].export?.name ?? name;
58
- // Local shorthand over the shared helper: kernel/part/view/p/d are fixed per job.
59
- const posed = (name, purpose, prog) => buildPosed(kernel, part, name, { purpose, view: msg.view, p, d, onProgress: prog });
60
57
 
61
58
  try {
59
+ // Inside the try so a throwing derive posts an error the UI can show,
60
+ // instead of killing the worker turn silently (an endless spinner).
61
+ const { p, d } = resolveParams(part, msg.params);
62
+ // Local shorthand over the shared helper: kernel/part/view/p/d are fixed per job.
63
+ const posed = (name, purpose, prog) => buildPosed(kernel, part, name, { purpose, view: msg.view, p, d, onProgress: prog });
64
+
62
65
  if (msg.type === "generate") {
63
66
  const t0 = Date.now();
64
67
  const useCache = msg.cache !== false; // ?debug toggle can disable caching (cache:false)
@@ -8,6 +8,7 @@ import { relevantParamKeys } from "./param-deps.js";
8
8
  import { createMeshCache } from "./mesh-cache.js";
9
9
  import { createGeometryService } from "./geometry-service.js";
10
10
  import { viewSubParts } from "./jobs.js";
11
+ import { resolveDerived } from "./derive.js";
11
12
  import { detectBackend } from "./geometry/probe.js";
12
13
  import { createDebugOverlay } from "./debug-overlay.js";
13
14
  import { createRegenLoop } from "./regen-loop.js";
@@ -58,7 +59,12 @@ export function mount(part, { createWorker, container = document.getElementById(
58
59
 
59
60
  // Current selection context for the pickers: the active view + live params +
60
61
  // derived values. Shared by both ?pick modes below.
61
- const getContext = () => ({ view: view(), params, derived: part.derive ? part.derive({ ...part.defaults, ...params }) : {} });
62
+ const getContext = () => {
63
+ let derived = {};
64
+ // A throwing derive must not crash the pick flow — proceed without derived context.
65
+ try { derived = resolveDerived(part, { ...part.defaults, ...params }); } catch { /* derived stays {} */ }
66
+ return { view: view(), params, derived };
67
+ };
62
68
 
63
69
  // ?pick enables click-to-select: a toggle button + a transient toast. Off by
64
70
  // default — no button, no listener, no behavior change. Deleting this block and
@@ -4,6 +4,7 @@
4
4
  // probe kernel). Errs toward RELEVANT_ALL whenever it can't analyze a build.
5
5
  import { createProbeKernel } from "./geometry/probe.js";
6
6
  import { viewSubParts } from "./jobs.js";
7
+ import { resolveDerived } from "./derive.js";
7
8
 
8
9
  export const RELEVANT_ALL = Symbol("relevant-all");
9
10
 
@@ -19,9 +20,56 @@ function recorder(obj, seen) {
19
20
  });
20
21
  }
21
22
 
23
+ // Run derive with recorders. `allInputs` is every raw param derive reads.
24
+ // For the grouped form (derive as an object of group functions — see derive.js),
25
+ // `depsOf` maps each derived key to the raw params of just its own group,
26
+ // transitively including the groups whose outputs it read. For the single-function
27
+ // form there is no per-key attribution, so depsOf is null and callers fall back to
28
+ // treating every derive input as feeding every derived key.
29
+ function analyzeDerive(part, params) {
30
+ const allInputs = new Set();
31
+ if (!part.derive) return { derived: {}, allInputs, depsOf: null };
32
+ if (typeof part.derive === "function") {
33
+ const derived = part.derive(recorder(params, allInputs)) ?? {};
34
+ return { derived, allInputs, depsOf: null };
35
+ }
36
+ const derived = {};
37
+ const depsOf = new Map();
38
+ for (const fn of Object.values(part.derive)) {
39
+ const raw = new Set();
40
+ const fromEarlier = new Set();
41
+ const written = new Set();
42
+ // Reads are recorded (and guarded against not-yet-produced keys, matching
43
+ // resolveDerived); writes pass THROUGH to the real accumulator so a group
44
+ // that mutates `d` in place analyzes exactly like it runs in production.
45
+ const dProxy = new Proxy(derived, {
46
+ get(t, key) {
47
+ if (typeof key === "string" && key !== "then") {
48
+ if (!(key in t)) throw new Error(`derive: group read "${key}" before any earlier group produced it`);
49
+ fromEarlier.add(key);
50
+ }
51
+ return Reflect.get(t, key);
52
+ },
53
+ set(t, key, v) {
54
+ if (typeof key === "string") written.add(key);
55
+ return Reflect.set(t, key, v);
56
+ },
57
+ });
58
+ const out = fn(recorder(params, raw), dProxy) ?? {};
59
+ const deps = new Set(raw);
60
+ for (const k of fromEarlier) for (const dep of depsOf.get(k) ?? []) deps.add(dep);
61
+ for (const r of raw) allInputs.add(r);
62
+ for (const key of [...Object.keys(out), ...written]) {
63
+ depsOf.set(key, deps);
64
+ if (Object.hasOwn(out, key)) derived[key] = out[key];
65
+ }
66
+ }
67
+ return { derived, allInputs, depsOf };
68
+ }
69
+
22
70
  export function relevantParamKeys(part, view, params) {
23
71
  // The union of every on-screen sub-part's read set (that's exactly what
24
- // subPartReadKeys computes, derive-input folding included)...
72
+ // subPartReadKeys computes, derive attribution included)...
25
73
  const reads = subPartReadKeys(part, view, params);
26
74
  if (reads === RELEVANT_ALL) return RELEVANT_ALL; // analysis failed → everything relevant
27
75
  try {
@@ -46,8 +94,7 @@ export function relevantParamKeys(part, view, params) {
46
94
  // analysis failure (caller then treats every param as relevant — safe, just slower).
47
95
  export function subPartReadKeys(part, view, params) {
48
96
  try {
49
- const deriveInputs = new Set();
50
- const derived = part.derive ? (part.derive(recorder(params, deriveInputs)) ?? {}) : {};
97
+ const { derived, allInputs, depsOf } = analyzeDerive(part, params);
51
98
  const { kernel } = createProbeKernel();
52
99
  const map = new Map();
53
100
  for (const name of viewSubParts(part, view, params)) {
@@ -55,8 +102,20 @@ export function subPartReadKeys(part, view, params) {
55
102
  const reads = new Set();
56
103
  const dSeen = new Set();
57
104
  if (sp.enabled) sp.enabled(recorder(params, reads)); // gate params change presence too
58
- sp.build(kernel, recorder(params, reads), recorder(derived, dSeen));
59
- if (dSeen.size > 0) for (const k of deriveInputs) reads.add(k);
105
+ const built = sp.build(kernel, recorder(params, reads), recorder(derived, dSeen));
106
+ // place() shapes what's on screen too (display pose is baked into the cached
107
+ // mesh), so its reads count — without this, a param consumed only by place()
108
+ // would let the mesh cache skip a rebuild and leave the sub-part misplaced.
109
+ if (sp.place) sp.place(built, { view, purpose: "display", p: recorder(params, reads), d: recorder(derived, dSeen) });
110
+ if (dSeen.size > 0) {
111
+ if (depsOf && [...dSeen].every((k) => depsOf.has(k))) {
112
+ for (const k of dSeen) for (const dep of depsOf.get(k)) reads.add(dep);
113
+ } else {
114
+ // single-function derive, or a derived key no group produced: no
115
+ // attribution possible — fold every derive input in (safe, coarser).
116
+ for (const dep of allInputs) reads.add(dep);
117
+ }
118
+ }
60
119
  map.set(name, reads);
61
120
  }
62
121
  return map;
@@ -0,0 +1,18 @@
1
+ /* partforge design tokens — the light/dark palette. Imported by app.css (so every
2
+ part-app gets it via mount) and exported as "partforge/tokens.css" for other
3
+ consumers (e.g. partforge-cloud's app chrome) to share one source of truth. */
4
+ :root {
5
+ color-scheme: dark;
6
+ --bg: #15181d; --surface: #1f242c; --surface-2: #20262e; --border: #2c333d;
7
+ --text: #d6dbe2; --text-strong: #e7ebf1; --text-2: #cdd4dd;
8
+ --muted: #7d8794; --muted-2: #aab2bd; --status: #8b94a0; --hint: #6b7480;
9
+ --accent: #3f7bf0; --accent-soft: #26314a; --on-accent: #fff; --input-bg: #161a20; --err: #f8746c;
10
+ --mono: ui-monospace, "SF Mono", SFMono-Regular, "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace;
11
+ }
12
+ :root[data-theme="light"] {
13
+ color-scheme: light;
14
+ --bg: #eef1f5; --surface: #ffffff; --surface-2: #f4f6f9; --border: #d4dae2;
15
+ --text: #2b333d; --text-strong: #1a2129; --text-2: #3a434e;
16
+ --muted: #6b7480; --muted-2: #59636f; --status: #6b7480; --hint: #8a93a0;
17
+ --accent: #1f5bd6; --accent-soft: #e6edfc; --on-accent: #fff; --input-bg: #ffffff; --err: #d8453d;
18
+ }
@@ -109,12 +109,14 @@ export default {
109
109
  views: { planter: { label: "Planter" } },
110
110
  // Self-verification (see docs/AUTHORING-PARTS.md "Self-verification"): opt into the
111
111
  // FDM-PLA process profile (bed-fit gate + min-wall warning) and pin the design intent
112
- // — one drainage hole through the base, fits the bed, no interpenetration.
112
+ // — fits the bed, no interpenetration, and the RIGHT genus per case: verify runs
113
+ // across every preset, and "Pen cup"/"Vase" turn the drain off, so `expect` is a
114
+ // function of the case's params rather than one static hole count.
113
115
  verify: {
114
116
  process: "fdm-pla",
115
- expect: {
116
- planter: { holes: 1 /* drain=8 at defaults 1 hole; adjust if running verify with a non-default drain */, bbox: "<=[220,220,250]" },
117
+ expect: (p) => ({
118
+ planter: { holes: p.drain > 0 ? 1 : 0, bbox: "<=[220,220,250]" },
117
119
  _view: { overlaps: 0 } /* _view = whole-model composite (not a named part) */,
118
- },
120
+ }),
119
121
  },
120
122
  };
@@ -2,8 +2,9 @@
2
2
  // Triangle BVH over a mesh in either Manifold non-indexed soup form (9 floats per
3
3
  // triangle, no `indices`) or OCCT indexed form (`positions` = 3 floats/vertex +
4
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.
5
+ // (raycast), nearest surface point (closestPoint), and exact mesh-to-mesh distance
6
+ // (distanceTo). AABB tree, median split on the widest centroid axis, slab ray–box
7
+ // test with pruning.
7
8
 
8
9
  const LEAF = 4; // max triangles per leaf
9
10
 
@@ -104,6 +105,82 @@ function distSqBox(p, min, max) {
104
105
  return s;
105
106
  }
106
107
 
108
+ // summed extent of a node's AABB — the "which node is larger" heuristic for dual traversal
109
+ const nodeExtent = (n) => (n.max[0] - n.min[0]) + (n.max[1] - n.min[1]) + (n.max[2] - n.min[2]);
110
+
111
+ // squared distance between two AABBs (0 when they overlap)
112
+ function boxBoxDistSq(a, b) {
113
+ let s = 0;
114
+ for (let ax = 0; ax < 3; ax++) {
115
+ const v = a.min[ax] > b.max[ax] ? a.min[ax] - b.max[ax] : b.min[ax] > a.max[ax] ? b.min[ax] - a.max[ax] : 0;
116
+ s += v * v;
117
+ }
118
+ return s;
119
+ }
120
+
121
+ // closest points between segments P1→Q1 and P2→Q2 (Ericson 5.1.9), → { a, b, d2 }
122
+ function closestSegSeg(P1, Q1, P2, Q2) {
123
+ const sub = (p, q) => [p[0] - q[0], p[1] - q[1], p[2] - q[2]];
124
+ const dot = (p, q) => p[0] * q[0] + p[1] * q[1] + p[2] * q[2];
125
+ const clamp01 = (x) => (x < 0 ? 0 : x > 1 ? 1 : x);
126
+ const d1 = sub(Q1, P1), d2v = sub(Q2, P2), r = sub(P1, P2);
127
+ const a = dot(d1, d1), e = dot(d2v, d2v), f = dot(d2v, r);
128
+ const EPS = 1e-12;
129
+ let s, t;
130
+ if (a <= EPS && e <= EPS) { s = 0; t = 0; }
131
+ else if (a <= EPS) { s = 0; t = clamp01(f / e); }
132
+ else {
133
+ const c = dot(d1, r);
134
+ if (e <= EPS) { t = 0; s = clamp01(-c / a); }
135
+ else {
136
+ const b = dot(d1, d2v), denom = a * e - b * b;
137
+ s = denom !== 0 ? clamp01((b * f - c * e) / denom) : 0;
138
+ t = (b * s + f) / e;
139
+ if (t < 0) { t = 0; s = clamp01(-c / a); }
140
+ else if (t > 1) { t = 1; s = clamp01((b - c) / a); }
141
+ }
142
+ }
143
+ const A = [P1[0] + d1[0] * s, P1[1] + d1[1] * s, P1[2] + d1[2] * s];
144
+ const B = [P2[0] + d2v[0] * t, P2[1] + d2v[1] * t, P2[2] + d2v[2] * t];
145
+ const pq = sub(A, B);
146
+ return { a: A, b: B, d2: dot(pq, pq) };
147
+ }
148
+
149
+ // exact min distance between two triangles → { d2, a, b } (a on t1, b on t2).
150
+ // Non-intersecting triangles realize their minimum at a vertex-face or edge-edge
151
+ // feature pair; a piercing edge (interior×interior crossing) is caught first with
152
+ // rayTri, since feature distances alone would miss it. rayTri's t is in units of
153
+ // the unnormalized edge direction, so 0 < t <= 1 means the segment itself pierces;
154
+ // parallel/grazing edges return Infinity and the coplanar cases fall to the
155
+ // feature distances.
156
+ function triTriDist(t1, t2) {
157
+ const edges = (t) => [[t.v0, t.v1], [t.v1, t.v2], [t.v2, t.v0]];
158
+ for (const [p, q] of edges(t1)) {
159
+ const d = [q[0] - p[0], q[1] - p[1], q[2] - p[2]];
160
+ const t = rayTri(p, d, t2, 0);
161
+ if (t <= 1) { const at = [p[0] + d[0] * t, p[1] + d[1] * t, p[2] + d[2] * t]; return { d2: 0, a: at, b: at }; }
162
+ }
163
+ for (const [p, q] of edges(t2)) {
164
+ const d = [q[0] - p[0], q[1] - p[1], q[2] - p[2]];
165
+ const t = rayTri(p, d, t1, 0);
166
+ if (t <= 1) { const at = [p[0] + d[0] * t, p[1] + d[1] * t, p[2] + d[2] * t]; return { d2: 0, a: at, b: at }; }
167
+ }
168
+ let best = { d2: Infinity, a: null, b: null };
169
+ for (const v of [t2.v0, t2.v1, t2.v2]) {
170
+ const r = closestOnTri(v, t1);
171
+ if (r.d2 < best.d2) best = { d2: r.d2, a: r.point, b: v };
172
+ }
173
+ for (const v of [t1.v0, t1.v1, t1.v2]) {
174
+ const r = closestOnTri(v, t2);
175
+ if (r.d2 < best.d2) best = { d2: r.d2, a: v, b: r.point };
176
+ }
177
+ for (const [p1, q1] of edges(t1)) for (const [p2, q2] of edges(t2)) {
178
+ const r = closestSegSeg(p1, q1, p2, q2);
179
+ if (r.d2 < best.d2) best = { d2: r.d2, a: r.a, b: r.b };
180
+ }
181
+ return best;
182
+ }
183
+
107
184
  // Möller–Trumbore; returns t>tMin or Infinity
108
185
  function rayTri(o, d, tri, tMin) {
109
186
  const e1 = [tri.v1[0] - tri.v0[0], tri.v1[1] - tri.v0[1], tri.v1[2] - tri.v0[2]];
@@ -144,7 +221,6 @@ export function buildBVH(mesh) {
144
221
  return bestTri === -1 ? null : { t: best, tri: bestTri };
145
222
  }
146
223
 
147
- // No production consumer yet — pre-built + tested as the reusable primitive for the deferred clearance/min-feature gate.
148
224
  function closestPoint(p) {
149
225
  let best2 = Infinity, bestPt = null, bestTri = -1;
150
226
  const stack = [root];
@@ -162,5 +238,36 @@ export function buildBVH(mesh) {
162
238
  return { point: bestPt, dist: Math.sqrt(best2), tri: bestTri };
163
239
  }
164
240
 
165
- return { raycast, closestPoint };
241
+ // Exact minimum surface-to-surface distance to another buildBVH result.
242
+ // Dual traversal pruned by AABB–AABB distance; exact triangle–triangle
243
+ // distance at leaf pairs; early-exits at 0 (touching/intersecting).
244
+ function distanceTo(other) {
245
+ let best = { d2: Infinity, a: null, b: null };
246
+ const stack = [[root, other._root]];
247
+ while (stack.length && best.d2 > 0) {
248
+ const [na, nb] = stack.pop();
249
+ if (boxBoxDistSq(na, nb) >= best.d2) continue;
250
+ const aLeaf = !!na.tris, bLeaf = !!nb.tris;
251
+ if (aLeaf && bLeaf) {
252
+ for (const ta of na.tris) for (const tb of nb.tris) {
253
+ const r = triTriDist(ta, tb);
254
+ if (r.d2 < best.d2) best = r;
255
+ }
256
+ } else if (!aLeaf && (bLeaf || nodeExtent(na) >= nodeExtent(nb))) {
257
+ // descend the larger node; push the nearer child last so it pops first
258
+ const dl = boxBoxDistSq(na.left, nb), dr = boxBoxDistSq(na.right, nb);
259
+ if (dl < dr) stack.push([na.right, nb], [na.left, nb]);
260
+ else stack.push([na.left, nb], [na.right, nb]);
261
+ } else {
262
+ const dl = boxBoxDistSq(na, nb.left), dr = boxBoxDistSq(na, nb.right);
263
+ if (dl < dr) stack.push([na, nb.right], [na, nb.left]);
264
+ else stack.push([na, nb.left], [na, nb.right]);
265
+ }
266
+ }
267
+ if (best.a === null) return { distance: Infinity, at: null, pointA: null, pointB: null }; // empty mesh
268
+ const at = [(best.a[0] + best.b[0]) / 2, (best.a[1] + best.b[1]) / 2, (best.a[2] + best.b[2]) / 2];
269
+ return { distance: Math.sqrt(best.d2), at, pointA: best.a, pointB: best.b };
270
+ }
271
+
272
+ return { raycast, closestPoint, distanceTo, _root: root };
166
273
  }
@@ -0,0 +1,48 @@
1
+ import { buildView } from "./build.js";
2
+ import { buildBVH } from "./bvh.js";
3
+
4
+ // A measured pair distance at or below this (mm) counts as touching — absorbs
5
+ // posing float error while staying far below any real print clearance.
6
+ export const CONTACT_EPS = 1e-3;
7
+
8
+ // Default near-miss threshold (mm): pairs closer than this without touching are
9
+ // the "did you mean these to touch?" signal.
10
+ export const GAP_THRESHOLD = 0.5;
11
+
12
+ // Canonical order-insensitive pair identity — the one rule for "the same pair"
13
+ // shared by measure's overlap exclusion and verify's declared-pair matching.
14
+ export const pairKey = (a, b) => [a, b].sort().join("×");
15
+
16
+ // Minimum surface-to-surface distance for every sub-part pair of pre-built posed
17
+ // meshes ([{ name, mesh }] — buildView output). Distance 0 = touching or
18
+ // interpenetrating surfaces; callers filter. Pairs involving an empty mesh are
19
+ // skipped (the watertight gate owns that failure). Pure mesh math — both backends.
20
+ // → [{ a, b, distance, at: [x,y,z] }]
21
+ export function meshGaps(built) {
22
+ const hasTris = (m) => (m.indices ? m.indices.length > 0 : m.positions.length > 0);
23
+ const bvhs = built
24
+ .filter(({ mesh }) => hasTris(mesh))
25
+ .map(({ name, mesh }) => ({ name, bvh: buildBVH(mesh) }));
26
+ const out = [];
27
+ for (let i = 0; i < bvhs.length; i++) {
28
+ for (let j = i + 1; j < bvhs.length; j++) {
29
+ const { distance, at } = bvhs[i].bvh.distanceTo(bvhs[j].bvh);
30
+ out.push({ a: bvhs[i].name, b: bvhs[j].name, distance, at });
31
+ }
32
+ }
33
+ return out;
34
+ }
35
+
36
+ // Near-miss check for an assembled view — the complement of assemblyOverlaps:
37
+ // sub-part pairs that *almost* touch (0 < distance < threshold mm) in the display
38
+ // pose. Same posing path as assemblyOverlaps; no kernel booleans, so it runs on
39
+ // Manifold and OCCT alike.
40
+ // → [{ a, b, distance, at }] (empty = no near misses)
41
+ export function assemblyGaps(kernel, part, view, params = {}, { threshold = GAP_THRESHOLD } = {}) {
42
+ if (!(threshold > CONTACT_EPS)) {
43
+ throw new Error(`assemblyGaps: threshold must exceed CONTACT_EPS (${CONTACT_EPS} mm), got ${threshold}`);
44
+ }
45
+ const gaps = meshGaps(buildView(kernel, part, view, params));
46
+ kernel.cleanup?.(); // free the per-check WASM objects (meshes are JS-owned copies)
47
+ return gaps.filter((g) => g.distance > CONTACT_EPS && g.distance < threshold);
48
+ }
@@ -1,5 +1,6 @@
1
1
  import { buildView } from "./build.js";
2
2
  import { assemblyOverlaps } from "../framework/assembly.js";
3
+ import { meshGaps, pairKey, CONTACT_EPS, GAP_THRESHOLD } from "./gaps.js";
3
4
  import { bounds, meshArea } from "./mesh.js";
4
5
  import { minWall } from "./min-wall.js";
5
6
 
@@ -11,9 +12,10 @@ const unionBounds = (list) => list.reduce(
11
12
 
12
13
  // Headless geometric report for one view of a part (Manifold-only). Reads exact
13
14
  // solid facts (volume/genus/emptiness) and mesh facts (bbox/area/triangles), plus
14
- // the assembly overlap check. All solid facts are read BEFORE assemblyOverlaps,
15
+ // the assembly overlap check plus pair gap distances (near misses are reported,
16
+ // never folded into `ok`). All solid facts are read BEFORE assemblyOverlaps,
15
17
  // which frees the shared kernel's objects at its end.
16
- // → { part, view, subparts[], aggregate, overlaps[], ok }
18
+ // → { part, view, subparts[], aggregate, overlaps[], gaps[], nearMisses[], ok }
17
19
  export function measure(kernel, part, view = Object.keys(part.views)[0], params = {}, opts = {}) {
18
20
  const built = buildView(kernel, part, view, params);
19
21
  const subBounds = [];
@@ -34,12 +36,24 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
34
36
  };
35
37
  });
36
38
 
39
+ // Pair surface distances from the meshes already built — no kernel dependency,
40
+ // so this reads on OCCT too. nearMisses = the issue-#29 signal: pairs that
41
+ // *almost* touch; overlapping pairs are excluded by name (a fully-contained
42
+ // sub-part has surface distance > 0 but is the overlap gate's business).
43
+ const gaps = built.length > 1 ? meshGaps(built) : [];
44
+
37
45
  // Rebuilds with the same kernel and cleans up at its end — every solid fact
38
46
  // above is already read, so this is safe.
39
47
  const canIntersect = built.length > 0 && typeof built[0].solid.intersect === "function";
40
48
  const overlaps = canIntersect ? assemblyOverlaps(kernel, part, view, params) : [];
41
49
  kernel.cleanup?.();
42
50
 
51
+ const overlapping = new Set(overlaps.map((o) => pairKey(o.a, o.b)));
52
+ const gapThreshold = opts.gapThreshold ?? GAP_THRESHOLD;
53
+ const nearMisses = gaps.filter(
54
+ (g) => g.distance > CONTACT_EPS && g.distance < gapThreshold && !overlapping.has(pairKey(g.a, g.b)),
55
+ );
56
+
43
57
  const aggregate = {
44
58
  bbox: subparts.length ? size(unionBounds(subBounds)) : [0, 0, 0],
45
59
  volume: subparts.reduce((a, s) => a + s.volume, 0),
@@ -52,6 +66,8 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
52
66
  subparts,
53
67
  aggregate,
54
68
  overlaps,
69
+ gaps,
70
+ nearMisses,
55
71
  ok: subparts.every((s) => s.watertight !== false) && overlaps.length === 0,
56
72
  };
57
73
  }
@@ -1,8 +1,10 @@
1
1
  import { parseAssertion, evaluateAssertion } from "./assert-dsl.js";
2
2
  import { measure as defaultMeasure } from "./measure.js";
3
+ import { pairKey, CONTACT_EPS } from "./gaps.js";
3
4
  import { resolveProfile } from "./dfm-profiles.js";
4
5
  import { expandCases } from "./cases.js";
5
6
  import { subPartReadKeys, relevanceHash, RELEVANT_ALL } from "../framework/param-deps.js";
7
+ import { resolveParams } from "../framework/jobs.js";
6
8
 
7
9
  // Metric registry: name → how to pull the value out of facts, whether a failure
8
10
  // is a hard gate or a warning, and the diagnostics attached to a non-pass check:
@@ -44,6 +46,98 @@ const normalizeExpectation = (spec) =>
44
46
  ? { expr: spec.expr, hint: spec.hint }
45
47
  : { expr: spec, hint: undefined };
46
48
 
49
+ const PAIR_HINTS = {
50
+ contact: "the pair should touch but doesn't — grow the joining feature or move the mating datum so the faces meet",
51
+ clearance: "the pair's free-fit gap is out of the declared range — adjust the mating dimensions or the declared clearance",
52
+ nearMiss: "sub-parts nearly touch here — if they should meet, declare the pair in verify.expect._view.contacts and close the gap; if a free fit is intended, declare it under clearance",
53
+ };
54
+
55
+ // Pair-wise view checks: `contacts` (must touch), `clearance` (assertion DSL vs
56
+ // the measured pair distance), and warnings for undeclared near misses. These are
57
+ // per-pair, so they live outside the scalar VIEW_METRICS registry but emit the
58
+ // same structured check objects.
59
+ function pairGapChecks(facts, { contacts, clearance }, subPartNames) {
60
+ const checks = [];
61
+ const declared = new Set();
62
+ const names = new Set(facts.subparts.map((s) => s.name));
63
+ // The part's full sub-part vocabulary (when the caller knows it): a declared
64
+ // name absent from THIS case's facts but present in the part is an
65
+ // enabled()-gated sub-part that is off for this case → skip, don't throw.
66
+ // A name in neither set is a typo → throw. Without the vocabulary (bare
67
+ // evaluateCase callers) the case's own names are the vocabulary.
68
+ const known = subPartNames ? new Set(subPartNames) : names;
69
+ const requirePair = (a, b, what) => {
70
+ if (a === b) throw new Error(`${what}: a pair must name two different sub-parts, got ["${a}", "${b}"]`);
71
+ let absent = false;
72
+ for (const n of [a, b]) {
73
+ if (names.has(n)) continue;
74
+ if (!known.has(n)) throw new Error(`${what}: unknown sub-part "${n}" (view has: ${[...names].join(", ")})`);
75
+ absent = true;
76
+ }
77
+ return absent; // true = valid pair, but a sub-part is disabled in this case
78
+ };
79
+ const gapFor = (a, b) => facts.gaps?.find((g) => pairKey(g.a, g.b) === pairKey(a, b));
80
+ const disabledSkip = (base) => ({ ...base, actual: null, status: "skip", pass: null, message: "sub-part disabled in this case" });
81
+ // No gap table at all = legacy facts → skip. A table that MERELY LACKS the pair
82
+ // = the sub-part built empty (meshGaps skips empty meshes) → a declared gate
83
+ // must fail loudly, not skip, or verify.ok would vouch for an unverified pair.
84
+ const noReading = (base) => (facts.gaps
85
+ ? { ...base, actual: null, status: "fail", pass: false,
86
+ message: "no measured distance for the pair",
87
+ hint: "one sub-part produced no mesh (an empty solid?) — fix the build before trusting this gate" }
88
+ : { ...base, actual: null, status: "skip", pass: null, message: "unavailable" });
89
+
90
+ if (contacts != null && !Array.isArray(contacts)) {
91
+ throw new Error(`contacts: must be an array of ["a", "b"] pairs, got ${JSON.stringify(contacts)}`);
92
+ }
93
+ for (const pair of contacts ?? []) {
94
+ if (!Array.isArray(pair) || pair.length !== 2) {
95
+ throw new Error(`contacts: each entry must be an ["a", "b"] pair, got ${JSON.stringify(pair)}`);
96
+ }
97
+ const [a, b] = pair;
98
+ const disabled = requirePair(a, b, "contacts");
99
+ declared.add(pairKey(a, b));
100
+ const base = { scope: "view", subpart: `${a}×${b}`, metric: "contact", kind: "gate", expr: "touching" };
101
+ if (disabled) { checks.push(disabledSkip(base)); continue; }
102
+ const g = gapFor(a, b);
103
+ if (!g) { checks.push(noReading(base)); continue; }
104
+ const overlapping = (facts.overlaps ?? []).some((o) => pairKey(o.a, o.b) === pairKey(a, b));
105
+ if (overlapping || g.distance <= CONTACT_EPS) {
106
+ checks.push({ ...base, actual: g.distance, status: "pass", pass: true, message: overlapping ? "in contact (overlapping)" : "in contact" });
107
+ } else {
108
+ checks.push({ ...base, actual: g.distance, status: "fail", pass: false,
109
+ message: `${g.distance.toFixed(3)}mm apart, expected touching`,
110
+ hint: PAIR_HINTS.contact, pattern: "near-miss-gap", location: g.at });
111
+ }
112
+ }
113
+
114
+ for (const [key, spec] of Object.entries(clearance ?? {})) {
115
+ const pair = key.split("×").map((s) => s.trim());
116
+ if (pair.length !== 2 || !pair[0] || !pair[1]) throw new Error(`clearance: pair key must be "a×b", got "${key}"`);
117
+ const [a, b] = pair;
118
+ const disabled = requirePair(a, b, "clearance");
119
+ declared.add(pairKey(a, b));
120
+ const { expr, hint: partHint } = normalizeExpectation(spec);
121
+ const base = { scope: "view", subpart: `${a}×${b}`, metric: "clearance", kind: "gate", expr: String(expr) };
122
+ if (disabled) { checks.push(disabledSkip(base)); continue; }
123
+ const g = gapFor(a, b);
124
+ if (!g) { checks.push(noReading(base)); continue; }
125
+ const { pass, message } = evaluateAssertion(parseAssertion(expr), g.distance);
126
+ const out = { ...base, actual: g.distance, status: pass ? "pass" : "fail", pass, message };
127
+ if (!pass) { out.hint = partHint ?? PAIR_HINTS.clearance; out.pattern = "near-miss-gap"; out.location = g.at; }
128
+ checks.push(out);
129
+ }
130
+
131
+ for (const g of facts.nearMisses ?? []) {
132
+ if (declared.has(pairKey(g.a, g.b))) continue;
133
+ checks.push({ scope: "view", subpart: `${g.a}×${g.b}`, metric: "nearMiss", kind: "warn",
134
+ expr: "intent undeclared", actual: g.distance, status: "warn", pass: false,
135
+ message: `${g.distance.toFixed(3)}mm gap`, hint: PAIR_HINTS.nearMiss,
136
+ pattern: "near-miss-gap", location: g.at });
137
+ }
138
+ return checks;
139
+ }
140
+
47
141
  function check(scope, subpart, metric, spec, registry, factsObj) {
48
142
  const reg = registry[metric];
49
143
  if (!reg) throw new Error(`unknown ${scope} metric "${metric}"${subpart ? ` on sub-part "${subpart}"` : ""}`);
@@ -71,13 +165,17 @@ function check(scope, subpart, metric, spec, registry, factsObj) {
71
165
  }
72
166
 
73
167
  // Pure policy: profile rules + per-part expect → checks for one case's facts.
74
- export function evaluateCase(facts, { profile, expect }) {
168
+ export function evaluateCase(facts, { profile, expect, subPartNames }) {
75
169
  const checks = [];
170
+ // contacts/clearance are per-pair, not scalar view metrics — peel them off
171
+ // before the registry loop and hand them to pairGapChecks.
172
+ const { contacts, clearance, ...viewScalarExp } = expect?._view ?? {};
76
173
  const viewExp = {
77
174
  ...(profile?.bed ? { bbox: `<=[${profile.bed.join(",")}]` } : {}),
78
- ...(expect?._view ?? {}),
175
+ ...viewScalarExp,
79
176
  };
80
177
  for (const [metric, expr] of Object.entries(viewExp)) checks.push(check("view", null, metric, expr, VIEW_METRICS, facts));
178
+ checks.push(...pairGapChecks(facts, { contacts, clearance }, subPartNames));
81
179
 
82
180
  for (const s of facts.subparts) {
83
181
  const merged = {
@@ -93,11 +191,22 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
93
191
  view = view ?? Object.keys(part.views)[0];
94
192
  const profileSpec = process ?? part.verify?.process;
95
193
  const profile = profileSpec ? resolveProfile(profileSpec) : null;
96
- const expect = part.verify?.expect ?? {};
97
- const expectMentionsMinWall = Object.values(expect).some((o) => o && typeof o === "object" && "minWall" in o);
98
- const needMinWall = profile?.minWall != null || expectMentionsMinWall;
194
+ const expectSpec = part.verify?.expect ?? {};
99
195
 
100
196
  const cases = expandCases(part);
197
+ // `expect` can be a pure function of the case's resolved params — (p, d) →
198
+ // expect object — so topology that legitimately changes with a preset (an
199
+ // optional drain or bore flipping the genus) can be pinned per case instead
200
+ // of one static number that some presets must violate.
201
+ const resolveExpect = (params) => {
202
+ if (typeof expectSpec !== "function") return expectSpec;
203
+ const { p, d } = resolveParams(part, params);
204
+ return expectSpec(p, d) ?? {};
205
+ };
206
+ const expanded = cases.map((c) => ({ ...c, expect: resolveExpect(c.params) }));
207
+ const expectMentionsMinWall = expanded.some(({ expect }) =>
208
+ Object.values(expect).some((o) => o && typeof o === "object" && "minWall" in o));
209
+ const needMinWall = profile?.minWall != null || expectMentionsMinWall;
101
210
  const readKeys = subPartReadKeys(part, view, part.defaults);
102
211
  const signature = (params) =>
103
212
  readKeys === RELEVANT_ALL
@@ -111,7 +220,8 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
111
220
  return memo.get(key);
112
221
  };
113
222
 
114
- const caseResults = cases.map(({ name, params }) => ({ name, params, checks: evaluateCase(measureCase(params), { profile, expect }) }));
223
+ const subPartNames = Object.keys(part.parts);
224
+ const caseResults = expanded.map(({ name, params, expect }) => ({ name, params, checks: evaluateCase(measureCase(params), { profile, expect, subPartNames }) }));
115
225
  const all = caseResults.flatMap((c) => c.checks.map((ch) => ({ case: c.name, ...ch })));
116
226
  return {
117
227
  ok: !all.some((c) => c.status === "fail"),
package/src/testing.js CHANGED
@@ -4,7 +4,10 @@
4
4
  export { createManifoldKernel } from "./framework/geometry/manifold-backend.js";
5
5
  export { bootManifoldKernel } from "./testing/manifold.js";
6
6
  export { handle, viewSubParts } from "./framework/jobs.js";
7
+ export { resolveDerived } from "./framework/derive.js";
8
+ export { relevantParamKeys, RELEVANT_ALL } from "./framework/param-deps.js";
7
9
  export { assemblyOverlaps } from "./framework/assembly.js";
10
+ export { assemblyGaps, meshGaps } from "./testing/gaps.js";
8
11
  export { bootOcctKernel } from "./testing/occt.js";
9
12
  export { meshVolume, bboxSize } from "./testing/mesh.js";
10
13
  export { buildView } from "./testing/build.js";