partforge 0.8.0 → 0.10.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
@@ -14,10 +14,31 @@ import { measure } from "../src/testing/measure.js";
14
14
  import { verify } from "../src/testing/verify.js";
15
15
  import { renderViews } from "../src/testing/render.js";
16
16
  import { createPickServer, requestPicks, formatPickResult } from "../src/framework/pick-request/server.js";
17
+ import { matchPattern } from "../src/testing/error-patterns.js";
17
18
 
18
19
  const die = (msg) => { console.error(msg); process.exit(1); };
19
20
  const USAGE = "usage: partforge <measure|render|pick-serve|pick> …";
20
21
 
22
+ // Crash contract (issue #27): with --json, a thrown error becomes structured
23
+ // stdout JSON; either way the message is matched against ERROR-PATTERNS.md and
24
+ // the pattern's fix is surfaced. Exit 1 always. NOTE on stdout purity: crash
25
+ // JSON is the only thing on stdout for errors thrown before any report printing
26
+ // (load/boot/measure). But verify() runs after printMeasure and can throw (an
27
+ // unknown metric in verify.expect, or a per-case build crash), so a throw after
28
+ // printing appends the JSON after the human lines — it is not pure. Consumers
29
+ // should prefer --out for robust machine parsing.
30
+ function crash(cmd, e, jsonMode) {
31
+ const message = e?.message || String(e);
32
+ const m = matchPattern(message);
33
+ if (jsonMode) {
34
+ console.log(JSON.stringify({ ok: false, error: { message, ...(m && { pattern: m.id, hint: m.fix }) } }, null, 2));
35
+ } else {
36
+ console.error(`${cmd} failed: ${message}`);
37
+ if (m) console.error(`pattern: ERROR-PATTERNS.md#${m.id} — ${m.fix}`);
38
+ }
39
+ process.exit(1);
40
+ }
41
+
21
42
  const parse = (args, options, usage) => {
22
43
  try {
23
44
  return parseArgs({ args, options, strict: true, allowPositionals: true });
@@ -29,9 +50,9 @@ const parse = (args, options, usage) => {
29
50
  async function loadPart(partPath, usage) {
30
51
  if (!partPath) die(usage);
31
52
  const mod = await import(pathToFileURL(resolve(process.cwd(), partPath)))
32
- .catch((e) => die(`cannot load part "${partPath}": ${e.message}`));
53
+ .catch((e) => { throw new Error(`cannot load part "${partPath}": ${e.message}`); });
33
54
  const part = mod.default;
34
- if (!part?.parts || !part?.views) die(`"${partPath}" has no default-exported PartDefinition`);
55
+ if (!part?.parts || !part?.views) throw new Error(`"${partPath}" has no default-exported PartDefinition`);
35
56
  return part;
36
57
  }
37
58
 
@@ -46,27 +67,33 @@ const commands = {
46
67
  json: { type: "boolean" },
47
68
  out: { type: "string" },
48
69
  }, usage);
49
- const part = await loadPart(partPath, usage);
50
- const kernel = await bootKernel(part);
51
70
  try {
71
+ const part = await loadPart(partPath, usage);
72
+ const kernel = await bootKernel(part);
52
73
  const report = measure(kernel, part, view);
53
74
  printMeasure(report);
75
+ // Write --out right after measure succeeds, then re-write once verify has
76
+ // attached report.verify. If verify throws (unknown metric, per-case build
77
+ // crash) the file already holds the measure half (no `verify` key) — matching
78
+ // the doc's advice to prefer --out for exactly that non-pure-stdout case.
79
+ const writeOut = () => {
80
+ mkdirSync(dirname(resolve(flags.out)), { recursive: true });
81
+ writeFileSync(flags.out, JSON.stringify(report, null, 2));
82
+ };
83
+ if (flags.out) writeOut();
54
84
  let vok = true;
55
85
  if ((part.verify || flags.process) && !flags["no-verify"]) {
56
86
  const v = verify(kernel, part, { process: flags.process, view });
57
87
  printVerify(v);
58
88
  report.verify = v;
59
89
  vok = v.ok;
90
+ if (flags.out) writeOut();
60
91
  }
61
- if (flags.out) {
62
- mkdirSync(dirname(resolve(flags.out)), { recursive: true });
63
- writeFileSync(flags.out, JSON.stringify(report, null, 2));
64
- console.log(`\nwrote ${flags.out}`);
65
- }
92
+ if (flags.out) console.log(`\nwrote ${flags.out}`);
66
93
  if (flags.json) console.log(JSON.stringify(report, null, 2));
67
94
  process.exit(report.ok && vok ? 0 : 1);
68
95
  } catch (e) {
69
- die(`measure failed: ${e.message || e}`);
96
+ crash("measure", e, !!flags.json);
70
97
  }
71
98
  },
72
99
 
@@ -76,15 +103,15 @@ const commands = {
76
103
  views: { type: "string" },
77
104
  out: { type: "string" },
78
105
  }, usage);
79
- const part = await loadPart(partPath, usage);
80
- const kernel = await bootKernel(part);
81
106
  try {
107
+ const part = await loadPart(partPath, usage);
108
+ const kernel = await bootKernel(part);
82
109
  const views = flags.views ? flags.views.split(",") : undefined;
83
110
  const files = await renderViews(kernel, part, view, { views, out: flags.out || "render" });
84
111
  for (const f of files) console.log(`wrote ${f}`);
85
112
  process.exit(0);
86
113
  } catch (e) {
87
- die(`render failed: ${e.message || e}`);
114
+ crash("render", e, false);
88
115
  }
89
116
  },
90
117
 
@@ -120,7 +147,12 @@ function printMeasure(r) {
120
147
  }
121
148
  const a = r.aggregate;
122
149
  console.log(` ── view bbox ${a.bbox.map((n) => n.toFixed(1)).join("×")} vol ${(a.volume / 1000).toFixed(2)}cm³ tris ${a.triangleCount}`);
123
- console.log(` overlaps: ${r.overlaps.length ? r.overlaps.map((o) => `${o.a}×${o.b} (${o.volume.toFixed(1)}mm³)`).join(", ") : "none"}`);
150
+ console.log(` overlaps: ${r.overlaps.length
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
+ : "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"}`);
124
156
  }
125
157
 
126
158
  function printVerify(v) {
@@ -130,6 +162,10 @@ function printVerify(v) {
130
162
  for (const ch of c.checks) {
131
163
  const icon = ch.status === "pass" ? "✓" : ch.status === "fail" ? "✗" : ch.status === "warn" ? "⚠" : "·";
132
164
  console.log(` ${icon} ${ch.subpart ?? "_view"} ${ch.metric} ${ch.expr} (${ch.message})`);
165
+ if (ch.status === "fail" || ch.status === "warn") {
166
+ if (ch.location) console.log(` at [${ch.location.map((n) => n.toFixed(1)).join(", ")}]`);
167
+ if (ch.hint) console.log(` hint: ${ch.hint}${ch.pattern ? ` (ERROR-PATTERNS.md#${ch.pattern})` : ""}`);
168
+ }
133
169
  }
134
170
  }
135
171
  const f = v.failures.length, w = v.warnings.length;
@@ -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).
@@ -431,9 +483,10 @@ stylesheet). `mount` looks up these element IDs:
431
483
  Copy `demo.html` and change the title, the panel heading, and the `<script src>`. Two workers are spawned from your one worker entry
432
484
  (`name` = `"manifold"` for preview/STL/3MF, `"occt"` for STEP — handled for you).
433
485
 
434
- > Production deploy builds `index.html` only. Extra `*.html` files are **dev-only**
435
- > (Vite serves any root HTML in `npm run dev`). To also ship one, add it to
436
- > `build.rollupOptions.input` in `vite.config.js`.
486
+ > Production deploy compiles only the pages listed in `build.rollupOptions.input`
487
+ > (currently the landing gallery + the demo part pages). Other root `*.html` files are
488
+ > **dev-only** (Vite serves any root HTML in `npm run dev`) unless added there. To also
489
+ > ship one, add it to `build.rollupOptions.input` in `vite.config.js`.
437
490
 
438
491
  ### Developing against a local (linked) partforge
439
492
 
@@ -457,11 +510,11 @@ Tests run under **Node 24** (`nvm use` first; the default shell Node is too old)
457
510
  `npx vitest run`. Build geometry directly off your part with a Manifold kernel:
458
511
 
459
512
  ```js
460
- import { bootManifoldKernel } from "partforge/testing";
513
+ import { bootManifoldKernel, resolveDerived } from "partforge/testing";
461
514
  import part from "../src/parts/<part>.js";
462
515
 
463
516
  const k = await bootManifoldKernel();
464
- 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));
465
518
  expect(solid.toMesh().triangles).toBeGreaterThan(0);
466
519
  ```
467
520
 
@@ -497,7 +550,10 @@ check it without opening the app:
497
550
 
498
551
  `measure` prints a report: per sub-part and per view it reports bounding box,
499
552
  volume, surface area, triangle count, whether the solid is watertight, and the
500
- 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
501
557
  if any sub-part isn't watertight or any parts interpenetrate — so it doubles as a
502
558
  CI/agent gate. Add `--json` to also dump the report as JSON on stdout, or
503
559
  `--out report.json` to write it to a file (nothing is written otherwise). (Manifold output is
@@ -518,6 +574,58 @@ The `measure` function is also exported for vitest (boot a Manifold kernel as in
518
574
  expect(r.subparts[0].holes).toBe(1); // e.g. expects one bore
519
575
  });
520
576
 
577
+ ### The diagnostics contract (for agents)
578
+
579
+ `partforge measure <part> --json` / `--out <file>` emits the machine-readable
580
+ report. Every `fail`/`warn` check in `verify.failures` / `verify.warnings`
581
+ carries:
582
+
583
+ - `hint` — one self-contained corrective sentence (always present),
584
+ - `pattern` — a stable [ERROR-PATTERNS.md](ERROR-PATTERNS.md) entry ID when one
585
+ applies (follow it with `ERROR-PATTERNS.md#<id>`),
586
+ - `location` — `[x, y, z]` in mm where the metric has one: `minWall` (thinnest
587
+ sample point) and `overlaps` (the center of the first offending intersection's
588
+ *bounding box* — a nearby indicator, not an exact point: when a pair overlaps in
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.
593
+
594
+ Subpart facts include `minWall` (number or `null` — null exactly when no reading
595
+ exists, e.g. the OCCT backend or min-wall measurement turned off, matching
596
+ `minWallAt`'s null) and `minWallAt` (`[x,y,z]` or `null`); overlap entries are
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).
600
+
601
+ A **thrown** error (bad part module, kernel failure) with `--json` prints pure
602
+ JSON to stdout and exits 1:
603
+
604
+ ```json
605
+ { "ok": false, "error": { "message": "…", "pattern": "<id>", "hint": "…" } }
606
+ ```
607
+
608
+ `pattern`/`hint` appear when the message matches an ERROR-PATTERNS.md symptom
609
+ string. Exit codes: 0 pass, 1 gate failure or crash — unchanged. Caveat: a throw
610
+ *after* measure output has printed (e.g. an unknown metric in `verify.expect`, or
611
+ a per-case build crash) appends this JSON after the human lines, so stdout is no
612
+ longer pure JSON; prefer `--out` (or parse the trailing JSON object — the crash
613
+ JSON is pretty-printed across multiple lines) for robust machine parsing. With
614
+ `--out` the measure report is written to the file as soon as `measure` succeeds,
615
+ so even if a later `verify` throw crashes the run the file is there — it just
616
+ lacks the `verify` key.
617
+
618
+ **Part-authored hints.** Any `verify.expect` metric accepts `{ expr, hint }` in
619
+ place of a bare expression — use it to name the governing parameter:
620
+
621
+ ```js
622
+ verify: {
623
+ expect: {
624
+ body: { minWall: { expr: ">=1.2", hint: "increase `wallThickness` or reduce `twist`" } },
625
+ },
626
+ }
627
+ ```
628
+
521
629
  ---
522
630
 
523
631
  ## Self-verification (the `verify` block)
@@ -533,7 +641,9 @@ verify: {
533
641
  cases: ["defaults", "M3"], // optional; default = defaults + every preset
534
642
  expect: { // design intent, by sub-part name (+ "_view")
535
643
  spacer: { holes: 1, bbox: "<=[60,60,60]", volume: "0.4..0.6cm3" },
536
- _view: { overlaps: 0 },
644
+ _view: { overlaps: 0,
645
+ contacts: [["drum", "flange"]], // these pairs must touch
646
+ clearance: { "lid×body": ">=0.3" } }, // intended free fits
537
647
  },
538
648
  }
539
649
  ```
@@ -542,7 +652,7 @@ verify: {
542
652
  and a **min-wall** warning. **What `expect` gives you:** per-sub-part assertions on the
543
653
  facts `measure` already reports — `holes` (through-bores / genus), `volume`,
544
654
  `surfaceArea`, `triangleCount`, `bbox`, `watertight`, `minWall`; and `_view` assertions
545
- `bbox`, `volume`, `overlaps`.
655
+ `bbox`, `volume`, `overlaps`, plus the pair-wise `contacts` / `clearance` below.
546
656
 
547
657
  **Assertion DSL:** a bare number means equality (`holes: 1`); `">=n"`, `"<=n"`, `">n"`,
548
658
  `"<n"`, or a range `"a..b"`; an optional unit suffix `mm`/`cm`/`mm3`/`cm3`; and for
@@ -555,6 +665,53 @@ The parser is strict — a malformed assertion fails loudly.
555
665
  `holes`/`watertight` are Manifold-only, so those assertions **skip** on OCCT parts
556
666
  rather than fail.
557
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
+
558
715
  **Running it:**
559
716
 
560
717
  ```bash
@@ -596,7 +753,9 @@ whole part to OCCT — no declaration needed:
596
753
  - `{ dir: "X"|"Y"|"Z" }` — edges running along an axis (e.g. `{dir:"Z"}` = the vertical edges)
597
754
  - `{ inPlane: "XY"|"XZ"|"YZ", at }` — edges lying in a plane (e.g. base edges: `{inPlane:"XY", at:0}`)
598
755
  - `{ near: [x,y,z] }` — edges passing through a point
599
- - 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`)
600
759
 
601
760
  ```js
602
761
  let s = k.box([0,0,0],[40,30,16]);
@@ -622,20 +781,23 @@ entirely on OCCT, its fillets are exact in the STEP **and** present in the print
622
781
 
623
782
  ## Conventions & gotchas
624
783
 
625
- - **replicad (OCCT) transforms consume their input.** `s.translate/.rotate/.mirror/.cut`
626
- delete the operand and return a new solid; never reuse a solid after transforming it.
627
- The framework rebuilds each sub-part fresh per job and applies `place` once, which
628
- avoids this follow the same pattern in your own code.
629
- - **Part modules are DOM-free and side-effect-free** — they import into both the main
630
- thread (schema controls) and the worker (build kernel).
784
+ When something fails confusingly, **grep [ERROR-PATTERNS.md](ERROR-PATTERNS.md) for the
785
+ symptom first** it maps error text cause fix. The invariants, one line each:
786
+
787
+ - **replicad (OCCT) transforms consume their input** never reuse a transformed solid;
788
+ `.clone()` first ([replicad-consumed-operand](ERROR-PATTERNS.md#replicad-consumed-operand)).
789
+ - **Part modules are DOM-free and side-effect-free** they load in both the main thread
790
+ and the worker ([worker-imports-main-entry](ERROR-PATTERNS.md#worker-imports-main-entry)).
791
+ - **`build` is a pure function of `(k, p, d)`** — impurity silently defeats the geometry
792
+ cache ([impure-build-stale-preview](ERROR-PATTERNS.md#impure-build-stale-preview)).
631
793
  - **Units are millimetres** throughout.
632
- - **Preview vs print quality.** Manifold bakes segment counts in at primitive creation,
633
- so the export path uses a separate high-res "print" kernel — your `build` is quality-
634
- agnostic; just build the geometry.
635
- - **Display placement is view-independent** (so meshes cache across views); only
636
- `place(..., { purpose: "export" })` may depend on `view`.
637
- - Keep geometry backend-agnostic (kernel calls only) so it works in both backends; only
638
- STEP requires OCCT.
794
+ - **Preview vs print quality:** Manifold bakes segment counts in at primitive creation,
795
+ so builds are quality-agnostic; the export path uses a separate high-res "print" kernel.
796
+ - **Display placement is view-independent**; only `place(..., { purpose: "export" })` may
797
+ depend on `view` ([view-dependent-display-place](ERROR-PATTERNS.md#view-dependent-display-place)).
798
+ - **Keep geometry backend-agnostic** (kernel calls only); only STEP requires OCCT
799
+ ([probe-routed-to-occt](ERROR-PATTERNS.md#probe-routed-to-occt),
800
+ [occt-holes-watertight-na](ERROR-PATTERNS.md#occt-holes-watertight-na)).
639
801
 
640
802
  ---
641
803
 
@@ -0,0 +1,168 @@
1
+ # Error patterns — symptom-indexed lookup
2
+
3
+ When a build, test, `measure`, or `verify` run fails confusingly: **grep this file
4
+ for the symptom first** — the literal error text, or a phrase describing the
5
+ misbehavior — before debugging from scratch.
6
+
7
+ **How to add a pattern** (`##` headings are reserved for pattern entries — the lint
8
+ test parses every one; keep prose like this as plain paragraphs):
9
+
10
+ - One pattern per `## <id>` heading. The heading is a **stable kebab-case ID**:
11
+ permanent once committed — never renamed, never reused. External consumers
12
+ (issue #27 diagnostics, HARDWARE.md, skills) cite `ERROR-PATTERNS.md#<id>`.
13
+ - **Namespaces:** core framework patterns are bare slugs. Subsystem patterns take
14
+ a reserved prefix — `hardware-*` is reserved for the parts library (issue #30).
15
+ One `#`-level section per namespace.
16
+ - Entry shape — exactly these three list lines, then optional note paragraphs:
17
+ - **Symptom:** the literal string an agent would see, verbatim in backticks,
18
+ when one exists; otherwise the observable misbehavior. This is the grep target.
19
+ - **Cause:** one sentence.
20
+ - **Fix:** the concrete change, linking the governing rule
21
+ ([AUTHORING-PARTS.md](AUTHORING-PARTS.md) section) rather than restating it.
22
+ - No tables inside entries.
23
+ - Code that throws should throw greppable strings: an error message thrown by
24
+ partforge should appear verbatim, in a backtick literal **at the start** of its
25
+ pattern's Symptom line. Only that leading literal is what the crash matcher
26
+ matches on — backticks used for prose later in the line never participate, so a
27
+ reworded Symptom must lead with the thrown string, not bury it mid-sentence.
28
+ - `test/error-patterns.test.js` lints this file's structure.
29
+
30
+ # Core framework
31
+
32
+ ## worker-imports-main-entry
33
+
34
+ - **Symptom:** `ReferenceError: document is not defined` thrown from a worker build.
35
+ - **Cause:** The part (or a helper it imports) imports `partforge` instead of `partforge/geometry`, and the main entry pulls in the DOM viewer/controls.
36
+ - **Fix:** Import geometry helpers only from `partforge/geometry` in anything a worker loads. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Geometry: the kernel / `Solid` API".
37
+
38
+ ## impure-build-stale-preview
39
+
40
+ - **Symptom:** Preview geometry doesn't change after editing the part's `build` (or changes once, then sticks), with no error anywhere.
41
+ - **Cause:** The preview kernel memoizes geometry by content hash, and an impure `build` (`Math.random`, clock, module-level mutable state) silently defeats it.
42
+ - **Fix:** Make `build` a pure function of `(k, p, d)`; move randomness/state into `derive` inputs or delete it. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Caching & determinism".
43
+
44
+ ## replicad-consumed-operand
45
+
46
+ - **Symptom:** On the OCCT backend a solid is unexpectedly empty, or the build crashes, right after the same solid was transformed or used in a boolean — often only in STEP export, with the Manifold preview fine.
47
+ - **Cause:** replicad transforms and booleans (`translate`/`rotate`/`mirror`/`cut`/…) consume their operand — the input solid is deleted and a new one returned.
48
+ - **Fix:** Never reuse a solid after transforming it; take a `.clone()` first when you need the original again. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Geometry: the kernel / `Solid` API" (the `s.clone()` row).
49
+
50
+ The framework itself rebuilds each sub-part fresh per job and applies `place` once, which avoids the problem — follow the same pattern in your own code.
51
+
52
+ ## probe-routed-to-occt
53
+
54
+ - **Symptom:** A part builds far slower than expected (preview takes seconds instead of milliseconds), and the worker logs show it running on the `occt` worker.
55
+ - **Cause:** The geometry-free probe runs `build` against a recording proxy (dummy query values), and a `fillet`/`chamfer`/`shell` call it reaches — including a branch the real build wouldn't take, since queries return dummies — routes the whole part to OCCT.
56
+ - **Fix:** Remove the CAD-only call the probe reaches unnecessarily, or force the backend with `meta.backend: "manifold"` (or `"occt"`). See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Fillet & chamfer (automatic OCCT backend)".
57
+
58
+ ## boolean-not-watertight
59
+
60
+ - **Symptom:** `NOT watertight ✗` from `partforge measure` (non-zero exit) after adding a boolean cut or union.
61
+ - **Cause:** A coplanar-face or grazing-cut degeneracy — the tool surface exactly touches the body surface, leaving zero-thickness geometry.
62
+ - **Fix:** Overcut: extend the tool past the faces it pierces (e.g. the demo's cut tool is `h + 4` starting at `z = -2`) and avoid exactly-flush faces in unions. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Verifying a part headlessly (render + measure)".
63
+
64
+ ## dual-kernel-same-process
65
+
66
+ - **Symptom:** A test file crashes or hangs (WASM abort) when it boots both geometry kernels.
67
+ - **Cause:** OCCT and Manifold WASM must not boot in the same process.
68
+ - **Fix:** Keep OCCT-booting tests in their own files (vitest isolates per file) and boot via `bootOcctKernel()` in a `beforeAll`. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Testing a part".
69
+
70
+ ## view-dependent-display-place
71
+
72
+ - **Symptom:** A sub-part renders correctly in one view but appears misplaced (usually in its other-view pose) after switching views.
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
+ - **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
+
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
+
82
+ ## wrong-node-version
83
+
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.
85
+ - **Cause:** The shell's default Node is older than the required Node 24 (`.nvmrc` pins it).
86
+ - **Fix:** Run `nvm use` before `npm install`, tests, or any `npx partforge` command. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Quickstart".
87
+
88
+ ## worker-url-not-inline
89
+
90
+ - **Symptom:** The app loads but geometry never builds — the worker 404s or is missing from the production bundle (works in `npm run dev`, breaks in `npm run build`).
91
+ - **Cause:** The `new Worker(new URL(...))` call was moved out of the app entry file (into a helper or variable), so Vite's static analysis can't see and bundle the worker.
92
+ - **Fix:** Keep `new Worker(new URL("./<part>-worker.js", import.meta.url), ...)` inline in `src/app-<part>.js`. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Wiring a part into a runnable app".
93
+
94
+ ## minwall-sliver-triangles
95
+
96
+ - **Symptom:** `⚠` minWall warnings from `verify` on a faceted part whose walls are clearly thicker than the profile minimum.
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.
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)".
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
+
112
+ ## param-key-missing-from-defaults
113
+
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.
115
+ - **Cause:** A `key` used in the `parameters` schema (slider, feature, or preset override) doesn't exist in `defaults` — every key must, including `hidden` ones.
116
+ - **Fix:** Add the key to `defaults` with a sensible starting value. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Parameters: the control-panel schema".
117
+
118
+ ## dimmed-control-vestigial-param
119
+
120
+ - **Symptom:** A control renders dimmed (but still editable) and changing it does nothing on screen.
121
+ - **Cause:** No sub-part visible in the active view reads that parameter — the relevance-aware panel dims controls with no on-screen effect.
122
+ - **Fix:** This is a signal, not a bug: either the parameter is vestigial (delete it), the control is in the wrong section/view scope, or you're in a view that legitimately doesn't use it. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "The relevance-aware panel".
123
+
124
+ ## linked-checkout-wasm-403
125
+
126
+ - **Symptom:** In a consuming app using an `npm link`ed partforge checkout, the kernel never boots and the dev-server network tab shows `403` on the Manifold/OCCT `.wasm` files.
127
+ - **Cause:** The linked checkout lives outside the app's project root, so Vite's dev server refuses to serve its files.
128
+ - **Fix:** Allow-list it: `server: { fs: { allow: ["./", "../partforge"] } }` in the app's `vite.config.js`. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Developing against a local (linked) partforge".
129
+
130
+ ## ring-sector-full-circle
131
+
132
+ - **Symptom:** `ringSectorPolygon: arcDeg must be < 360 (use a cut for a full ring)`
133
+ - **Cause:** A full annulus can't be a single simple polygon — it's a contour-with-hole.
134
+ - **Fix:** Cut an inner cylinder from an outer one (or `k.extrude({ outer, holes })`); use `ringSectorPolygon` only for partial arcs. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Profiles & patterns".
135
+
136
+ ## occt-closed-loop-unsupported
137
+
138
+ - **Symptom:** `loft: closed:true loops are only supported on the Manifold backend` (or the same message from `sweep:`) — typically during STEP export of a part that previews fine.
139
+ - **Cause:** Capless closed loops are a Manifold-only capability; the OCCT backend rejects them, and STEP export always runs on OCCT.
140
+ - **Fix:** Keep the part on Manifold (no STEP) or model the loop as a capped solid both backends support. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Geometry: the kernel / `Solid` API".
141
+
142
+ ## smooth-geometry-faceted-preview
143
+
144
+ - **Symptom:** A `ruled:false` loft or `smooth:true` sweep looks faceted/straight-walled in the viewer even though the options are set.
145
+ - **Cause:** Smooth blending is OCCT-native; the Manifold preview always tessellates ruled straight walls — only STEP export carries the smooth surface.
146
+ - **Fix:** Nothing is wrong — verify smoothness in the exported STEP, not the preview. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Geometry: the kernel / `Solid` API".
147
+
148
+ ## scale-moved-the-part
149
+
150
+ - **Symptom:** After `s.scale(f)` a part is resized but also relocated — features drift away from where they were built.
151
+ - **Cause:** `scale(factor, center?)` defaults its center to the origin, so scaling an off-origin solid about the origin also translates it.
152
+ - **Fix:** Pass the center you mean, e.g. `s.scale(f, s.boundingBox().center)` to resize in place. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Geometry: the kernel / `Solid` API".
153
+
154
+ ## occt-holes-watertight-na
155
+
156
+ - **Symptom:** `watertight n/a` in `partforge measure` output, and `holes`/`watertight` assertions in a `verify` block don't run, on a part with fillets/chamfers.
157
+ - **Cause:** `holes` and `watertight` are Manifold-only topology facts, and this part auto-routed to OCCT — the assertions skip rather than fail.
158
+ - **Fix:** Expected behavior: assert on backend-independent facts (`bbox`, `volume`, `overlaps`) for OCCT parts, or split topology assertions into a Manifold-buildable configuration. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Self-verification (the `verify` block)".
159
+
160
+ ## html-page-missing-in-prod
161
+
162
+ - **Symptom:** A part's page 404s in the production deploy while working fine under `npm run dev`.
163
+ - **Cause:** Only pages listed in `build.rollupOptions.input` are compiled by the production build; other root `*.html` pages are dev-only conveniences Vite serves without building.
164
+ - **Fix:** Add the page to `build.rollupOptions.input` in `vite.config.js` if it should ship. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Wiring a part into a runnable app".
165
+
166
+ # Hardware library
167
+
168
+ Reserved for `hardware-*` patterns (issue #30). No entries yet.