partforge 0.5.2 → 0.6.1

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/README.md CHANGED
@@ -47,8 +47,11 @@ boots with no errors. Needs Playwright: `npm i -D playwright && npx playwright i
47
47
  contract, the geometry kernel API, the parameter schema, app wiring, testing, and
48
48
  gotchas. See **Designing the control panel** in that guide for how to write descriptions,
49
49
  hide internal params, and keep the interface simple while staying deeply adjustable.
50
- `src/parts/demo.js` is a minimal worked example; run `npm run dev` and open
51
- `/demo.html` to see it live.
50
+ `src/parts/demo.js` is a minimal worked example.
51
+
52
+ **Live showcase:** https://scottsykora.github.io/partforge/ — the landing gallery links
53
+ three live example apps (Faceted Planter, Spacer, Filleted Box), auto-deployed from `main`.
54
+ Locally, `npm run dev` then open `/demo.html`, `/planter.html`, or `/filleted-box.html`.
52
55
 
53
56
  - **Agent clarification (`request-a-pick`):** an external tool can ask the user to click
54
57
  geometry and get the `Selection` back — serve with `?pickserver`, drive with
package/bin/cli.js CHANGED
@@ -7,6 +7,7 @@ import { createManifoldKernel } from "../src/framework/geometry/manifold-backend
7
7
  import { detectBackend } from "../src/framework/geometry/probe.js";
8
8
  import { bootOcctKernel } from "../src/testing/occt.js";
9
9
  import { measure } from "../src/testing/measure.js";
10
+ import { verify } from "../src/testing/verify.js";
10
11
  import { renderViews } from "../src/testing/render.js";
11
12
  import { createPickServer, requestPicks, formatPickResult } from "../src/framework/pick-request/server.js";
12
13
 
@@ -64,11 +65,19 @@ if (["measure", "render"].includes(cmd)) {
64
65
  if (cmd === "measure") {
65
66
  const report = measure(kernel, part, view);
66
67
  printMeasure(report);
68
+ let vok = true;
69
+ const processFlag = typeof flags.process === "string" ? flags.process : undefined;
70
+ if ((part.verify || processFlag) && !flags["no-verify"]) {
71
+ const v = verify(kernel, part, { process: processFlag, view });
72
+ printVerify(v);
73
+ report.verify = v;
74
+ vok = v.ok;
75
+ }
67
76
  const file = `measure-${slug(report.part)}-${report.view}.json`;
68
77
  writeFileSync(file, JSON.stringify(report, null, 2));
69
78
  console.log(`\nwrote ${file}`);
70
79
  if (flags.json) console.log(JSON.stringify(report, null, 2));
71
- process.exit(report.ok ? 0 : 1);
80
+ process.exit(report.ok && vok ? 0 : 1);
72
81
  } else {
73
82
  const views = typeof flags.views === "string" ? flags.views.split(",") : undefined;
74
83
  const files = await renderViews(kernel, part, view, { views, out: flags.out || "render" });
@@ -93,3 +102,16 @@ function printMeasure(r) {
93
102
  console.log(` ── view bbox ${a.bbox.map((n) => n.toFixed(1)).join("×")} vol ${(a.volume / 1000).toFixed(2)}cm³ tris ${a.triangleCount}`);
94
103
  console.log(` overlaps: ${r.overlaps.length ? r.overlaps.map((o) => `${o.a}×${o.b} (${o.volume.toFixed(1)}mm³)`).join(", ") : "none"}`);
95
104
  }
105
+
106
+ function printVerify(v) {
107
+ console.log(`\nverify:`);
108
+ for (const c of v.cases) {
109
+ console.log(` ${c.name}`);
110
+ for (const ch of c.checks) {
111
+ const icon = ch.status === "pass" ? "✓" : ch.status === "fail" ? "✗" : ch.status === "warn" ? "⚠" : "·";
112
+ console.log(` ${icon} ${ch.subpart ?? "_view"} ${ch.metric} ${ch.expr} (${ch.message})`);
113
+ }
114
+ }
115
+ const f = v.failures.length, w = v.warnings.length;
116
+ console.log(` result: ${f ? `${f} gate failure(s)` : "all gates passed"}${w ? `, ${w} warning(s)` : ""}`);
117
+ }
@@ -106,7 +106,11 @@ entry pulls in the DOM viewer/controls, and your build functions run in a Web Wo
106
106
  | `s.cut(tool)` / `s.cutAll(tools[])` | boolean subtract (one / batch) |
107
107
  | `s.intersect(other)` | boolean intersection (Manifold; used by collision tests) |
108
108
  | `s.translate([x,y,z])` | move |
109
- | `s.rotate(deg, center, axis)` | rotate `deg` about `axis` through `center` |
109
+ | `s.rotate(deg, center, axis)` | **internal primitive** prefer `rotateX/Y/Z` / `rotateAbout` |
110
+ | `s.rotateX(deg)` / `s.rotateY(deg)` / `s.rotateZ(deg)` | rotate about a world axis through the origin |
111
+ | `s.rotateAbout({ axis, deg, through? })` | general rotation: `axis` = `"X"|"Y"|"Z"` or `[x,y,z]`; `through` = centre (default origin) |
112
+ | `s.along(dir)` | orient the canonical **+Z** build axis to point along `dir` (`"+X"|"-X"|"+Y"|"-Y"|"+Z"|"-Z"`) |
113
+ | `s.at([x,y,z])` | place an origin-built solid at a point (readable alias of `translate`) |
110
114
  | `s.mirror("XY"\|"XZ"\|"YZ")` | mirror across a plane |
111
115
  | `s.scale(factor, center?)` | uniform scale (single factor) about `center` (default origin) — scaling an off-origin part about the origin also moves it; pass a center (e.g. `s.boundingBox().center`) to resize in place |
112
116
  | `s.clone()` | independent copy (replicad consumes solids on transform) |
@@ -118,6 +122,41 @@ entry pulls in the DOM viewer/controls, and your build functions run in a Web Wo
118
122
  You normally only call the *make/combine/transform* ops; the framework handles
119
123
  `toMesh`/`toSTL`/`toIndexedMesh`/`toSTEP`. Units are millimetres.
120
124
 
125
+ ### Build-step style: orient → place, and batch features
126
+
127
+ Write build steps so intent is legible — an LLM (and a human) should not have to decode
128
+ magic vectors. Three habits:
129
+
130
+ - **Orient then place.** Build a primitive along its canonical **+Z** axis, point it with
131
+ `along(dir)`, then position it with `at([x,y,z])`:
132
+
133
+ ```js
134
+ // ✗ cryptic: which axis? what centre?
135
+ k.cylinder(r, r, L).rotate(-90, [0, 0, 0], [1, 0, 0]).translate([rp, y1, sz])
136
+ // ✓ legible
137
+ k.cylinder(r, r, L).along("+Y").at([rp, y1, sz])
138
+ ```
139
+
140
+ - **Rotate about a point with `rotateAbout`** when the axis isn't through the origin
141
+ (use `rotateX/Y/Z` for the common origin cases):
142
+
143
+ ```js
144
+ // ✗ .rotate(angle, [rp, 0, 0], [0, 0, 1])
145
+ // ✓
146
+ tool.rotateAbout({ axis: "Z", deg: angle, through: [rp, 0, 0] })
147
+ ```
148
+
149
+ - **Batch features** instead of reassigning through a cut-chain:
150
+
151
+ ```js
152
+ // ✗ body = body.cut(a); body = body.cut(b); body = body.cut(c);
153
+ // ✓
154
+ body.cutAll([a, b, c]) // and k.union([base, f1, f2]) for additive batches
155
+ ```
156
+
157
+ The bare `rotate(deg, center, axis)` remains available as the low-level primitive for
158
+ anything `rotateX/Y/Z`/`rotateAbout` can't express, but prefer the vocabulary above.
159
+
121
160
  ### Caching & determinism
122
161
 
123
162
  The preview kernel memoizes geometry by content hash, so editing a parameter only
@@ -409,6 +448,64 @@ The `measure` function is also exported for vitest (boot a Manifold kernel as in
409
448
 
410
449
  ---
411
450
 
451
+ ## Self-verification (the `verify` block)
452
+
453
+ A part can declare how it should be checked, co-located with its schema, so
454
+ `partforge measure` (and vitest) can prove it is both **printable** and **correct**.
455
+ Add an optional top-level `verify` block:
456
+
457
+ ```js
458
+ verify: {
459
+ process: "fdm-pla", // a DFM profile: fdm-pla | fdm-petg | resin, or an
460
+ // inline { bed:[x,y,z], minWall, clearance } object
461
+ cases: ["defaults", "M3"], // optional; default = defaults + every preset
462
+ expect: { // design intent, by sub-part name (+ "_view")
463
+ spacer: { holes: 1, bbox: "<=[60,60,60]", volume: "0.4..0.6cm3" },
464
+ _view: { overlaps: 0 },
465
+ },
466
+ }
467
+ ```
468
+
469
+ **What the profile gives you:** a hard **bed-fit** gate (the view bbox must fit `bed`)
470
+ and a **min-wall** warning. **What `expect` gives you:** per-sub-part assertions on the
471
+ facts `measure` already reports — `holes` (through-bores / genus), `volume`,
472
+ `surfaceArea`, `triangleCount`, `bbox`, `watertight`, `minWall`; and `_view` assertions
473
+ `bbox`, `volume`, `overlaps`.
474
+
475
+ **Assertion DSL:** a bare number means equality (`holes: 1`); `">=n"`, `"<=n"`, `">n"`,
476
+ `"<n"`, or a range `"a..b"`; an optional unit suffix `mm`/`cm`/`mm3`/`cm3`; and for
477
+ `bbox`, a componentwise vector `"<=[x,y,z]"` / `">=[x,y,z]"` where `*` skips an axis.
478
+ The parser is strict — a malformed assertion fails loudly.
479
+
480
+ **Gates vs. warnings:** exact facts are **gates** (a failure sets a non-zero exit code);
481
+ `minWall` is computed (a ray/shot wall-thickness measurement) and reported as a
482
+ **warning** — it flags walls below the profile's minimum but never fails the build.
483
+ `holes`/`watertight` are Manifold-only, so those assertions **skip** on OCCT parts
484
+ rather than fail.
485
+
486
+ **Running it:**
487
+
488
+ ```bash
489
+ npx partforge measure src/parts/<part>.js # auto-runs verify if a block exists
490
+ npx partforge measure src/parts/<part>.js --process resin # force/override a profile
491
+ npx partforge measure src/parts/<part>.js --no-verify # facts only
492
+ ```
493
+
494
+ …and in vitest:
495
+
496
+ ```js
497
+ import { verify } from "partforge/testing";
498
+ test("part is printable and correct", () => {
499
+ expect(verify(kernel, part).ok).toBe(true);
500
+ });
501
+ ```
502
+
503
+ Checks run across the **default config plus every preset** (or your `cases` list); a
504
+ preset that changes only parameters no on-screen sub-part reads is deduplicated, so
505
+ coverage is cheap.
506
+
507
+ ---
508
+
412
509
  ## Fillet & chamfer (automatic OCCT backend)
413
510
 
414
511
  Two backends build your part: **Manifold** (fast meshes — preview, STL, 3MF) and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.5.2",
3
+ "version": "0.6.1",
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",
@@ -0,0 +1,10 @@
1
+ import planterPart from "./parts/planter.js";
2
+ import { mount } from "./framework/index.js";
3
+
4
+ // Dev-only example app for the faceted planter part. Identical wiring to app-demo.js —
5
+ // only the imported definition and the worker entry differ per part. `npm run dev`,
6
+ // then open /planter.html.
7
+ mount(planterPart, {
8
+ createWorker: (name) =>
9
+ new Worker(new URL("./planter-worker.js", import.meta.url), { type: "module", name }),
10
+ });
@@ -8,14 +8,15 @@
8
8
  --bg: #15181d; --surface: #1f242c; --surface-2: #20262e; --border: #2c333d;
9
9
  --text: #d6dbe2; --text-strong: #e7ebf1; --text-2: #cdd4dd;
10
10
  --muted: #7d8794; --muted-2: #aab2bd; --status: #8b94a0; --hint: #6b7480;
11
- --accent: #3b82f6; --on-accent: #fff; --input-bg: #161a20; --err: #f8746c;
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;
12
13
  }
13
14
  :root[data-theme="light"] {
14
15
  color-scheme: light;
15
16
  --bg: #eef1f5; --surface: #ffffff; --surface-2: #f4f6f9; --border: #d4dae2;
16
17
  --text: #2b333d; --text-strong: #1a2129; --text-2: #3a434e;
17
18
  --muted: #6b7480; --muted-2: #59636f; --status: #6b7480; --hint: #8a93a0;
18
- --accent: #3b82f6; --on-accent: #fff; --input-bg: #ffffff; --err: #d8453d;
19
+ --accent: #1f5bd6; --accent-soft: #e6edfc; --on-accent: #fff; --input-bg: #ffffff; --err: #d8453d;
19
20
  }
20
21
  * { box-sizing: border-box; }
21
22
  html, body { margin: 0; height: 100%; overflow: hidden;
@@ -24,59 +25,86 @@ html, body { margin: 0; height: 100%; overflow: hidden;
24
25
  canvas { display: block; }
25
26
 
26
27
  #panel {
27
- position: fixed; top: 12px; left: 12px; width: 252px;
28
+ position: fixed; top: 12px; left: 12px; width: 256px;
28
29
  max-height: calc(100vh - 24px); overflow-y: auto; z-index: 10;
29
30
  background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
30
31
  padding: 14px; color: var(--text); box-shadow: 0 6px 24px rgba(0,0,0,.35);
31
32
  }
32
- #panel h1 { font-size: 14px; margin: 0 0 2px; }
33
- #panel .sub { color: var(--muted); font-size: 11px; margin: 0 0 12px; }
33
+ #panel h1 { font-size: 14px; margin: 0 0 2px; color: var(--text-strong); letter-spacing: -0.01em; }
34
+ #panel .sub {
35
+ font-family: var(--mono); color: var(--muted); font-size: 10px;
36
+ letter-spacing: 0.04em; text-transform: uppercase;
37
+ margin: 0 0 12px; padding-bottom: 12px; border-bottom: 1px solid var(--border);
38
+ }
34
39
 
35
40
  .seg { display: flex; gap: 4px; margin-bottom: 12px; }
36
41
  .seg button {
37
42
  flex: 1; padding: 7px 0; border: 1px solid var(--border); border-radius: 7px;
38
- background: var(--surface-2); color: var(--muted-2); cursor: pointer; font-size: 12px;
43
+ background: var(--surface-2); color: var(--muted-2); cursor: pointer;
44
+ font-family: var(--mono); font-size: 11px; letter-spacing: 0.02em;
39
45
  }
40
46
  .seg button.on { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
41
47
 
42
48
  .section {
43
- border: 1px solid var(--border); border-radius: 8px; padding: 9px 10px;
49
+ border: 1px solid var(--border); border-radius: 8px; padding: 10px;
44
50
  margin-bottom: 8px; background: var(--surface-2);
45
51
  }
46
- .sec-title { font-weight: 600; color: var(--text-strong); margin-bottom: 7px; }
52
+ .sec-title {
53
+ font-family: var(--mono); font-size: 10px; font-weight: 600;
54
+ letter-spacing: 0.14em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 9px;
55
+ }
47
56
  select.preset {
48
57
  width: 100%; background: var(--input-bg); color: var(--text-2);
49
- border: 1px solid var(--border); border-radius: 6px; padding: 5px 7px; font-size: 11px;
58
+ border: 1px solid var(--border); border-radius: 6px; padding: 6px 8px;
59
+ font-family: var(--mono); font-size: 11px;
50
60
  }
51
61
  .feat { display: flex; align-items: center; gap: 8px; margin: 6px 0;
52
62
  color: var(--text-2); cursor: pointer; }
53
- .feat input { cursor: pointer; }
54
- .feat-group { margin: 2px 0 8px; padding-left: 9px; border-left: 2px solid var(--border); }
63
+ .feat input { cursor: pointer; accent-color: var(--accent); }
64
+ .feat-group { margin: 2px 0 8px; padding-left: 10px; border-left: 2px solid var(--border); }
55
65
  .feat-group.hidden { display: none; }
56
66
  .adv-toggle {
57
- margin-top: 8px; padding: 3px 0; width: 100%; border: 0; border-radius: 5px;
58
- background: transparent; color: var(--muted); cursor: pointer; font-size: 11px;
59
- text-align: left;
67
+ margin-top: 8px; padding: 4px 0; width: 100%; border: 0; border-radius: 5px;
68
+ background: transparent; color: var(--muted); cursor: pointer;
69
+ font-family: var(--mono); font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; text-align: left;
60
70
  }
61
71
  .adv-toggle:hover { color: var(--muted-2); }
62
72
  .adv.hidden { display: none; }
63
73
  .adv { margin-top: 4px; }
64
74
 
65
- .slider { margin: 7px 0; }
75
+ .slider { margin: 9px 0; }
66
76
  .row { display: flex; justify-content: space-between; align-items: center;
67
- margin: 0 0 3px; gap: 8px; }
68
- .row label { color: var(--muted-2); }
77
+ margin: 0 0 4px; gap: 8px; }
78
+ .row label { font-family: var(--mono); font-size: 11px; color: var(--muted-2); letter-spacing: 0.01em; }
69
79
  .row .val { display: flex; align-items: baseline; gap: 4px; flex: none; }
70
80
  .row .num {
71
- width: 52px; text-align: right; font: inherit; font-variant-numeric: tabular-nums;
81
+ width: 54px; text-align: right; font-family: var(--mono); font-size: 12px; font-variant-numeric: tabular-nums;
72
82
  background: var(--input-bg); color: var(--text-strong);
73
- border: 1px solid var(--border); border-radius: 5px; padding: 2px 5px;
83
+ border: 1px solid var(--border); border-radius: 5px; padding: 3px 6px;
74
84
  }
75
- .row .num:focus { outline: none; border-color: var(--accent); }
85
+ .row .num:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
76
86
  .row .num::-webkit-outer-spin-button, .row .num::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
77
87
  .row .num { -moz-appearance: textfield; }
78
- .row .unit { color: var(--muted); font-size: 11px; }
79
- input[type="range"] { width: 100%; }
88
+ .row .unit { font-family: var(--mono); color: var(--muted); font-size: 10px; }
89
+
90
+ /* crafted range slider — hairline track + CAD-blue handle (the panel's signature control) */
91
+ input[type="range"] { -webkit-appearance: none; appearance: none; width: 100%; height: 18px; margin: 0; background: transparent; cursor: pointer; }
92
+ input[type="range"]::-webkit-slider-runnable-track { height: 3px; border-radius: 2px; background: var(--border); }
93
+ input[type="range"]::-moz-range-track { height: 3px; border-radius: 2px; background: var(--border); }
94
+ input[type="range"]::-moz-range-progress { height: 3px; border-radius: 2px; background: var(--accent); }
95
+ input[type="range"]::-webkit-slider-thumb {
96
+ -webkit-appearance: none; appearance: none; width: 14px; height: 14px; margin-top: -5.5px;
97
+ border-radius: 50%; background: var(--accent); border: 2px solid var(--surface-2); box-shadow: 0 0 0 1px var(--accent);
98
+ transition: box-shadow .12s ease;
99
+ }
100
+ input[type="range"]::-moz-range-thumb {
101
+ width: 14px; height: 14px; border-radius: 50%; background: var(--accent);
102
+ border: 2px solid var(--surface-2); box-shadow: 0 0 0 1px var(--accent);
103
+ }
104
+ input[type="range"]:hover::-webkit-slider-thumb { box-shadow: 0 0 0 5px var(--accent-soft); }
105
+ input[type="range"]:focus-visible { outline: none; }
106
+ input[type="range"]:focus-visible::-webkit-slider-thumb { box-shadow: 0 0 0 5px var(--accent-soft); }
107
+ input[type="range"]:focus-visible::-moz-range-thumb { box-shadow: 0 0 0 5px var(--accent-soft); }
80
108
 
81
109
  button.action {
82
110
  width: 100%; margin-top: 8px; padding: 9px; border: 0; border-radius: 7px;
@@ -85,19 +113,29 @@ button.action {
85
113
  button.ghost { background: var(--border); color: var(--text-2); font-weight: 500; }
86
114
  button.action:disabled { opacity: .5; cursor: default; }
87
115
 
88
- .dl { margin-top: 12px; }
89
- .dl-head { font-weight: 600; color: var(--text-strong); margin-bottom: 6px; }
116
+ .dl { margin-top: 14px; }
117
+ .dl-head {
118
+ font-family: var(--mono); font-size: 10px; font-weight: 600;
119
+ letter-spacing: 0.14em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 7px;
120
+ }
90
121
  .dl-row { display: flex; gap: 6px; }
91
122
  .dl-row button {
92
123
  flex: 1; padding: 8px 0; border: 1px solid var(--border); border-radius: 7px;
93
- background: var(--surface-2); color: var(--text-2); font-weight: 600;
94
- font-size: 12px; cursor: pointer;
124
+ background: var(--surface-2); color: var(--text-2);
125
+ font-family: var(--mono); font-weight: 600; font-size: 11px; letter-spacing: 0.06em; cursor: pointer;
95
126
  }
96
127
  .dl-row button:hover:not(:disabled) { border-color: var(--accent); color: var(--text-strong); }
97
128
  .dl-row button:disabled { opacity: .45; cursor: default; }
98
- #status { margin-top: 10px; min-height: 16px; color: var(--status); font-size: 11px; }
129
+ #status { font-family: var(--mono); margin-top: 12px; min-height: 16px; color: var(--status);
130
+ font-size: 11px; font-variant-numeric: tabular-nums; }
99
131
  #status.err { color: var(--err); }
100
- .hint { margin-top: 8px; color: var(--hint); font-size: 10px; }
132
+ .hint { font-family: var(--mono); margin-top: 8px; color: var(--hint); font-size: 10px; letter-spacing: 0.02em; }
133
+
134
+ /* keyboard focus ring shared across the panel's interactive controls */
135
+ .seg button:focus-visible, select.preset:focus-visible, .dl-row button:focus-visible,
136
+ button.action:focus-visible, .adv-toggle:focus-visible, #viewbar button:focus-visible {
137
+ outline: 2px solid var(--accent); outline-offset: 2px;
138
+ }
101
139
 
102
140
  /* part tabs, floated top-centre over the viewport */
103
141
  #topbar {
@@ -156,6 +194,7 @@ button.action:disabled { opacity: .5; cursor: default; }
156
194
  }
157
195
  .popover[hidden] { display: none; }
158
196
  .popover img { max-width: 100%; height: auto; border-radius: 4px; }
197
+ .popover code { font-family: var(--mono); background: var(--surface-2); padding: 0.05em 0.35em; border-radius: 4px; font-size: 0.9em; }
159
198
  .popover a { color: var(--accent); }
160
199
  .popover p:first-child { margin-top: 0; }
161
200
  .popover p:last-child { margin-bottom: 0; }
@@ -198,4 +237,4 @@ button.action:disabled { opacity: .5; cursor: default; }
198
237
  @keyframes pf-pick-in {
199
238
  from { opacity: 0; transform: translateX(-50%) translateY(-10px); }
200
239
  to { opacity: 1; transform: translateX(-50%) translateY(0); }
201
- }
240
+ }
@@ -11,7 +11,13 @@
11
11
  * @property {() => {min:number[],max:number[],center:number[],size:number[]}} boundingBox axis-aligned bounds (query)
12
12
  * @property {(thickness:number, openFaces:object) => Solid} shell hollow inward (OCCT only); openFaces selector required
13
13
  * @property {(v: number[]) => Solid} translate
14
- * @property {(deg: number, center: number[], axis: number[]) => Solid} rotate
14
+ * @property {(deg: number, center: number[], axis: number[]) => Solid} rotate internal primitive — prefer rotateX/Y/Z / rotateAbout
15
+ * @property {(deg: number) => Solid} rotateX rotate about world X through the origin
16
+ * @property {(deg: number) => Solid} rotateY rotate about world Y through the origin
17
+ * @property {(deg: number) => Solid} rotateZ rotate about world Z through the origin
18
+ * @property {(o:{axis:"X"|"Y"|"Z"|number[], deg:number, through?:number[]}) => Solid} rotateAbout general rotation (legible)
19
+ * @property {(dir:"+X"|"-X"|"+Y"|"-Y"|"+Z"|"-Z") => Solid} along orient the canonical +Z build axis along dir
20
+ * @property {(v:number[]) => Solid} at place an origin-built solid at point v (alias of translate)
15
21
  * @property {(plane: "XY"|"XZ"|"YZ") => Solid} mirror
16
22
  * @property {(factor:number, center?:number[]) => Solid} scale uniform scale about center (default origin)
17
23
  * @property {() => number} volume solid volume in mm³ (Manifold; used by collision tests)
@@ -2,6 +2,7 @@ import { helixTube } from "./helix-tube.js";
2
2
  import { KernelCapabilityError } from "./errors.js";
3
3
  import { h } from "./solid-hash.js";
4
4
  import { createSolidCache } from "./solid-cache.js";
5
+ import { addSugar } from "./solid-sugar.js";
5
6
 
6
7
  const PLANE_NORMAL = { XY: [0, 0, 1], XZ: [0, 1, 0], YZ: [1, 0, 0] };
7
8
  // 'preview' = interactive view (fast); 'print' = STL export (high-res, used only
@@ -12,6 +13,18 @@ const SHARP_ANGLE = 35; // deg — same-surface edges sharper than this shade ha
12
13
  const COPLANAR_COS = Math.cos((5 * Math.PI) / 180); // edge lines: skip cut seams that bend less than 5° (coplanar)
13
14
  const MIN_EDGE2 = 0.01 * 0.01; // edge lines: drop sub-0.01mm segments (degenerate boolean slivers, not real features)
14
15
 
16
+ // true axis-angle rotation as a column-major 4x4 (manifold Mat4), translation 0
17
+ function axisAngleMat4(axis, deg) {
18
+ const len = Math.hypot(axis[0], axis[1], axis[2]) || 1;
19
+ const x = axis[0] / len, y = axis[1] / len, z = axis[2] / len;
20
+ const t = (deg * Math.PI) / 180, c = Math.cos(t), s = Math.sin(t), C = 1 - c;
21
+ const R00 = c + x*x*C, R01 = x*y*C - z*s, R02 = x*z*C + y*s;
22
+ const R10 = y*x*C + z*s, R11 = c + y*y*C, R12 = y*z*C - x*s;
23
+ const R20 = z*x*C - y*s, R21 = z*y*C + x*s, R22 = c + z*z*C;
24
+ // column-major: columns are images of the basis vectors; 4th column = translation (0)
25
+ return [R00, R10, R20, 0, R01, R11, R21, 0, R02, R12, R22, 0, 0, 0, 0, 1];
26
+ }
27
+
15
28
  export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
16
29
  const { Manifold, CrossSection } = wasm;
17
30
  const segs = SEGS[quality], tube = TUBE[quality];
@@ -58,7 +71,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
58
71
  return { positions, indices };
59
72
  }
60
73
 
61
- const wrap = (m, hash) => ({
74
+ const wrap = (m, hash) => addSugar({
62
75
  _m: m,
63
76
  _hash: hash,
64
77
  cut: (t) => cached(h("cut", hash, t._hash), () => T(m.subtract(t._m))),
@@ -80,9 +93,11 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
80
93
  isEmpty: () => m.isEmpty(),
81
94
  translate: (v) => wrap(T(m.translate(v)), h("translate", hash, v)),
82
95
  rotate: (deg, center, axis) => {
83
- const euler = [axis[0] * deg, axis[1] * deg, axis[2] * deg];
96
+ const nz = (axis[0] !== 0) + (axis[1] !== 0) + (axis[2] !== 0);
84
97
  const a = T(m.translate([-center[0], -center[1], -center[2]]));
85
- const b = T(a.rotate(euler));
98
+ const b = nz <= 1
99
+ ? T(a.rotate([axis[0] * deg, axis[1] * deg, axis[2] * deg])) // basis axis — euler is exact; unchanged
100
+ : T(a.transform(axisAngleMat4(axis, deg))); // general axis-angle
86
101
  return wrap(T(b.translate(center)), h("rotate", hash, deg, center, axis));
87
102
  },
88
103
  mirror: (plane) => wrap(T(m.mirror(PLANE_NORMAL[plane])), h("mirror", hash, plane)),
@@ -123,7 +138,9 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
123
138
  const cs = T(CrossSection.ofPolygons([pts]));
124
139
  if (twist === 0 && scaleTop === 1) return T(cs.extrude(height));
125
140
  const nDiv = Math.max(1, Math.ceil(Math.abs(twist) / 5));
126
- return T(cs.extrude(height, nDiv, twist, scaleTop));
141
+ // Manifold's extrude scaleTop is a Vec2 — a scalar is NOT broadcast (it scales
142
+ // X and drives Y to 0, squishing the top to a line). Broadcast for a uniform taper.
143
+ return T(cs.extrude(height, nDiv, twist, [scaleTop, scaleTop]));
127
144
  }),
128
145
  helixSweptTube: (o) => cached(h("helixSweptTube", o, tube), () => T(helixTube(wasm, { ...o, ...tube }))),
129
146
  revolve: (pts, { degrees = 360 } = {}) =>
@@ -3,6 +3,7 @@
3
3
  // (makeCylinder, makeHelix+genericSweep, draw/extrude, cut/fuse) now live.
4
4
  import { toEdgeFinder } from "./edge-selector.js";
5
5
  import { toFaceFinder } from "./face-selector.js";
6
+ import { addSugar } from "./solid-sugar.js";
6
7
  const MESH = { preview: { tolerance: 0.1, angularTolerance: 0.5 }, print: { tolerance: 0.01, angularTolerance: 0.1 } };
7
8
 
8
9
  export function createOcctKernel(replicad) {
@@ -77,7 +78,7 @@ export function createOcctKernel(replicad) {
77
78
  return backup;
78
79
  };
79
80
 
80
- const wrap = (shape) => ({
81
+ const wrap = (shape) => addSugar({
81
82
  _s: shape,
82
83
  cut: (t) => wrap(shape.cut(t._s)),
83
84
  cutAll: (tools) => wrap(shape.cut(makeCompound(tools.map((t) => t._s)))),
@@ -0,0 +1,35 @@
1
+ // src/framework/geometry/solid-sugar.js
2
+ // Self-describing build-step vocabulary, defined ONCE over both geometry backends.
3
+ // Every Solid a backend's wrap() returns is passed through addSugar(), which attaches
4
+ // readable transform/placement methods composed purely from the solid's existing
5
+ // rotate()/translate() primitives — so the sugar is geometry-identical to the
6
+ // hand-written primitive calls, on Manifold and OCCT alike.
7
+ const ORIGIN = [0, 0, 0];
8
+ const AXIS = { X: [1, 0, 0], Y: [0, 1, 0], Z: [0, 0, 1] };
9
+
10
+ const SUGAR = {
11
+ rotateX(deg) { return this.rotate(deg, ORIGIN, [1, 0, 0]); },
12
+ rotateY(deg) { return this.rotate(deg, ORIGIN, [0, 1, 0]); },
13
+ rotateZ(deg) { return this.rotate(deg, ORIGIN, [0, 0, 1]); },
14
+ rotateAbout({ axis, deg, through = ORIGIN }) {
15
+ const ax = Array.isArray(axis) ? axis : AXIS[axis];
16
+ if (!ax) throw new Error(`rotateAbout: unknown axis ${JSON.stringify(axis)} (use "X"|"Y"|"Z" or a [x,y,z] vector)`);
17
+ return this.rotate(deg, through, ax);
18
+ },
19
+ along(dir) {
20
+ switch (dir) {
21
+ case "+Z": return this.translate(ORIGIN); // fresh handle, identity geometry — consistent with the other directions
22
+ case "-Z": return this.rotate(180, ORIGIN, [1, 0, 0]);
23
+ case "+Y": return this.rotate(-90, ORIGIN, [1, 0, 0]);
24
+ case "-Y": return this.rotate(90, ORIGIN, [1, 0, 0]);
25
+ case "+X": return this.rotate(90, ORIGIN, [0, 1, 0]);
26
+ case "-X": return this.rotate(-90, ORIGIN, [0, 1, 0]);
27
+ default: throw new Error(`along: unknown direction ${JSON.stringify(dir)} (use "+X"|"-X"|"+Y"|"-Y"|"+Z"|"-Z")`);
28
+ }
29
+ },
30
+ at(v) { return this.translate(v); },
31
+ };
32
+
33
+ export function addSugar(s) {
34
+ return Object.assign(s, SUGAR);
35
+ }
package/src/parts/demo.js CHANGED
@@ -50,9 +50,19 @@ export default {
50
50
  build: (k, p, d) => {
51
51
  let s = k.cylinder(p.od / 2, p.od / 2, p.h);
52
52
  if (p.flange_d > 0) s = k.union([s, k.cylinder(p.flange_d / 2, p.flange_d / 2, p.flange_h)]);
53
- return s.cut(k.cylinder(d.boreR, d.boreR, d.cutH).translate([0, 0, -2]));
53
+ return s.cut(k.cylinder(d.boreR, d.boreR, d.cutH).at([0, 0, -2]));
54
54
  },
55
55
  },
56
56
  },
57
57
  views: { spacer: { label: "Spacer" } },
58
+ // Self-verification (see docs/AUTHORING-PARTS.md "Self-verification"): opt into the
59
+ // FDM-PLA process profile (bed-fit gate + min-wall warning) and pin the design intent
60
+ // — one through-bore, fits comfortably on the bed, no interpenetration.
61
+ verify: {
62
+ process: "fdm-pla",
63
+ expect: {
64
+ spacer: { holes: 1, bbox: "<=[60,60,60]" },
65
+ _view: { overlaps: 0 },
66
+ },
67
+ },
58
68
  };
@@ -37,7 +37,7 @@ export default {
37
37
  // the shortest edge it touches (here the fillets' bottom arcs), so it stops at
38
38
  // its valid maximum instead of mangling the bottom face.
39
39
  if (p.chamfer > 0) s = s.chamfer(p.chamfer, { inPlane: "XY", at: 0 }); // base edges
40
- if (p.bore > 0) s = s.cut(k.cylinder(p.bore / 2, p.bore / 2, p.h + 2).translate([p.w / 2, p.d / 2, -1]));
40
+ if (p.bore > 0) s = s.cut(k.cylinder(p.bore / 2, p.bore / 2, p.h + 2).at([p.w / 2, p.d / 2, -1]));
41
41
  return s;
42
42
  },
43
43
  },
@@ -0,0 +1,119 @@
1
+ // Example PartDefinition — a faceted planter / cup / vase. A second worked example
2
+ // alongside parts/demo.js (the Spacer): it shows a prism-based body (Manifold backend,
3
+ // so it stays fast — no OCCT), per-control descriptions, presets, an optional feature
4
+ // (drainage), a hidden internal constant (floor), and a derive() that turns raw inputs
5
+ // into the n-gon point lists and dependent dimensions the build consumes.
6
+ //
7
+ // Why it's a good demo: every control has an obvious reason to touch it before
8
+ // printing. Facets/twist are pure fun, height/diameter/taper fit it to your plant or
9
+ // pens, the drainage hole is a real functional choice (planter vs. cup), and dropping
10
+ // Wall below the fdm-pla 1.2 mm minimum trips partforge's min-wall warning.
11
+
12
+ // A regular n-gon of circumradius R, in the XY plane, as [[x,y],…] for k.prism.
13
+ // A small rotation seats a flat edge toward the viewer so even-sided shapes read right.
14
+ const ngon = (R, n) => {
15
+ const pts = [];
16
+ const offset = Math.PI / n - Math.PI / 2; // flat side facing -Y
17
+ for (let i = 0; i < n; i++) {
18
+ const a = (2 * Math.PI * i) / n + offset;
19
+ pts.push([R * Math.cos(a), R * Math.sin(a)]);
20
+ }
21
+ return pts;
22
+ };
23
+
24
+ export default {
25
+ meta: { title: "Faceted Planter", units: "mm", background: 0x15181d },
26
+ parameters: [
27
+ {
28
+ id: "body",
29
+ title: "Body",
30
+ description:
31
+ "The faceted vessel. Pick a preset to start, or open **Advanced** for exact dimensions. " +
32
+ "**Facets** and **Twist** are pure styling; **Wall** is the one that decides whether it prints cleanly.",
33
+ presets: {
34
+ "Pen cup": { facets: 6, dia: 80, height: 100, taper: 1.0, twist: 0, drain: 0 },
35
+ Planter: { facets: 8, dia: 90, height: 80, taper: 0.9, twist: 0, drain: 8 },
36
+ Vase: { facets: 5, dia: 70, height: 150, taper: 1.12, twist: 40, drain: 0 },
37
+ },
38
+ advanced: [
39
+ { key: "facets", label: "Facets", min: 3, max: 12, step: 1,
40
+ description: "Number of flat sides around the body. Low counts read as crystalline; high counts approach a smooth cylinder." },
41
+ { key: "dia", label: "Diameter", unit: "mm", min: 30, max: 150, step: 1,
42
+ description: "Across-corners diameter at the base. Size it to the plant, pens, or shelf it has to fit." },
43
+ { key: "height", label: "Height", unit: "mm", min: 20, max: 200, step: 1,
44
+ description: "Overall height along the axis." },
45
+ { key: "taper", label: "Top taper", min: 0.6, max: 1.4, step: 0.02,
46
+ description: "Rim size relative to the base: below 1 tapers inward (planter), 1 is straight (cup), above 1 flares out (vase)." },
47
+ { key: "wall", label: "Wall thickness", unit: "mm", min: 0.8, max: 4, step: 0.1,
48
+ description: "Side-wall thickness. The fdm-pla profile wants **≥ 1.2 mm** — go thinner and partforge flags a min-wall warning." },
49
+ { key: "twist", label: "Twist", unit: "°", min: 0, max: 180, step: 5,
50
+ description: "Rotates the facets from base to rim for a spiral look. 0 keeps the facets vertical." },
51
+ { key: "floor", label: "Floor thickness", unit: "mm", min: 1, max: 6, step: 0.5, hidden: true,
52
+ description: "Internal: solid base thickness, fixed by the design. Hidden from the end user but still drives the geometry." },
53
+ ],
54
+ },
55
+ {
56
+ id: "drainage",
57
+ title: "Drainage",
58
+ description: "Optional drainage hole through the base — turn it on for a planter, off to hold water like a cup or vase.",
59
+ features: [
60
+ { label: "Drainage hole", key: "drain", on: 8,
61
+ description: "Drills a centered hole of this diameter through the floor.",
62
+ sliders: [{ key: "drain", label: "Hole diameter", unit: "mm", min: 3, max: 30, step: 1,
63
+ description: "Diameter of the centered drainage hole." }] },
64
+ ],
65
+ },
66
+ ],
67
+ defaults: { facets: 6, dia: 70, height: 90, taper: 1.2, wall: 1.6, twist: 30, drain: 8, floor: 3 },
68
+ // derive(): turn raw inputs into the n-gon point lists and dependent dimensions the
69
+ // build needs, sized so the wall stays even (see build()).
70
+ derive: (p) => {
71
+ const Rout = p.dia / 2;
72
+ // Offset the inner polygon inward by `wall` along the FACE normals, not the radius:
73
+ // for a regular n-gon an edge offset of `wall` shrinks the circumradius by
74
+ // wall / cos(π/n). This keeps the perpendicular wall = `wall` on every flat.
75
+ // clamp only matters if wall is set past the slider bounds via the API
76
+ const Rin = Math.max(Rout - p.wall / Math.cos(Math.PI / p.facets), 1);
77
+ return {
78
+ outerPts: ngon(Rout, p.facets),
79
+ innerPts: ngon(Rin, p.facets),
80
+ // Inner taper that holds the wall constant top-to-bottom even as the body flares:
81
+ // pick it so inner_radius(top) = outer_radius(top) − wall.
82
+ innerTaper: 1 + (Rout * (p.taper - 1)) / Rin,
83
+ drainR: (p.drain + 0.2) / 2, // nominal hole + 0.2 mm print clearance, as a radius
84
+ };
85
+ },
86
+ parts: {
87
+ planter: {
88
+ label: "Planter",
89
+ views: ["planter"],
90
+ export: { name: "planter" },
91
+ build: (k, p, d) => {
92
+ const body = k.prism(d.outerPts, p.height, { scaleTop: p.taper, twist: p.twist });
93
+ // Hollow it. The cavity is built from z=0 sharing the body's exact twist RATE and
94
+ // taper slope (f rescales the ~4 mm overshoot so the rates still match), so the
95
+ // inner and outer facets stay radially aligned at every height — the wall can't
96
+ // pinch when twisted. Then clip the cavity to z ≥ floor so the base stays solid.
97
+ const f = (p.height + 4) / p.height;
98
+ const cavity = k
99
+ .prism(d.innerPts, p.height + 4, { scaleTop: 1 + (d.innerTaper - 1) * f, twist: p.twist * f })
100
+ .intersect(k.box([-1e4, -1e4, p.floor], [1e4, 1e4, p.height + 10]));
101
+ let s = body.cut(cavity);
102
+ // Optional drainage hole straight through the base.
103
+ if (p.drain > 0) s = s.cut(k.cylinder(d.drainR, d.drainR, p.floor + 4).at([0, 0, -2]));
104
+ return s;
105
+ },
106
+ },
107
+ },
108
+ views: { planter: { label: "Planter" } },
109
+ // Self-verification (see docs/AUTHORING-PARTS.md "Self-verification"): opt into the
110
+ // FDM-PLA process profile (bed-fit gate + min-wall warning) and pin the design intent
111
+ // — one drainage hole through the base, fits the bed, no interpenetration.
112
+ verify: {
113
+ process: "fdm-pla",
114
+ expect: {
115
+ planter: { holes: 1 /* drain=8 at defaults → 1 hole; adjust if running verify with a non-default drain */, bbox: "<=[220,220,250]" },
116
+ _view: { overlaps: 0 } /* _view = whole-model composite (not a named part) */,
117
+ },
118
+ },
119
+ };
@@ -0,0 +1,3 @@
1
+ import part from "./parts/planter.js";
2
+ import { runWorker } from "./framework/worker.js";
3
+ runWorker(part);
@@ -0,0 +1,79 @@
1
+ // Assertion mini-DSL: parse a declared expectation into a normalized predicate.
2
+ // Numeric values are normalized to base units (mm for length, mm³ for volume) at
3
+ // parse time so the evaluator compares plain numbers. Strict: any unrecognized form
4
+ // throws an Error naming the offending string.
5
+
6
+ const UNIT = { mm: 1, cm: 10, mm3: 1, cm3: 1000 };
7
+
8
+ function toBase(numStr, unit) {
9
+ const n = Number(numStr);
10
+ if (!Number.isFinite(n)) throw new Error(`assertion: not a number: "${numStr}"`);
11
+ if (unit === undefined) return n;
12
+ if (!(unit in UNIT)) throw new Error(`assertion: unknown unit: "${unit}"`);
13
+ return n * UNIT[unit];
14
+ }
15
+
16
+ const NUM = "[-+]?[0-9]*\\.?[0-9]+";
17
+ const U = "(mm3|cm3|mm|cm)?";
18
+ const reScalar = new RegExp(`^(>=|<=|>|<)?\\s*(${NUM})\\s*${U}$`);
19
+ const reRange = new RegExp(`^(${NUM})\\s*\\.\\.\\s*(${NUM})\\s*${U}$`);
20
+ const reVec = /^(>=|<=)\s*\[\s*(.+?)\s*\]$/;
21
+
22
+ export function parseAssertion(expr) {
23
+ if (typeof expr === "number" || typeof expr === "boolean") return { op: "eq", value: expr };
24
+ if (typeof expr !== "string") throw new Error(`assertion: unsupported value ${JSON.stringify(expr)}`);
25
+ const s = expr.trim();
26
+
27
+ const vec = s.match(reVec);
28
+ if (vec) {
29
+ const parts = vec[2].split(",").map((t) => t.trim());
30
+ if (parts.length !== 3) throw new Error(`assertion: vector needs 3 components: "${expr}"`);
31
+ return { op: vec[1] === "<=" ? "vle" : "vge", vec: parts.map((t) => (t === "*" ? null : toBase(t, undefined))) };
32
+ }
33
+ const range = s.match(reRange);
34
+ if (range) return { op: "range", min: toBase(range[1], range[3] || undefined), max: toBase(range[2], range[3] || undefined) };
35
+
36
+ const sc = s.match(reScalar);
37
+ if (sc) {
38
+ const op = sc[1] ? { ">=": "gte", "<=": "lte", ">": "gt", "<": "lt" }[sc[1]] : "eq";
39
+ return { op, value: toBase(sc[2], sc[3] || undefined) };
40
+ }
41
+ throw new Error(`assertion: unrecognized form: "${expr}"`);
42
+ }
43
+
44
+ const EPS = 1e-6;
45
+ const approxEq = (a, b) => Math.abs(a - b) <= EPS + EPS * Math.abs(b);
46
+ const fmtVec = (v) => "[" + v.map((x) => (x === null ? "*" : x)).join(",") + "]";
47
+
48
+ export function evaluateAssertion(parsed, actual) {
49
+ switch (parsed.op) {
50
+ case "eq": {
51
+ const pass = typeof parsed.value === "boolean" ? actual === parsed.value : approxEq(actual, parsed.value);
52
+ return { pass, message: `${actual} ${pass ? "==" : "!="} ${parsed.value}` };
53
+ }
54
+ case "gte": return mk(actual >= parsed.value - EPS, actual, ">=", parsed.value);
55
+ case "lte": return mk(actual <= parsed.value + EPS, actual, "<=", parsed.value);
56
+ case "gt": return mk(actual > parsed.value, actual, ">", parsed.value);
57
+ case "lt": return mk(actual < parsed.value, actual, "<", parsed.value);
58
+ case "range": {
59
+ const pass = actual >= parsed.min - EPS && actual <= parsed.max + EPS;
60
+ return { pass, message: `${actual} ${pass ? "in" : "out of"} ${parsed.min}..${parsed.max}` };
61
+ }
62
+ case "vle":
63
+ case "vge": {
64
+ const ge = parsed.op === "vge";
65
+ let pass = true;
66
+ for (let i = 0; i < 3; i++) {
67
+ const lim = parsed.vec[i];
68
+ if (lim === null) continue;
69
+ if (ge ? actual[i] < lim - EPS : actual[i] > lim + EPS) pass = false;
70
+ }
71
+ return { pass, message: `${fmtVec(actual)} ${ge ? ">=" : "<="} ${fmtVec(parsed.vec)}` };
72
+ }
73
+ default: throw new Error(`assertion: unknown op "${parsed.op}"`);
74
+ }
75
+ }
76
+
77
+ function mk(pass, actual, opStr, value) {
78
+ return { pass, message: `${actual} ${pass ? opStr : "not " + opStr} ${value}` };
79
+ }
@@ -0,0 +1,166 @@
1
+ // src/testing/bvh.js
2
+ // Triangle BVH over a mesh in either Manifold non-indexed soup form (9 floats per
3
+ // triangle, no `indices`) or OCCT indexed form (`positions` = 3 floats/vertex +
4
+ // `indices` = 3 vertex-indices/triangle). A reusable spatial index: nearest ray hit
5
+ // (raycast) and nearest surface point (closestPoint, added alongside). AABB tree,
6
+ // median split on the widest centroid axis, slab ray–box test with pruning.
7
+
8
+ const LEAF = 4; // max triangles per leaf
9
+
10
+ // Triangles as [v0,v1,v2] coord triples, from either a Manifold non-indexed soup
11
+ // (positions = 9 floats/triangle, no indices) or an OCCT indexed mesh (positions =
12
+ // 3 floats/vertex + indices = 3 vertex-indices/triangle).
13
+ export function meshTriangles(mesh) {
14
+ const { positions, indices } = mesh;
15
+ if (indices) {
16
+ const n = indices.length / 3, out = new Array(n);
17
+ for (let t = 0; t < n; t++) {
18
+ const a = indices[3 * t] * 3, b = indices[3 * t + 1] * 3, c = indices[3 * t + 2] * 3;
19
+ out[t] = [[positions[a], positions[a + 1], positions[a + 2]],
20
+ [positions[b], positions[b + 1], positions[b + 2]],
21
+ [positions[c], positions[c + 1], positions[c + 2]]];
22
+ }
23
+ return out;
24
+ }
25
+ const n = positions.length / 9, out = new Array(n);
26
+ for (let t = 0; t < n; t++) {
27
+ const o = t * 9;
28
+ out[t] = [[positions[o], positions[o + 1], positions[o + 2]],
29
+ [positions[o + 3], positions[o + 4], positions[o + 5]],
30
+ [positions[o + 6], positions[o + 7], positions[o + 8]]];
31
+ }
32
+ return out;
33
+ }
34
+
35
+ function readTris(mesh) {
36
+ const triangles = meshTriangles(mesh);
37
+ return triangles.map(([v0, v1, v2], i) => {
38
+ const min = [Math.min(v0[0], v1[0], v2[0]), Math.min(v0[1], v1[1], v2[1]), Math.min(v0[2], v1[2], v2[2])];
39
+ const max = [Math.max(v0[0], v1[0], v2[0]), Math.max(v0[1], v1[1], v2[1]), Math.max(v0[2], v1[2], v2[2])];
40
+ return { i, v0, v1, v2, min, max, c: [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2] };
41
+ });
42
+ }
43
+
44
+ function aabbOf(items) {
45
+ const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
46
+ for (const it of items) for (let a = 0; a < 3; a++) { if (it.min[a] < min[a]) min[a] = it.min[a]; if (it.max[a] > max[a]) max[a] = it.max[a]; }
47
+ return { min, max };
48
+ }
49
+
50
+ function build(items) {
51
+ const box = aabbOf(items);
52
+ if (items.length <= LEAF) return { ...box, tris: items };
53
+ const ext = [box.max[0] - box.min[0], box.max[1] - box.min[1], box.max[2] - box.min[2]];
54
+ const axis = ext[0] >= ext[1] && ext[0] >= ext[2] ? 0 : ext[1] >= ext[2] ? 1 : 2;
55
+ const sorted = items.slice().sort((p, q) => p.c[axis] - q.c[axis]);
56
+ const mid = sorted.length >> 1;
57
+ const left = sorted.slice(0, mid), right = sorted.slice(mid);
58
+ if (left.length === 0 || right.length === 0) return { ...box, tris: items }; // degenerate split
59
+ return { ...box, left: build(left), right: build(right) };
60
+ }
61
+
62
+ // slab test: returns the entry distance if the ray meets [min,max] within (tMin,best], else Infinity
63
+ function rayBox(o, invD, min, max, tMin, best) {
64
+ let t0 = tMin, t1 = best;
65
+ for (let a = 0; a < 3; a++) {
66
+ let lo = (min[a] - o[a]) * invD[a], hi = (max[a] - o[a]) * invD[a];
67
+ if (lo > hi) { const tmp = lo; lo = hi; hi = tmp; }
68
+ if (lo > t0) t0 = lo; if (hi < t1) t1 = hi;
69
+ if (t0 > t1) return Infinity;
70
+ }
71
+ return t0;
72
+ }
73
+
74
+ // nearest point on triangle to P (Ericson), returns { point, d2 }
75
+ function closestOnTri(P, tri) {
76
+ const A = tri.v0, B = tri.v1, C = tri.v2;
77
+ const sub = (p, q) => [p[0]-q[0], p[1]-q[1], p[2]-q[2]];
78
+ const dot = (p, q) => p[0]*q[0] + p[1]*q[1] + p[2]*q[2];
79
+ const add = (p, q) => [p[0]+q[0], p[1]+q[1], p[2]+q[2]];
80
+ const mul = (p, s) => [p[0]*s, p[1]*s, p[2]*s];
81
+ const ab = sub(B,A), ac = sub(C,A), ap = sub(P,A);
82
+ const d1 = dot(ab,ap), d2 = dot(ac,ap);
83
+ let Q;
84
+ if (d1<=0&&d2<=0) Q = A;
85
+ else { const bp = sub(P,B), d3 = dot(ab,bp), d4 = dot(ac,bp);
86
+ if (d3>=0&&d4<=d3) Q = B;
87
+ else { const vc = d1*d4 - d3*d2;
88
+ if (vc<=0&&d1>=0&&d3<=0) Q = add(A, mul(ab, d1/(d1-d3)));
89
+ else { const cp = sub(P,C), d5 = dot(ab,cp), d6 = dot(ac,cp);
90
+ if (d6>=0&&d5<=d6) Q = C;
91
+ else { const vb = d5*d2 - d1*d6;
92
+ if (vb<=0&&d2>=0&&d6<=0) Q = add(A, mul(ac, d2/(d2-d6)));
93
+ else { const va = d3*d6 - d5*d4;
94
+ if (va<=0&&(d4-d3)>=0&&(d5-d6)>=0) Q = add(B, mul(sub(C,B), (d4-d3)/((d4-d3)+(d5-d6))));
95
+ else { const denom = 1/(va+vb+vc); Q = add(add(A, mul(ab, vb*denom)), mul(ac, vc*denom)); } } } } } }
96
+ const pq = sub(P, Q);
97
+ return { point: Q, d2: dot(pq, pq) };
98
+ }
99
+
100
+ // squared distance from point to an AABB (0 inside)
101
+ function distSqBox(p, min, max) {
102
+ let s = 0;
103
+ for (let a = 0; a < 3; a++) { const v = p[a] < min[a] ? min[a] - p[a] : p[a] > max[a] ? p[a] - max[a] : 0; s += v * v; }
104
+ return s;
105
+ }
106
+
107
+ // Möller–Trumbore; returns t>tMin or Infinity
108
+ function rayTri(o, d, tri, tMin) {
109
+ const e1 = [tri.v1[0] - tri.v0[0], tri.v1[1] - tri.v0[1], tri.v1[2] - tri.v0[2]];
110
+ const e2 = [tri.v2[0] - tri.v0[0], tri.v2[1] - tri.v0[1], tri.v2[2] - tri.v0[2]];
111
+ const p = [d[1] * e2[2] - d[2] * e2[1], d[2] * e2[0] - d[0] * e2[2], d[0] * e2[1] - d[1] * e2[0]];
112
+ const det = e1[0] * p[0] + e1[1] * p[1] + e1[2] * p[2];
113
+ if (det > -1e-12 && det < 1e-12) return Infinity;
114
+ const inv = 1 / det;
115
+ const tv = [o[0] - tri.v0[0], o[1] - tri.v0[1], o[2] - tri.v0[2]];
116
+ const u = (tv[0] * p[0] + tv[1] * p[1] + tv[2] * p[2]) * inv;
117
+ if (u < 0 || u > 1) return Infinity;
118
+ const q = [tv[1] * e1[2] - tv[2] * e1[1], tv[2] * e1[0] - tv[0] * e1[2], tv[0] * e1[1] - tv[1] * e1[0]];
119
+ const v = (d[0] * q[0] + d[1] * q[1] + d[2] * q[2]) * inv;
120
+ if (v < 0 || u + v > 1) return Infinity;
121
+ const t = (e2[0] * q[0] + e2[1] * q[1] + e2[2] * q[2]) * inv;
122
+ return t > tMin ? t : Infinity;
123
+ }
124
+
125
+ export function buildBVH(mesh) {
126
+ const tris = readTris(mesh);
127
+ const root = build(tris);
128
+
129
+ function raycast(origin, dir, { tMin = 1e-6, tMax = Infinity, skipTri = -1 } = {}) {
130
+ const invD = [1 / dir[0], 1 / dir[1], 1 / dir[2]];
131
+ let best = tMax, bestTri = -1;
132
+ const stack = [root];
133
+ while (stack.length) {
134
+ const node = stack.pop();
135
+ if (rayBox(origin, invD, node.min, node.max, tMin, best) === Infinity) continue;
136
+ if (node.tris) {
137
+ for (const tri of node.tris) {
138
+ if (tri.i === skipTri) continue;
139
+ const t = rayTri(origin, dir, tri, tMin);
140
+ if (t < best) { best = t; bestTri = tri.i; }
141
+ }
142
+ } else { stack.push(node.left, node.right); }
143
+ }
144
+ return bestTri === -1 ? null : { t: best, tri: bestTri };
145
+ }
146
+
147
+ // No production consumer yet — pre-built + tested as the reusable primitive for the deferred clearance/min-feature gate.
148
+ function closestPoint(p) {
149
+ let best2 = Infinity, bestPt = null, bestTri = -1;
150
+ const stack = [root];
151
+ while (stack.length) {
152
+ const node = stack.pop();
153
+ if (distSqBox(p, node.min, node.max) > best2) continue;
154
+ if (node.tris) {
155
+ for (const tri of node.tris) { const r = closestOnTri(p, tri); if (r.d2 < best2) { best2 = r.d2; bestPt = r.point; bestTri = tri.i; } }
156
+ } else {
157
+ // visit the nearer child first for better pruning
158
+ const dl = distSqBox(p, node.left.min, node.left.max), dr = distSqBox(p, node.right.min, node.right.max);
159
+ if (dl < dr) { stack.push(node.right, node.left); } else { stack.push(node.left, node.right); }
160
+ }
161
+ }
162
+ return { point: bestPt, dist: Math.sqrt(best2), tri: bestTri };
163
+ }
164
+
165
+ return { raycast, closestPoint };
166
+ }
@@ -0,0 +1,25 @@
1
+ // Enumerate the parameter configurations verify() checks: the default config plus
2
+ // every declared preset (or an explicit part.verify.cases list).
3
+
4
+ function presetMap(part) {
5
+ const map = {};
6
+ for (const section of part.parameters ?? []) {
7
+ if (!section.presets) continue;
8
+ for (const [name, overrides] of Object.entries(section.presets)) {
9
+ if (name in map) throw new Error(`duplicate preset name across sections: "${name}"`);
10
+ map[name] = overrides;
11
+ }
12
+ }
13
+ return map;
14
+ }
15
+
16
+ export function expandCases(part) {
17
+ const presets = presetMap(part);
18
+ const make = (name) => {
19
+ if (name === "defaults") return { name, params: { ...part.defaults } };
20
+ if (!(name in presets)) throw new Error(`unknown verify case "${name}" (not "defaults" or a preset)`);
21
+ return { name, params: { ...part.defaults, ...presets[name] } };
22
+ };
23
+ const names = part.verify?.cases ?? ["defaults", ...Object.keys(presets)];
24
+ return names.map(make);
25
+ }
@@ -0,0 +1,23 @@
1
+ // Reusable design-for-manufacturing process profiles. `bed` is the build volume
2
+ // [x,y,z] in mm (a hard bbox-fit gate); `minWall` mm (a warn); `clearance` mm is
3
+ // carried for a future gap check (not enforced yet).
4
+ export const PROFILES = {
5
+ "fdm-pla": { bed: [220, 220, 250], minWall: 1.2, clearance: 0.2 },
6
+ "fdm-petg": { bed: [220, 220, 250], minWall: 1.5, clearance: 0.3 },
7
+ "resin": { bed: [120, 68, 160], minWall: 0.6, clearance: 0.1 },
8
+ };
9
+
10
+ export function resolveProfile(spec) {
11
+ if (typeof spec === "string") {
12
+ if (!(spec in PROFILES)) {
13
+ throw new Error(`unknown process profile: "${spec}" (known: ${Object.keys(PROFILES).join(", ")})`);
14
+ }
15
+ return { ...PROFILES[spec] };
16
+ }
17
+ if (spec && typeof spec === "object") {
18
+ const base = spec.base ? resolveProfile(spec.base) : {};
19
+ const { base: _drop, ...overrides } = spec;
20
+ return { ...base, ...overrides };
21
+ }
22
+ throw new Error(`invalid process profile: ${JSON.stringify(spec)}`);
23
+ }
@@ -1,6 +1,7 @@
1
1
  import { buildView } from "./build.js";
2
2
  import { assemblyOverlaps } from "../framework/assembly.js";
3
3
  import { bounds, meshArea } from "./mesh.js";
4
+ import { minWall } from "./min-wall.js";
4
5
 
5
6
  const size = ({ min, max }) => [max[0] - min[0], max[1] - min[1], max[2] - min[2]];
6
7
  const unionBounds = (list) => list.reduce(
@@ -13,7 +14,7 @@ const unionBounds = (list) => list.reduce(
13
14
  // the assembly overlap check. All solid facts are read BEFORE assemblyOverlaps,
14
15
  // which frees the shared kernel's objects at its end.
15
16
  // → { part, view, subparts[], aggregate, overlaps[], ok }
16
- export function measure(kernel, part, view = Object.keys(part.views)[0], params = {}) {
17
+ export function measure(kernel, part, view = Object.keys(part.views)[0], params = {}, opts = {}) {
17
18
  const built = buildView(kernel, part, view, params);
18
19
  const subBounds = [];
19
20
  const subparts = built.map(({ name, solid, mesh }) => {
@@ -27,6 +28,7 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
27
28
  triangleCount: mesh.triangles,
28
29
  watertight: typeof solid.isEmpty === "function" ? !solid.isEmpty() : null,
29
30
  holes: typeof solid.genus === "function" ? solid.genus() : null,
31
+ minWall: opts.minWall ? (minWall(mesh)?.value ?? null) : null,
30
32
  };
31
33
  });
32
34
 
@@ -0,0 +1,38 @@
1
+ // src/testing/min-wall.js
2
+ // Min wall thickness by ray/shot on a triangle BVH (see the spec's spike: this beat the
3
+ // voxel/SDF approach on both accuracy and speed). For each surface triangle, cast a ray
4
+ // inward (reverse of its outward normal) from the centroid; the nearest hit is the local
5
+ // material thickness. The minimum across samples is the reported min wall.
6
+ // Works with both Manifold non-indexed meshes and OCCT indexed meshes (via meshTriangles).
7
+ import { buildBVH, meshTriangles } from "./bvh.js";
8
+
9
+ export function minWall(mesh, { maxThickness } = {}) {
10
+ const pos = mesh.positions;
11
+ const tris = meshTriangles(mesh);
12
+ if (tris.length === 0) return null;
13
+
14
+ // bbox diagonal as the default cap (a ray exiting into open air gets no hit anyway).
15
+ if (maxThickness == null) {
16
+ const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
17
+ for (let i = 0; i < pos.length; i += 3) for (let a = 0; a < 3; a++) { if (pos[i + a] < min[a]) min[a] = pos[i + a]; if (pos[i + a] > max[a]) max[a] = pos[i + a]; }
18
+ maxThickness = Math.hypot(max[0] - min[0], max[1] - min[1], max[2] - min[2]) + 1;
19
+ }
20
+
21
+ const bvh = buildBVH(mesh);
22
+ let best = Infinity, loc = null;
23
+ for (let t = 0; t < tris.length; t++) {
24
+ const [v0, v1, v2] = tris[t];
25
+ const e1 = [v1[0] - v0[0], v1[1] - v0[1], v1[2] - v0[2]];
26
+ const e2 = [v2[0] - v0[0], v2[1] - v0[1], v2[2] - v0[2]];
27
+ let nx = e1[1] * e2[2] - e1[2] * e2[1], ny = e1[2] * e2[0] - e1[0] * e2[2], nz = e1[0] * e2[1] - e1[1] * e2[0];
28
+ const len = Math.hypot(nx, ny, nz);
29
+ if (len < 1e-9) continue; // degenerate triangle
30
+ nx /= len; ny /= len; nz /= len; // outward normal (manifold winding)
31
+ const c = [(v0[0] + v1[0] + v2[0]) / 3, (v0[1] + v1[1] + v2[1]) / 3, (v0[2] + v1[2] + v2[2]) / 3];
32
+ const dir = [-nx, -ny, -nz]; // inward
33
+ const origin = [c[0] + dir[0] * 1e-4, c[1] + dir[1] * 1e-4, c[2] + dir[2] * 1e-4];
34
+ const hit = bvh.raycast(origin, dir, { tMax: maxThickness, skipTri: t });
35
+ if (hit && hit.t < best) { best = hit.t; loc = c; }
36
+ }
37
+ return best === Infinity ? null : { value: best, location: loc };
38
+ }
@@ -0,0 +1,89 @@
1
+ import { parseAssertion, evaluateAssertion } from "./assert-dsl.js";
2
+ import { measure as defaultMeasure } from "./measure.js";
3
+ import { resolveProfile } from "./dfm-profiles.js";
4
+ import { expandCases } from "./cases.js";
5
+ import { subPartReadKeys, relevanceHash, RELEVANT_ALL } from "../framework/param-deps.js";
6
+
7
+ // Metric registry: name → how to pull the value out of facts, and whether a failure
8
+ // is a hard gate or a warning. `manifoldOnly` facts are null on OCCT parts.
9
+ const SUBPART_METRICS = {
10
+ holes: { kind: "gate", manifoldOnly: true, extract: (s) => s.holes },
11
+ watertight: { kind: "gate", manifoldOnly: true, extract: (s) => s.watertight },
12
+ volume: { kind: "gate", extract: (s) => s.volume },
13
+ surfaceArea: { kind: "gate", extract: (s) => s.surfaceArea },
14
+ triangleCount: { kind: "gate", extract: (s) => s.triangleCount },
15
+ bbox: { kind: "gate", extract: (s) => s.bbox },
16
+ minWall: { kind: "warn", extract: (s) => s.minWall },
17
+ };
18
+ const VIEW_METRICS = {
19
+ bbox: { kind: "gate", extract: (r) => r.aggregate.bbox },
20
+ volume: { kind: "gate", extract: (r) => r.aggregate.volume },
21
+ overlaps: { kind: "gate", extract: (r) => r.overlaps.length },
22
+ };
23
+
24
+ function check(scope, subpart, metric, expr, registry, factsObj) {
25
+ const reg = registry[metric];
26
+ if (!reg) throw new Error(`unknown ${scope} metric "${metric}"${subpart ? ` on sub-part "${subpart}"` : ""}`);
27
+ const actual = reg.extract(factsObj);
28
+ const base = { scope, subpart, metric, kind: reg.kind, expr: String(expr) };
29
+ if (actual === null || actual === undefined) {
30
+ if (reg.manifoldOnly) return { ...base, actual, status: "skip", pass: null, message: "n/a (OCCT backend)" };
31
+ if (metric === "minWall") return { ...base, actual, status: "warn", pass: null, message: "min wall unavailable" };
32
+ return { ...base, actual, status: "skip", pass: null, message: "unavailable" };
33
+ }
34
+ const { pass, message } = evaluateAssertion(parseAssertion(expr), actual);
35
+ const status = pass ? "pass" : reg.kind === "warn" ? "warn" : "fail";
36
+ return { ...base, actual, status, pass, message };
37
+ }
38
+
39
+ // Pure policy: profile rules + per-part expect → checks for one case's facts.
40
+ export function evaluateCase(facts, { profile, expect }) {
41
+ const checks = [];
42
+ const viewExp = {
43
+ ...(profile?.bed ? { bbox: `<=[${profile.bed.join(",")}]` } : {}),
44
+ ...(expect?._view ?? {}),
45
+ };
46
+ for (const [metric, expr] of Object.entries(viewExp)) checks.push(check("view", null, metric, expr, VIEW_METRICS, facts));
47
+
48
+ for (const s of facts.subparts) {
49
+ const merged = {
50
+ ...(profile?.minWall != null ? { minWall: `>=${profile.minWall}` } : {}),
51
+ ...(expect?.[s.name] ?? {}),
52
+ };
53
+ for (const [metric, expr] of Object.entries(merged)) checks.push(check("subpart", s.name, metric, expr, SUBPART_METRICS, s));
54
+ }
55
+ return checks;
56
+ }
57
+
58
+ export function verify(kernel, part, { process, view, measureFn = defaultMeasure } = {}) {
59
+ view = view ?? Object.keys(part.views)[0];
60
+ const profileSpec = process ?? part.verify?.process;
61
+ const profile = profileSpec ? resolveProfile(profileSpec) : null;
62
+ const expect = part.verify?.expect ?? {};
63
+ const expectMentionsMinWall = Object.values(expect).some((o) => o && typeof o === "object" && "minWall" in o);
64
+ const needMinWall = profile?.minWall != null || expectMentionsMinWall;
65
+
66
+ const cases = expandCases(part);
67
+ const readKeys = subPartReadKeys(part, view, part.defaults);
68
+ const signature = (params) =>
69
+ readKeys === RELEVANT_ALL
70
+ ? JSON.stringify(params)
71
+ : [...readKeys.entries()].map(([name, keys]) => `${name}:${relevanceHash([...keys], params)}`).join("|");
72
+
73
+ const memo = new Map();
74
+ const measureCase = (params) => {
75
+ const key = signature(params);
76
+ if (!memo.has(key)) memo.set(key, measureFn(kernel, part, view, params, { minWall: needMinWall }));
77
+ return memo.get(key);
78
+ };
79
+
80
+ const caseResults = cases.map(({ name, params }) => ({ name, params, checks: evaluateCase(measureCase(params), { profile, expect }) }));
81
+ const all = caseResults.flatMap((c) => c.checks.map((ch) => ({ case: c.name, ...ch })));
82
+ return {
83
+ ok: !all.some((c) => c.status === "fail"),
84
+ view,
85
+ cases: caseResults,
86
+ failures: all.filter((c) => c.status === "fail"),
87
+ warnings: all.filter((c) => c.status === "warn"),
88
+ };
89
+ }
package/src/testing.js CHANGED
@@ -9,3 +9,6 @@ export { meshVolume, bboxSize } from "./testing/mesh.js";
9
9
  export { buildView } from "./testing/build.js";
10
10
  export { measure } from "./testing/measure.js";
11
11
  export { renderViews } from "./testing/render.js";
12
+ export { verify } from "./testing/verify.js";
13
+ export { buildBVH } from "./testing/bvh.js";
14
+ export { minWall } from "./testing/min-wall.js";