partforge 0.8.0 → 0.9.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,9 @@ 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"}`);
124
153
  }
125
154
 
126
155
  function printVerify(v) {
@@ -130,6 +159,10 @@ function printVerify(v) {
130
159
  for (const ch of c.checks) {
131
160
  const icon = ch.status === "pass" ? "✓" : ch.status === "fail" ? "✗" : ch.status === "warn" ? "⚠" : "·";
132
161
  console.log(` ${icon} ${ch.subpart ?? "_view"} ${ch.metric} ${ch.expr} (${ch.message})`);
162
+ if (ch.status === "fail" || ch.status === "warn") {
163
+ if (ch.location) console.log(` at [${ch.location.map((n) => n.toFixed(1)).join(", ")}]`);
164
+ if (ch.hint) console.log(` hint: ${ch.hint}${ch.pattern ? ` (ERROR-PATTERNS.md#${ch.pattern})` : ""}`);
165
+ }
133
166
  }
134
167
  }
135
168
  const f = v.failures.length, w = v.warnings.length;
@@ -431,9 +431,10 @@ stylesheet). `mount` looks up these element IDs:
431
431
  Copy `demo.html` and change the title, the panel heading, and the `<script src>`. Two workers are spawned from your one worker entry
432
432
  (`name` = `"manifold"` for preview/STL/3MF, `"occt"` for STEP — handled for you).
433
433
 
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`.
434
+ > Production deploy compiles only the pages listed in `build.rollupOptions.input`
435
+ > (currently the landing gallery + the demo part pages). Other root `*.html` files are
436
+ > **dev-only** (Vite serves any root HTML in `npm run dev`) unless added there. To also
437
+ > ship one, add it to `build.rollupOptions.input` in `vite.config.js`.
437
438
 
438
439
  ### Developing against a local (linked) partforge
439
440
 
@@ -518,6 +519,54 @@ The `measure` function is also exported for vitest (boot a Manifold kernel as in
518
519
  expect(r.subparts[0].holes).toBe(1); // e.g. expects one bore
519
520
  });
520
521
 
522
+ ### The diagnostics contract (for agents)
523
+
524
+ `partforge measure <part> --json` / `--out <file>` emits the machine-readable
525
+ report. Every `fail`/`warn` check in `verify.failures` / `verify.warnings`
526
+ carries:
527
+
528
+ - `hint` — one self-contained corrective sentence (always present),
529
+ - `pattern` — a stable [ERROR-PATTERNS.md](ERROR-PATTERNS.md) entry ID when one
530
+ applies (follow it with `ERROR-PATTERNS.md#<id>`),
531
+ - `location` — `[x, y, z]` in mm where the metric has one: `minWall` (thinnest
532
+ sample point) and `overlaps` (the center of the first offending intersection's
533
+ *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.
536
+
537
+ Subpart facts include `minWall` (number or `null` — null exactly when no reading
538
+ exists, e.g. the OCCT backend or min-wall measurement turned off, matching
539
+ `minWallAt`'s null) and `minWallAt` (`[x,y,z]` or `null`); overlap entries are
540
+ `{ a, b, volume, location }`.
541
+
542
+ A **thrown** error (bad part module, kernel failure) with `--json` prints pure
543
+ JSON to stdout and exits 1:
544
+
545
+ ```json
546
+ { "ok": false, "error": { "message": "…", "pattern": "<id>", "hint": "…" } }
547
+ ```
548
+
549
+ `pattern`/`hint` appear when the message matches an ERROR-PATTERNS.md symptom
550
+ string. Exit codes: 0 pass, 1 gate failure or crash — unchanged. Caveat: a throw
551
+ *after* measure output has printed (e.g. an unknown metric in `verify.expect`, or
552
+ a per-case build crash) appends this JSON after the human lines, so stdout is no
553
+ longer pure JSON; prefer `--out` (or parse the trailing JSON object — the crash
554
+ JSON is pretty-printed across multiple lines) for robust machine parsing. With
555
+ `--out` the measure report is written to the file as soon as `measure` succeeds,
556
+ so even if a later `verify` throw crashes the run the file is there — it just
557
+ lacks the `verify` key.
558
+
559
+ **Part-authored hints.** Any `verify.expect` metric accepts `{ expr, hint }` in
560
+ place of a bare expression — use it to name the governing parameter:
561
+
562
+ ```js
563
+ verify: {
564
+ expect: {
565
+ body: { minWall: { expr: ">=1.2", hint: "increase `wallThickness` or reduce `twist`" } },
566
+ },
567
+ }
568
+ ```
569
+
521
570
  ---
522
571
 
523
572
  ## Self-verification (the `verify` block)
@@ -622,20 +671,23 @@ entirely on OCCT, its fillets are exact in the STEP **and** present in the print
622
671
 
623
672
  ## Conventions & gotchas
624
673
 
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).
674
+ When something fails confusingly, **grep [ERROR-PATTERNS.md](ERROR-PATTERNS.md) for the
675
+ symptom first** it maps error text cause fix. The invariants, one line each:
676
+
677
+ - **replicad (OCCT) transforms consume their input** never reuse a transformed solid;
678
+ `.clone()` first ([replicad-consumed-operand](ERROR-PATTERNS.md#replicad-consumed-operand)).
679
+ - **Part modules are DOM-free and side-effect-free** they load in both the main thread
680
+ and the worker ([worker-imports-main-entry](ERROR-PATTERNS.md#worker-imports-main-entry)).
681
+ - **`build` is a pure function of `(k, p, d)`** — impurity silently defeats the geometry
682
+ cache ([impure-build-stale-preview](ERROR-PATTERNS.md#impure-build-stale-preview)).
631
683
  - **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.
684
+ - **Preview vs print quality:** Manifold bakes segment counts in at primitive creation,
685
+ so builds are quality-agnostic; the export path uses a separate high-res "print" kernel.
686
+ - **Display placement is view-independent**; only `place(..., { purpose: "export" })` may
687
+ depend on `view` ([view-dependent-display-place](ERROR-PATTERNS.md#view-dependent-display-place)).
688
+ - **Keep geometry backend-agnostic** (kernel calls only); only STEP requires OCCT
689
+ ([probe-routed-to-occt](ERROR-PATTERNS.md#probe-routed-to-occt),
690
+ [occt-holes-watertight-na](ERROR-PATTERNS.md#occt-holes-watertight-na)).
639
691
 
640
692
  ---
641
693
 
@@ -0,0 +1,150 @@
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
+ ## wrong-node-version
77
+
78
+ - **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.
79
+ - **Cause:** The shell's default Node is older than the required Node 24 (`.nvmrc` pins it).
80
+ - **Fix:** Run `nvm use` before `npm install`, tests, or any `npx partforge` command. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Quickstart".
81
+
82
+ ## worker-url-not-inline
83
+
84
+ - **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`).
85
+ - **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.
86
+ - **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".
87
+
88
+ ## minwall-sliver-triangles
89
+
90
+ - **Symptom:** `⚠` minWall warnings from `verify` on a faceted part whose walls are clearly thicker than the profile minimum.
91
+ - **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
+ - **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
+
94
+ ## param-key-missing-from-defaults
95
+
96
+ - **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.
97
+ - **Cause:** A `key` used in the `parameters` schema (slider, feature, or preset override) doesn't exist in `defaults` — every key must, including `hidden` ones.
98
+ - **Fix:** Add the key to `defaults` with a sensible starting value. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Parameters: the control-panel schema".
99
+
100
+ ## dimmed-control-vestigial-param
101
+
102
+ - **Symptom:** A control renders dimmed (but still editable) and changing it does nothing on screen.
103
+ - **Cause:** No sub-part visible in the active view reads that parameter — the relevance-aware panel dims controls with no on-screen effect.
104
+ - **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".
105
+
106
+ ## linked-checkout-wasm-403
107
+
108
+ - **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.
109
+ - **Cause:** The linked checkout lives outside the app's project root, so Vite's dev server refuses to serve its files.
110
+ - **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".
111
+
112
+ ## ring-sector-full-circle
113
+
114
+ - **Symptom:** `ringSectorPolygon: arcDeg must be < 360 (use a cut for a full ring)`
115
+ - **Cause:** A full annulus can't be a single simple polygon — it's a contour-with-hole.
116
+ - **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".
117
+
118
+ ## occt-closed-loop-unsupported
119
+
120
+ - **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.
121
+ - **Cause:** Capless closed loops are a Manifold-only capability; the OCCT backend rejects them, and STEP export always runs on OCCT.
122
+ - **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".
123
+
124
+ ## smooth-geometry-faceted-preview
125
+
126
+ - **Symptom:** A `ruled:false` loft or `smooth:true` sweep looks faceted/straight-walled in the viewer even though the options are set.
127
+ - **Cause:** Smooth blending is OCCT-native; the Manifold preview always tessellates ruled straight walls — only STEP export carries the smooth surface.
128
+ - **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".
129
+
130
+ ## scale-moved-the-part
131
+
132
+ - **Symptom:** After `s.scale(f)` a part is resized but also relocated — features drift away from where they were built.
133
+ - **Cause:** `scale(factor, center?)` defaults its center to the origin, so scaling an off-origin solid about the origin also translates it.
134
+ - **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".
135
+
136
+ ## occt-holes-watertight-na
137
+
138
+ - **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.
139
+ - **Cause:** `holes` and `watertight` are Manifold-only topology facts, and this part auto-routed to OCCT — the assertions skip rather than fail.
140
+ - **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)".
141
+
142
+ ## html-page-missing-in-prod
143
+
144
+ - **Symptom:** A part's page 404s in the production deploy while working fine under `npm run dev`.
145
+ - **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.
146
+ - **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".
147
+
148
+ # Hardware library
149
+
150
+ Reserved for `hardware-*` patterns (issue #30). No entries yet.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.8.0",
3
+ "version": "0.9.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",
@@ -18,6 +18,7 @@
18
18
  "bin",
19
19
  "skills/partforge/SKILL.md",
20
20
  "docs/AUTHORING-PARTS.md",
21
+ "docs/ERROR-PATTERNS.md",
21
22
  "README.md"
22
23
  ],
23
24
  "exports": {
@@ -56,3 +56,8 @@ Picks come back **in request order**, each echoing its prompt, so you can map th
56
56
 
57
57
  - This only *reads* a click — it never edits files. You make the edits yourself after.
58
58
  - The server is localhost-only and holds one request at a time.
59
+
60
+ ## Related: debugging failures
61
+
62
+ If anything fails while you're editing a part, grep `docs/ERROR-PATTERNS.md` for the
63
+ symptom first — its preamble states the full grep-first rule.
@@ -6,7 +6,7 @@ import { viewSubParts, resolveParams, buildPosed } from "./jobs.js";
6
6
  // Parts meant to fit together (e.g. a block seated in a pocket void) read ~0 and
7
7
  // don't trip it. Manifold-only (needs Solid.intersect + Solid.volume); meant for
8
8
  // part tests so an author/LLM editing a part sees collisions fail.
9
- // → [{ a, b, volume }] for each offending pair (empty = no collisions)
9
+ // → [{ a, b, volume, location }] for each offending pair (empty = no collisions)
10
10
  export function assemblyOverlaps(kernel, part, view, params = {}, { tolerance = 1 } = {}) {
11
11
  const { p, d } = resolveParams(part, params);
12
12
  const posed = viewSubParts(part, view, p).map((name) => ({
@@ -17,8 +17,14 @@ export function assemblyOverlaps(kernel, part, view, params = {}, { tolerance =
17
17
  const overlaps = [];
18
18
  for (let i = 0; i < posed.length; i++) {
19
19
  for (let j = i + 1; j < posed.length; j++) {
20
- const volume = posed[i].solid.intersect(posed[j].solid).volume();
21
- if (volume > tolerance) overlaps.push({ a: posed[i].name, b: posed[j].name, volume });
20
+ const inter = posed[i].solid.intersect(posed[j].solid);
21
+ const volume = inter.volume();
22
+ if (volume > tolerance) {
23
+ // location = the intersection's bounding-box center — a nearby indicator, not
24
+ // an exact contact point: for a disjoint (multi-region) intersection it is the
25
+ // midpoint of those regions and can land in the empty space between them.
26
+ overlaps.push({ a: posed[i].name, b: posed[j].name, volume, location: inter.boundingBox().center });
27
+ }
22
28
  }
23
29
  }
24
30
  kernel.cleanup?.(); // free the per-check WASM objects
@@ -0,0 +1,78 @@
1
+ // Shared parser + matcher for docs/ERROR-PATTERNS.md — the symptom-indexed
2
+ // error→pattern library (issue #28). The parser here is the single source of
3
+ // truth: the format lint (test/error-patterns.test.js) and the CLI crash-path
4
+ // matcher (issue #27) both import it. Contract: partforge code that throws must
5
+ // throw strings appearing verbatim, in a backtick literal AT THE START of some
6
+ // entry's Symptom line — only a leading literal participates in matching, so
7
+ // backticks used for prose mid-sentence never mis-attribute an unrelated crash.
8
+ import { readFileSync } from "node:fs";
9
+
10
+ // Single-pass, fence-aware parse: a heading inside a ``` / ~~~ fence is quoted
11
+ // content, not structure. Each `## <id>` entry records the `# <section>` it sits
12
+ // under; its body runs to the next h1/h2 heading. (Moved verbatim from the lint
13
+ // test, then enriched with symptom/cause/fix extraction.)
14
+ export function parsePatterns(md) {
15
+ const entries = [];
16
+ let section = null;
17
+ let entry = null;
18
+ let inFence = false;
19
+ for (const line of md.split("\n")) {
20
+ if (/^\s*(```|~~~)/.test(line)) {
21
+ inFence = !inFence;
22
+ if (entry) entry.body += line + "\n";
23
+ continue;
24
+ }
25
+ if (!inFence) {
26
+ const h1 = line.match(/^# (.+)$/);
27
+ const h2 = line.match(/^## (.+)$/);
28
+ if (h1) { section = h1[1]; entry = null; continue; }
29
+ if (h2) { entry = { id: h2[1], section, body: "" }; entries.push(entry); continue; }
30
+ }
31
+ if (entry) entry.body += line + "\n";
32
+ }
33
+ const field = (body, label) => {
34
+ const i = body.indexOf(`- **${label}:**`);
35
+ return i < 0 ? null : body.slice(i).split("\n")[0].replace(`- **${label}:**`, "").trim();
36
+ };
37
+ return entries.map((e) => {
38
+ const symptom = field(e.body, "Symptom");
39
+ // Leading-literal convention: only a backtick literal at the very START of the
40
+ // Symptom text is a match literal (kept as a ≤1-element array for compatibility
41
+ // — tests and the matcher read symptomStrings). Mid-line backticks are prose.
42
+ const leading = symptom?.match(/^`([^`]+)`/);
43
+ return {
44
+ ...e,
45
+ symptom,
46
+ cause: field(e.body, "Cause"),
47
+ fix: field(e.body, "Fix"),
48
+ symptomStrings: leading ? [leading[1]] : [],
49
+ };
50
+ });
51
+ }
52
+
53
+ // Cached read of the live doc, resolved relative to this module so it works from
54
+ // a consuming app's node_modules too. Any read/parse error → null (callers treat
55
+ // that as "no patterns available", never an error).
56
+ let cached;
57
+ export function loadPatterns() {
58
+ if (cached !== undefined) return cached;
59
+ try {
60
+ cached = parsePatterns(readFileSync(new URL("../../docs/ERROR-PATTERNS.md", import.meta.url), "utf8"));
61
+ } catch {
62
+ cached = null;
63
+ }
64
+ return cached;
65
+ }
66
+
67
+ // Leading symptom literals ≥ 6 chars, longest match wins. Never throws.
68
+ export function matchPattern(message, patterns = loadPatterns()) {
69
+ if (!patterns || typeof message !== "string") return null;
70
+ let best = null;
71
+ let bestLen = 5;
72
+ for (const p of patterns) {
73
+ for (const s of p.symptomStrings) {
74
+ if (s.length > bestLen && message.includes(s)) { best = p; bestLen = s.length; }
75
+ }
76
+ }
77
+ return best ? { id: best.id, fix: best.fix } : null;
78
+ }
@@ -20,6 +20,7 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
20
20
  const subparts = built.map(({ name, solid, mesh }) => {
21
21
  const b = bounds(mesh.positions);
22
22
  subBounds.push(b);
23
+ const mw = opts.minWall ? minWall(mesh) : null;
23
24
  return {
24
25
  name,
25
26
  bbox: size(b),
@@ -28,7 +29,8 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
28
29
  triangleCount: mesh.triangles,
29
30
  watertight: typeof solid.isEmpty === "function" ? !solid.isEmpty() : null,
30
31
  holes: typeof solid.genus === "function" ? solid.genus() : null,
31
- minWall: opts.minWall ? (minWall(mesh)?.value ?? null) : null,
32
+ minWall: mw?.value ?? null,
33
+ minWallAt: mw?.location ?? null,
32
34
  };
33
35
  });
34
36
 
@@ -4,36 +4,70 @@ import { resolveProfile } from "./dfm-profiles.js";
4
4
  import { expandCases } from "./cases.js";
5
5
  import { subPartReadKeys, relevanceHash, RELEVANT_ALL } from "../framework/param-deps.js";
6
6
 
7
- // Metric registry: name → how to pull the value out of facts, and whether a failure
8
- // is a hard gate or a warning. `manifoldOnly` facts are null on OCCT parts.
9
- const SUBPART_METRICS = {
10
- holes: { kind: "gate", manifoldOnly: true, extract: (s) => s.holes },
11
- watertight: { kind: "gate", manifoldOnly: true, extract: (s) => s.watertight },
12
- volume: { kind: "gate", extract: (s) => s.volume },
13
- surfaceArea: { kind: "gate", extract: (s) => s.surfaceArea },
14
- triangleCount: { kind: "gate", extract: (s) => s.triangleCount },
15
- bbox: { kind: "gate", extract: (s) => s.bbox },
16
- minWall: { kind: "warn", extract: (s) => s.minWall },
7
+ // Metric registry: name → how to pull the value out of facts, whether a failure
8
+ // is a hard gate or a warning, and the diagnostics attached to a non-pass check:
9
+ // `hint` (required — the report contract promises one on every fail/warn),
10
+ // `pattern` (optional stable ERROR-PATTERNS.md#<id>), `locate` (optional
11
+ // [x,y,z] source). `manifoldOnly` facts are null on OCCT parts.
12
+ export const SUBPART_METRICS = {
13
+ holes: { kind: "gate", manifoldOnly: true, extract: (s) => s.holes,
14
+ hint: "genus is wrong an unintended tunnel exists or an intended bore is blocked; make cut tools pierce fully (overcut past the faces)" },
15
+ watertight: { kind: "gate", manifoldOnly: true, extract: (s) => s.watertight,
16
+ hint: "a boolean produced an open shell check for coplanar faces or a cut that exactly grazes a surface",
17
+ pattern: "boolean-not-watertight" },
18
+ volume: { kind: "gate", extract: (s) => s.volume,
19
+ hint: "solid volume is out of range — a feature is missing, doubled, or a governing parameter is mis-scaled" },
20
+ surfaceArea: { kind: "gate", extract: (s) => s.surfaceArea,
21
+ hint: "surface area is out of range — detail features (facets, ribs, textures) are missing or doubled" },
22
+ triangleCount: { kind: "gate", extract: (s) => s.triangleCount,
23
+ hint: "triangle count is out of range — tessellation quality or feature count changed unexpectedly" },
24
+ bbox: { kind: "gate", extract: (s) => s.bbox,
25
+ hint: "bounding box is out of range — check the governing dimensions and the part's orientation" },
26
+ minWall: { kind: "warn", extract: (s) => s.minWall,
27
+ hint: "thinnest wall is at the reported location — increase the governing wall/thickness parameter or reduce the intersecting feature's depth",
28
+ pattern: "minwall-sliver-triangles",
29
+ locate: (s) => s.minWallAt },
17
30
  };
18
- const VIEW_METRICS = {
19
- bbox: { kind: "gate", extract: (r) => r.aggregate.bbox },
20
- volume: { kind: "gate", extract: (r) => r.aggregate.volume },
21
- overlaps: { kind: "gate", extract: (r) => r.overlaps.length },
31
+ export const VIEW_METRICS = {
32
+ bbox: { kind: "gate", extract: (r) => r.aggregate.bbox,
33
+ hint: "the assembled view exceeds its size limit — shrink the assembly or pick a process with a larger bed" },
34
+ volume: { kind: "gate", extract: (r) => r.aggregate.volume,
35
+ hint: "total assembly volume is out of range — a sub-part is missing, doubled, or mis-scaled" },
36
+ overlaps: { kind: "gate", extract: (r) => r.overlaps.length,
37
+ hint: "sub-parts interpenetrate near the reported location — adjust placement or add clearance in derive()",
38
+ locate: (r) => r.overlaps[0]?.location ?? null },
22
39
  };
23
40
 
24
- function check(scope, subpart, metric, expr, registry, factsObj) {
41
+ // An expectation is a bare expression (string/number/boolean) or { expr, hint }.
42
+ const normalizeExpectation = (spec) =>
43
+ spec !== null && typeof spec === "object" && !Array.isArray(spec) && "expr" in spec
44
+ ? { expr: spec.expr, hint: spec.hint }
45
+ : { expr: spec, hint: undefined };
46
+
47
+ function check(scope, subpart, metric, spec, registry, factsObj) {
25
48
  const reg = registry[metric];
26
49
  if (!reg) throw new Error(`unknown ${scope} metric "${metric}"${subpart ? ` on sub-part "${subpart}"` : ""}`);
50
+ const { expr, hint: partHint } = normalizeExpectation(spec);
27
51
  const actual = reg.extract(factsObj);
28
52
  const base = { scope, subpart, metric, kind: reg.kind, expr: String(expr) };
29
53
  if (actual === null || actual === undefined) {
30
54
  if (reg.manifoldOnly) return { ...base, actual, status: "skip", pass: null, message: "n/a (OCCT backend)" };
31
- if (metric === "minWall") return { ...base, actual, status: "warn", pass: null, message: "min wall unavailable" };
55
+ if (metric === "minWall") {
56
+ return { ...base, actual, status: "warn", pass: null, message: "min wall unavailable",
57
+ hint: partHint ?? "no min-wall reading for this mesh — treat thin features as unverified" };
58
+ }
32
59
  return { ...base, actual, status: "skip", pass: null, message: "unavailable" };
33
60
  }
34
61
  const { pass, message } = evaluateAssertion(parseAssertion(expr), actual);
35
62
  const status = pass ? "pass" : reg.kind === "warn" ? "warn" : "fail";
36
- return { ...base, actual, status, pass, message };
63
+ const out = { ...base, actual, status, pass, message };
64
+ if (!pass) {
65
+ out.hint = partHint ?? reg.hint;
66
+ if (reg.pattern) out.pattern = reg.pattern;
67
+ const loc = reg.locate?.(factsObj);
68
+ if (loc) out.location = loc;
69
+ }
70
+ return out;
37
71
  }
38
72
 
39
73
  // Pure policy: profile rules + per-part expect → checks for one case's facts.