partforge 0.12.0 → 0.13.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.
@@ -94,18 +94,29 @@ handles. The same code runs on **Manifold** (fast meshes — preview + STL + 3MF
94
94
  semantics, conformance classes, versioning) are in `docs/KERNEL-CONTRACT.md` — the
95
95
  tables below are the authoring-side view of that contract.
96
96
 
97
+ **Calling convention.** Every multi-parameter op below takes a single **options
98
+ object** — this is the canonical, documented way to call them (`k.cylinder({ r, h
99
+ })`, not `k.cylinder(r, r, h)`); the object's keys are named the same across both
100
+ backends, so a call is self-describing and immune to the positional-argument
101
+ transposition mistake (swap two same-typed numbers, get a valid *wrong* solid).
102
+ Single-argument chaining ops (`translate`, `rotate*`, `cut`, `mirror`, `scale`, …)
103
+ already take one argument and are unaffected. Legacy positional calls (e.g.
104
+ `k.cylinder(rBottom, rTop, h)`) still work — they're accepted silently until a
105
+ future contract v2 — but are not shown here; see `docs/KERNEL-CONTRACT.md`
106
+ "Calling convention" for the full canonical/legacy table and the detection rule.
107
+
97
108
  **Kernel — make solids:**
98
109
 
99
110
  | Call | Result |
100
111
  |---|---|
101
- | `k.cylinder(rBottom, rTop, h, { center? })` | cylinder/cone along +Z (frustum if radii differ) |
102
- | `k.box(min, max)` | axis-aligned box from `[x,y,z]` min/max |
103
- | `k.prism(points2D, h, { twist?, scaleTop? })` | extrude a 2-D polygon (or an **arc profile** from `roundedProfile`) from z=0; optional `twist` (degrees over the height) and `scaleTop` (uniform top taper: 1 straight, <1 taper in, 0 → point/cone) |
104
- | `k.extrude(profile, h, { twist?, scaleTop? })` | extrude a **polygon-with-holes** region from z=0 in one op — `profile` is `{ outer, holes? }` where each contour is a points array **or an arc profile** (`roundedProfile`, for true STEP fillets), or a bare points array / arc profile for outer-only; same `twist`/`scaleTop` as `prism` (both backends) |
105
- | `k.loft(rings, { ruled?, closed? })` | stack polygon cross-sections into a solid — ruled walls between consecutive rings, capped ends (both backends; `closed:true` capless loops are Manifold-only). `ruled:false` (smooth C2 blend) is honoured only by OCCT/STEP export; the Manifold preview always shows faceted straight walls |
106
- | `k.sweep(profile2D, path3D, { cornerRadius?, closed?, ruled?, smooth? })` | sweep a fixed 2-D profile along a 3-D polyline path — sharp mitered corners (or `cornerRadius` fillets), capped ends (both backends). `closed:true` capless loops and `smooth:true` (OCCT-native swept B-rep, STEP-exact / preview-faceted) are backend-specific, like loft's `closed`/`ruled:false`. `closed:true` loops must be **planar** — RMF frame-transport holonomy can seam-twist a non-planar closed loop where the last station rejoins the first, so only planar closed loops are supported/tested |
107
- | `k.sphere(r)` | sphere centred at the origin |
108
- | `k.revolve(points2D, { degrees })` | revolve a lathe profile `[[r,z],…]` (r ≥ 0) around the Z axis (full or partial) |
112
+ | `k.cylinder({ r\|d, h, center? })` · `k.cylinder({ r1, r2, h, center? })` \| `{ d1, d2, h }` | cylinder/cone along +Z (frustum for the cone form); straight takes exactly one of `r`/`d` |
113
+ | `k.box({ size, center? })` · `k.box({ min, max })` | `{size:[x,y,z]}` = centered X/Y, base at z=0 (`center:true` also centers Z); `{min,max}` = explicit `[x,y,z]` corners |
114
+ | `k.prism({ points, h, twist?, scaleTop? })` | extrude a 2-D polygon (or an **arc profile** from `roundedProfile`) from z=0; optional `twist` (degrees over the height) and `scaleTop` (uniform top taper: 1 straight, <1 taper in, 0 → point/cone) |
115
+ | `k.extrude({ profile, h, twist?, scaleTop? })` | extrude a **polygon-with-holes** region from z=0 in one op — `profile` is `{ outer, holes? }` where each contour is a points array **or an arc profile** (`roundedProfile`, for true STEP fillets), or a bare points array / arc profile for outer-only; same `twist`/`scaleTop` as `prism` (both backends) |
116
+ | `k.loft({ rings, ruled?, closed? })` | stack polygon cross-sections into a solid — ruled walls between consecutive rings, capped ends (both backends; `closed:true` capless loops are Manifold-only). `ruled:false` (smooth C2 blend) is honoured only by OCCT/STEP export; the Manifold preview always shows faceted straight walls |
117
+ | `k.sweep({ profile, path, cornerRadius?, closed?, ruled?, smooth? })` | sweep a fixed 2-D profile along a 3-D polyline path — sharp mitered corners (or `cornerRadius` fillets), capped ends (both backends). `closed:true` capless loops and `smooth:true` (OCCT-native swept B-rep, STEP-exact / preview-faceted) are backend-specific, like loft's `closed`/`ruled:false`. `closed:true` loops must be **planar** — RMF frame-transport holonomy can seam-twist a non-planar closed loop where the last station rejoins the first, so only planar closed loops are supported/tested |
118
+ | `k.sphere({ r\|d })` | sphere centred at the origin; bare `k.sphere(r)` also stays valid |
119
+ | `k.revolve({ profile, degrees? })` | revolve a lathe profile `[[r,z],…]` (r ≥ 0) around the Z axis (full or partial) |
109
120
  | `k.helixSweptTube({ pathR, profileR, pitch, turns, z0, lefthand })` | circle swept along a helix (e.g. a rope groove) |
110
121
  | `k.union(solids[])` | boolean union |
111
122
 
@@ -116,26 +127,26 @@ number or `[sx,sy]`). Author rings CCW and ordered by ascending `z` (the `regula
116
127
  CW-wound or descending-z rings still export a valid outward solid. (Arc profiles from
117
128
  `roundedProfile` are **not** accepted as loft rings yet — a ring must be a point array;
118
129
  use `prism`/`extrude` for true-arc STEP export.) **`sweep`** takes the same CCW
119
- `polygon.js` outline as its `profile2D` and a plain `[[x,y,z],…]` point list as its
120
- `path3D`; the profile stays perpendicular to the path (a rotation-minimizing frame), with
130
+ `polygon.js` outline as its `profile` and a plain `[[x,y,z],…]` point list as its
131
+ `path`; the profile stays perpendicular to the path (a rotation-minimizing frame), with
121
132
  sharp mitered corners by default or `cornerRadius` fillets. Worked snippets:
122
133
 
123
134
  ```js
124
135
  // a square tube (extrude a region with a hole) — one op, no boolean cut
125
- k.extrude({ outer: roundedRectPolygon(40, 30, 4), holes: [circleProfile(6)] }, 10);
136
+ k.extrude({ profile: { outer: roundedRectPolygon(40, 30, 4), holes: [circleProfile(6)] }, h: 10 });
126
137
 
127
138
  // a tapered, twisting faceted vase wall (see src/parts/faceted-vase.js)
128
139
  const rings = [];
129
140
  for (let i = 0; i <= 24; i++) { const t = i / 24;
130
141
  rings.push({ sides: 6, radius: 30 - 8 * t, z: 120 * t, rotate: 90 * t }); }
131
- k.loft(rings); // ruled walls, capped ends
142
+ k.loft({ rings }); // ruled walls, capped ends
132
143
 
133
144
  // a cable/hose: sweep a circle along a 3-D polyline, with rounded bends
134
- k.sweep(circleProfile(3), [[0, 0, 0], [0, 0, 20], [15, 0, 20]], { cornerRadius: 5 });
145
+ k.sweep({ profile: circleProfile(3), path: [[0, 0, 0], [0, 0, 20], [15, 0, 20]], cornerRadius: 5 });
135
146
 
136
147
  // round every corner of any CCW outline, then extrude/loft/prism it
137
- k.prism(filletPolygon(bracketOutline, 3), 4); // tessellated corners (faceted in STEP)
138
- k.prism(roundedProfile(bracketOutline, 3), 4); // true CIRCLE corners in STEP export
148
+ k.prism({ points: filletPolygon(bracketOutline, 3), h: 4 }); // tessellated corners (faceted in STEP)
149
+ k.prism({ points: roundedProfile(bracketOutline, 3), h: 4 }); // true CIRCLE corners in STEP export
139
150
  ```
140
151
 
141
152
  2-D polygon helpers for `prism`/`extrude`/`loft`: `import { piePolygon, hexPolygon,
@@ -187,9 +198,9 @@ magic vectors. Three habits:
187
198
 
188
199
  ```js
189
200
  // ✗ cryptic: which axis? what centre?
190
- k.cylinder(r, r, L).rotate(-90, [0, 0, 0], [1, 0, 0]).translate([rp, y1, sz])
201
+ k.cylinder({ r, h: L }).rotate(-90, [0, 0, 0], [1, 0, 0]).translate([rp, y1, sz])
191
202
  // ✓ legible
192
- k.cylinder(r, r, L).along("+Y").at([rp, y1, sz])
203
+ k.cylinder({ r, h: L }).along("+Y").at([rp, y1, sz])
193
204
  ```
194
205
 
195
206
  - **Rotate about a point with `rotateAbout`** when the axis isn't through the origin
@@ -226,9 +237,9 @@ a user could reasonably want to change: the base body, and each functional featu
226
237
  grooves, mounts, bores, pockets, distinct structural members.
227
238
 
228
239
  ```js
229
- const body = k.prism(d.outerPts, p.height, { scaleTop: p.taper }).label("Faceted wall");
240
+ const body = k.prism({ points: d.outerPts, h: p.height, scaleTop: p.taper }).label("Faceted wall");
230
241
  let s = body.cut(cavity.label("Cavity"));
231
- if (p.drain > 0) s = s.cut(k.cylinder(d.drainR, d.drainR, p.floor + 4).at([0, 0, -2]).label("Drainage hole"));
242
+ if (p.drain > 0) s = s.cut(k.cylinder({ r: d.drainR, h: p.floor + 4 }).at([0, 0, -2]).label("Drainage hole"));
232
243
  ```
233
244
 
234
245
  - **Aim for functional groups.** Label at the granularity a user would name a thing
@@ -430,15 +441,15 @@ Pure helpers from `partforge/geometry` (no backend dependency):
430
441
  `ringSectorPolygon(innerR,outerR,arcDeg)` (**arcDeg < 360** — a full ring is a contour-with-hole;
431
442
  cut an inner cylinder from an outer one instead).
432
443
  `circleProfile(r, center?)` — a circle of radius `r` centered at `[cx,cy]` (default origin).
433
- Compose it for round solids: `k.prism(circleProfile(r), h)` is a cylinder, and
434
- **a torus is `k.revolve(circleProfile(minorR, [majorR, 0]))`** (with `majorR > minorR`) —
444
+ Compose it for round solids: `k.prism({ points: circleProfile(r), h })` is a cylinder, and
445
+ **a torus is `k.revolve({ profile: circleProfile(minorR, [majorR, 0]) })`** (with `majorR > minorR`) —
435
446
  partforge has no `torus` primitive because it's just a revolved circle.
436
447
 
437
448
  **Patterns** (return `Solid[]` — feed to `k.union(...)` for features or `s.cutAll(...)` for holes):
438
449
  `linearPattern(solid, count, [dx,dy,dz])`, `circularPattern(solid, count, { center, axis, angle, rotateCopies })`.
439
450
 
440
451
  ```js
441
- const hole = k.cylinder(2, 2, 20).translate([20, 0, 0]);
452
+ const hole = k.cylinder({ r: 2, h: 20 }).translate([20, 0, 0]);
442
453
  body = body.cutAll(circularPattern(hole, 8, { axis: "Z" })); // 8 bolt holes on a 40mm circle
443
454
  ```
444
455
 
@@ -744,11 +755,11 @@ whole part to OCCT — no declaration needed:
744
755
 
745
756
  | Op | Meaning |
746
757
  |---|---|
747
- | `s.fillet(radius, selector?)` | round edges (curve-following, exact) |
748
- | `s.chamfer(distance, selector?)` | bevel edges |
749
- | `s.shell(thickness, openFaces)` | hollow inward, wall = `thickness`; `openFaces` selector (`{inPlane,at}`/`{dir}`/`{near}`) chooses which face(s) to open. Closed (no-open-face) hollows are not supported. |
758
+ | `s.fillet(radius)` · `s.fillet({ r, edges? })` | round edges (curve-following, exact); the bare-number scalar shorthand fillets **all** edges, the options form adds a selector |
759
+ | `s.chamfer(distance)` · `s.chamfer({ d, edges? })` | bevel edges; same scalar-shorthand-or-options-with-selector shape as `fillet` |
760
+ | `s.shell({ t, open })` | hollow inward, wall = `t`; `open` selector (`{inPlane,at}`/`{dir}`/`{near}`) chooses which face(s) to open. Closed (no-open-face) hollows are not supported. |
750
761
 
751
- `selector` chooses which edges (omit it for **all** edges):
762
+ `edges` (fillet/chamfer) / `open` (shell) chooses which edges/faces (omit `edges` for **all** edges — `shell` always requires `open`):
752
763
 
753
764
  - `{ dir: "X"|"Y"|"Z" }` — edges running along an axis (e.g. `{dir:"Z"}` = the vertical edges)
754
765
  - `{ inPlane: "XY"|"XZ"|"YZ", at }` — edges lying in a plane (e.g. base edges: `{inPlane:"XY", at:0}`)
@@ -758,9 +769,9 @@ whole part to OCCT — no declaration needed:
758
769
  (parts meant to travel must use the object forms — see `KERNEL-CONTRACT.md`)
759
770
 
760
771
  ```js
761
- let s = k.box([0,0,0],[40,30,16]);
762
- s = s.fillet(3, { dir: "Z" }); // round the 4 vertical edges
763
- s = s.chamfer(1, { inPlane: "XY", at: 0 }); // bevel the base
772
+ let s = k.box({ min: [0, 0, 0], max: [40, 30, 16] });
773
+ s = s.fillet({ r: 3, edges: { dir: "Z" } }); // round the 4 vertical edges
774
+ s = s.chamfer({ d: 1, edges: { inPlane: "XY", at: 0 } }); // bevel the base
764
775
  ```
765
776
 
766
777
  See `src/parts/filleted-box.js` for the worked example.
@@ -131,7 +131,7 @@ The framework itself rebuilds each sub-part fresh per job and applies `place` on
131
131
 
132
132
  - **Symptom:** `ringSectorPolygon: arcDeg must be < 360 (use a cut for a full ring)`
133
133
  - **Cause:** A full annulus can't be a single simple polygon — it's a contour-with-hole.
134
- - **Fix:** Cut an inner cylinder from an outer one (or `k.extrude({ outer, holes })`); use `ringSectorPolygon` only for partial arcs. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Profiles & patterns".
134
+ - **Fix:** Cut an inner cylinder from an outer one (or `k.extrude({ profile: { outer, holes }, h })`); use `ringSectorPolygon` only for partial arcs. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Profiles & patterns".
135
135
 
136
136
  ## occt-closed-loop-unsupported
137
137
 
@@ -163,6 +163,38 @@ The framework itself rebuilds each sub-part fresh per job and applies `place` on
163
163
  - **Cause:** Only pages listed in `build.rollupOptions.input` are compiled by the production build; other root `*.html` pages are dev-only conveniences Vite serves without building.
164
164
  - **Fix:** Add the page to `build.rollupOptions.input` in `vite.config.js` if it should ship. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Wiring a part into a runnable app".
165
165
 
166
+ ## options-unknown-key
167
+
168
+ - **Symptom:** `unknown option` — e.g. `cylinder: unknown option "radius" — did you mean r?`
169
+ - **Cause:** an options-form kernel call passed a key the op does not accept (typo, or long-form vocabulary like `radius`/`height`).
170
+ - **Fix:** use the canonical keys from the op table in [AUTHORING-PARTS.md](AUTHORING-PARTS.md); the error's did-you-mean / valid-keys hint names them.
171
+
172
+ ## options-missing-key
173
+
174
+ - **Symptom:** `is required` — e.g. `cylinder: h is required`, `sweep: path is required`.
175
+ - **Cause:** an options-form kernel call omitted a required key.
176
+ - **Fix:** supply the key; canonical forms are in the [AUTHORING-PARTS.md](AUTHORING-PARTS.md) op table and KERNEL-CONTRACT.md "Calling convention".
177
+
178
+ ## cylinder-radius-keys
179
+
180
+ - **Symptom:** `cylinder: pass exactly one of r/d, or r1+r2 / d1+d2`
181
+ - **Cause:** mixed or missing radius vocabulary — both `r` and `d`, straight + cone keys together, only one cone end, or `r1`+`d2`.
182
+ - **Fix:** straight cylinders take one of `r`|`d` plus `h`; cones take `r1`+`r2` or `d1`+`d2` plus `h`.
183
+
184
+ The sphere variant is `sphere: pass exactly one of r/d` (same cause and fix).
185
+
186
+ ## box-size-vs-corners
187
+
188
+ - **Symptom:** `box: pass size or min+max, not both`
189
+ - **Cause:** the two `box` forms were mixed in one call.
190
+ - **Fix:** either `{size, center?}` (centered in X/Y, base at z=0; `center:true` centers Z too) or `{min, max}` — see [AUTHORING-PARTS.md](AUTHORING-PARTS.md).
191
+
192
+ ## box-center-with-corners
193
+
194
+ - **Symptom:** `box: center only applies to the size form`
195
+ - **Cause:** `center` was passed alongside `min`/`max`, but explicit corners already fix the placement.
196
+ - **Fix:** drop `center`, or switch to `{size, center?}` — see [AUTHORING-PARTS.md](AUTHORING-PARTS.md).
197
+
166
198
  # Hardware library
167
199
 
168
200
  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.12.0",
3
+ "version": "0.13.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",
@@ -7,120 +7,120 @@
7
7
  * { box-sizing: border-box; }
8
8
  html, body { margin: 0; height: 100%; overflow: hidden;
9
9
  font: 13px/1.4 -apple-system, system-ui, sans-serif; }
10
- #app { position: fixed; inset: 0; background: var(--bg); }
10
+ #app { position: fixed; inset: 0; background: var(--pf-bg); }
11
11
  canvas { display: block; }
12
12
 
13
13
  #panel {
14
14
  position: fixed; top: 12px; left: 12px; width: 256px;
15
15
  max-height: calc(100vh - 24px); overflow-y: auto; z-index: 10;
16
- background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
17
- padding: 14px; color: var(--text); box-shadow: 0 6px 24px rgba(0,0,0,.35);
16
+ background: var(--pf-surface); border: 1px solid var(--pf-border); border-radius: 10px;
17
+ padding: 14px; color: var(--pf-text); box-shadow: 0 6px 24px rgba(0,0,0,.35);
18
18
  }
19
- #panel h1 { font-size: 14px; margin: 0 0 2px; color: var(--text-strong); letter-spacing: -0.01em; }
19
+ #panel h1 { font-size: 14px; margin: 0 0 2px; color: var(--pf-text-strong); letter-spacing: -0.01em; }
20
20
  #panel .sub {
21
- font-family: var(--mono); color: var(--muted); font-size: 10px;
21
+ font-family: var(--pf-mono); color: var(--pf-muted); font-size: 10px;
22
22
  letter-spacing: 0.04em; text-transform: uppercase;
23
- margin: 0 0 12px; padding-bottom: 12px; border-bottom: 1px solid var(--border);
23
+ margin: 0 0 12px; padding-bottom: 12px; border-bottom: 1px solid var(--pf-border);
24
24
  }
25
25
 
26
26
  .seg { display: flex; gap: 4px; margin-bottom: 12px; }
27
27
  .seg button {
28
- flex: 1; padding: 7px 0; border: 1px solid var(--border); border-radius: 7px;
29
- background: var(--surface-2); color: var(--muted-2); cursor: pointer;
30
- font-family: var(--mono); font-size: 11px; letter-spacing: 0.02em;
28
+ flex: 1; padding: 7px 0; border: 1px solid var(--pf-border); border-radius: 7px;
29
+ background: var(--pf-surface-2); color: var(--pf-muted-2); cursor: pointer;
30
+ font-family: var(--pf-mono); font-size: 11px; letter-spacing: 0.02em;
31
31
  }
32
- .seg button.on { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
32
+ .seg button.on { background: var(--pf-accent); color: var(--pf-on-accent); border-color: var(--pf-accent); }
33
33
 
34
34
  .section {
35
- border: 1px solid var(--border); border-radius: 8px; padding: 10px;
36
- margin-bottom: 8px; background: var(--surface-2);
35
+ border: 1px solid var(--pf-border); border-radius: 8px; padding: 10px;
36
+ margin-bottom: 8px; background: var(--pf-surface-2);
37
37
  }
38
38
  .sec-title {
39
- font-family: var(--mono); font-size: 10px; font-weight: 600;
40
- letter-spacing: 0.14em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 9px;
39
+ font-family: var(--pf-mono); font-size: 10px; font-weight: 600;
40
+ letter-spacing: 0.14em; text-transform: uppercase; color: var(--pf-muted-2); margin-bottom: 9px;
41
41
  }
42
42
  select.preset {
43
- width: 100%; background: var(--input-bg); color: var(--text-2);
44
- border: 1px solid var(--border); border-radius: 6px; padding: 6px 8px;
45
- font-family: var(--mono); font-size: 11px;
43
+ width: 100%; background: var(--pf-input-bg); color: var(--pf-text-2);
44
+ border: 1px solid var(--pf-border); border-radius: 6px; padding: 6px 8px;
45
+ font-family: var(--pf-mono); font-size: 11px;
46
46
  }
47
47
  .feat { display: flex; align-items: center; gap: 8px; margin: 6px 0;
48
- color: var(--text-2); cursor: pointer; }
49
- .feat input { cursor: pointer; accent-color: var(--accent); }
50
- .feat-group { margin: 2px 0 8px; padding-left: 10px; border-left: 2px solid var(--border); }
48
+ color: var(--pf-text-2); cursor: pointer; }
49
+ .feat input { cursor: pointer; accent-color: var(--pf-accent); }
50
+ .feat-group { margin: 2px 0 8px; padding-left: 10px; border-left: 2px solid var(--pf-border); }
51
51
  .feat-group.hidden { display: none; }
52
52
  .adv-toggle {
53
53
  margin-top: 8px; padding: 4px 0; width: 100%; border: 0; border-radius: 5px;
54
- background: transparent; color: var(--muted); cursor: pointer;
55
- font-family: var(--mono); font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; text-align: left;
54
+ background: transparent; color: var(--pf-muted); cursor: pointer;
55
+ font-family: var(--pf-mono); font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; text-align: left;
56
56
  }
57
- .adv-toggle:hover { color: var(--muted-2); }
57
+ .adv-toggle:hover { color: var(--pf-muted-2); }
58
58
  .adv.hidden { display: none; }
59
59
  .adv { margin-top: 4px; }
60
60
 
61
61
  .slider { margin: 9px 0; }
62
62
  .row { display: flex; justify-content: space-between; align-items: center;
63
63
  margin: 0 0 4px; gap: 8px; }
64
- .row label { font-family: var(--mono); font-size: 11px; color: var(--muted-2); letter-spacing: 0.01em; }
64
+ .row label { font-family: var(--pf-mono); font-size: 11px; color: var(--pf-muted-2); letter-spacing: 0.01em; }
65
65
  .row .val { display: flex; align-items: baseline; gap: 4px; flex: none; }
66
66
  .row .num {
67
- width: 54px; text-align: right; font-family: var(--mono); font-size: 12px; font-variant-numeric: tabular-nums;
68
- background: var(--input-bg); color: var(--text-strong);
69
- border: 1px solid var(--border); border-radius: 5px; padding: 3px 6px;
67
+ width: 54px; text-align: right; font-family: var(--pf-mono); font-size: 12px; font-variant-numeric: tabular-nums;
68
+ background: var(--pf-input-bg); color: var(--pf-text-strong);
69
+ border: 1px solid var(--pf-border); border-radius: 5px; padding: 3px 6px;
70
70
  }
71
- .row .num:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
71
+ .row .num:focus { outline: none; border-color: var(--pf-accent); box-shadow: 0 0 0 3px var(--pf-accent-soft); }
72
72
  .row .num::-webkit-outer-spin-button, .row .num::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
73
73
  .row .num { -moz-appearance: textfield; }
74
- .row .unit { font-family: var(--mono); color: var(--muted); font-size: 10px; }
74
+ .row .unit { font-family: var(--pf-mono); color: var(--pf-muted); font-size: 10px; }
75
75
 
76
76
  /* crafted range slider — hairline track + CAD-blue handle (the panel's signature control) */
77
77
  input[type="range"] { -webkit-appearance: none; appearance: none; width: 100%; height: 18px; margin: 0; background: transparent; cursor: pointer; }
78
- input[type="range"]::-webkit-slider-runnable-track { height: 3px; border-radius: 2px; background: var(--border); }
79
- input[type="range"]::-moz-range-track { height: 3px; border-radius: 2px; background: var(--border); }
80
- input[type="range"]::-moz-range-progress { height: 3px; border-radius: 2px; background: var(--accent); }
78
+ input[type="range"]::-webkit-slider-runnable-track { height: 3px; border-radius: 2px; background: var(--pf-border); }
79
+ input[type="range"]::-moz-range-track { height: 3px; border-radius: 2px; background: var(--pf-border); }
80
+ input[type="range"]::-moz-range-progress { height: 3px; border-radius: 2px; background: var(--pf-accent); }
81
81
  input[type="range"]::-webkit-slider-thumb {
82
82
  -webkit-appearance: none; appearance: none; width: 14px; height: 14px; margin-top: -5.5px;
83
- border-radius: 50%; background: var(--accent); border: 2px solid var(--surface-2); box-shadow: 0 0 0 1px var(--accent);
83
+ border-radius: 50%; background: var(--pf-accent); border: 2px solid var(--pf-surface-2); box-shadow: 0 0 0 1px var(--pf-accent);
84
84
  transition: box-shadow .12s ease;
85
85
  }
86
86
  input[type="range"]::-moz-range-thumb {
87
- width: 14px; height: 14px; border-radius: 50%; background: var(--accent);
88
- border: 2px solid var(--surface-2); box-shadow: 0 0 0 1px var(--accent);
87
+ width: 14px; height: 14px; border-radius: 50%; background: var(--pf-accent);
88
+ border: 2px solid var(--pf-surface-2); box-shadow: 0 0 0 1px var(--pf-accent);
89
89
  }
90
- input[type="range"]:hover::-webkit-slider-thumb { box-shadow: 0 0 0 5px var(--accent-soft); }
90
+ input[type="range"]:hover::-webkit-slider-thumb { box-shadow: 0 0 0 5px var(--pf-accent-soft); }
91
91
  input[type="range"]:focus-visible { outline: none; }
92
- input[type="range"]:focus-visible::-webkit-slider-thumb { box-shadow: 0 0 0 5px var(--accent-soft); }
93
- input[type="range"]:focus-visible::-moz-range-thumb { box-shadow: 0 0 0 5px var(--accent-soft); }
92
+ input[type="range"]:focus-visible::-webkit-slider-thumb { box-shadow: 0 0 0 5px var(--pf-accent-soft); }
93
+ input[type="range"]:focus-visible::-moz-range-thumb { box-shadow: 0 0 0 5px var(--pf-accent-soft); }
94
94
 
95
95
  button.action {
96
96
  width: 100%; margin-top: 8px; padding: 9px; border: 0; border-radius: 7px;
97
- background: var(--accent); color: var(--on-accent); font-weight: 600; cursor: pointer;
97
+ background: var(--pf-accent); color: var(--pf-on-accent); font-weight: 600; cursor: pointer;
98
98
  }
99
- button.ghost { background: var(--border); color: var(--text-2); font-weight: 500; }
99
+ button.ghost { background: var(--pf-border); color: var(--pf-text-2); font-weight: 500; }
100
100
  button.action:disabled { opacity: .5; cursor: default; }
101
101
 
102
102
  .dl { margin-top: 14px; }
103
103
  .dl-head {
104
- font-family: var(--mono); font-size: 10px; font-weight: 600;
105
- letter-spacing: 0.14em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 7px;
104
+ font-family: var(--pf-mono); font-size: 10px; font-weight: 600;
105
+ letter-spacing: 0.14em; text-transform: uppercase; color: var(--pf-muted-2); margin-bottom: 7px;
106
106
  }
107
107
  .dl-row { display: flex; gap: 6px; }
108
108
  .dl-row button {
109
- flex: 1; padding: 8px 0; border: 1px solid var(--border); border-radius: 7px;
110
- background: var(--surface-2); color: var(--text-2);
111
- font-family: var(--mono); font-weight: 600; font-size: 11px; letter-spacing: 0.06em; cursor: pointer;
109
+ flex: 1; padding: 8px 0; border: 1px solid var(--pf-border); border-radius: 7px;
110
+ background: var(--pf-surface-2); color: var(--pf-text-2);
111
+ font-family: var(--pf-mono); font-weight: 600; font-size: 11px; letter-spacing: 0.06em; cursor: pointer;
112
112
  }
113
- .dl-row button:hover:not(:disabled) { border-color: var(--accent); color: var(--text-strong); }
113
+ .dl-row button:hover:not(:disabled) { border-color: var(--pf-accent); color: var(--pf-text-strong); }
114
114
  .dl-row button:disabled { opacity: .45; cursor: default; }
115
- #status { font-family: var(--mono); margin-top: 12px; min-height: 16px; color: var(--status);
115
+ #status { font-family: var(--pf-mono); margin-top: 12px; min-height: 16px; color: var(--pf-status);
116
116
  font-size: 11px; font-variant-numeric: tabular-nums; }
117
- #status.err { color: var(--err); }
118
- .hint { font-family: var(--mono); margin-top: 8px; color: var(--hint); font-size: 10px; letter-spacing: 0.02em; }
117
+ #status.err { color: var(--pf-err); }
118
+ .hint { font-family: var(--pf-mono); margin-top: 8px; color: var(--pf-hint); font-size: 10px; letter-spacing: 0.02em; }
119
119
 
120
120
  /* keyboard focus ring shared across the panel's interactive controls */
121
121
  .seg button:focus-visible, select.preset:focus-visible, .dl-row button:focus-visible,
122
122
  button.action:focus-visible, .adv-toggle:focus-visible, #viewbar button:focus-visible {
123
- outline: 2px solid var(--accent); outline-offset: 2px;
123
+ outline: 2px solid var(--pf-accent); outline-offset: 2px;
124
124
  }
125
125
 
126
126
  /* part tabs, floated top-centre over the viewport */
@@ -129,7 +129,7 @@ button.action:focus-visible, .adv-toggle:focus-visible, #viewbar button:focus-vi
129
129
  z-index: 15;
130
130
  }
131
131
  #topbar .seg {
132
- margin: 0; padding: 4px; background: var(--surface); border: 1px solid var(--border);
132
+ margin: 0; padding: 4px; background: var(--pf-surface); border: 1px solid var(--pf-border);
133
133
  border-radius: 9px; box-shadow: 0 6px 24px rgba(0,0,0,.35);
134
134
  }
135
135
  #topbar .seg button { min-width: 70px; padding: 7px 10px; }
@@ -138,17 +138,17 @@ button.action:focus-visible, .adv-toggle:focus-visible, #viewbar button:focus-vi
138
138
  #viewbar {
139
139
  position: fixed; top: 12px; right: 12px; z-index: 15;
140
140
  display: flex; gap: 4px; padding: 4px;
141
- background: var(--surface); border: 1px solid var(--border);
141
+ background: var(--pf-surface); border: 1px solid var(--pf-border);
142
142
  border-radius: 9px; box-shadow: 0 6px 24px rgba(0,0,0,.35);
143
143
  }
144
144
  #viewbar button {
145
- width: 34px; height: 34px; border: 1px solid var(--border); border-radius: 7px;
146
- background: var(--surface-2); color: var(--muted-2); cursor: pointer;
145
+ width: 34px; height: 34px; border: 1px solid var(--pf-border); border-radius: 7px;
146
+ background: var(--pf-surface-2); color: var(--pf-muted-2); cursor: pointer;
147
147
  font-size: 15px; line-height: 1;
148
148
  display: flex; align-items: center; justify-content: center;
149
149
  }
150
- #viewbar button:hover { color: var(--text); }
151
- #viewbar button.on { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
150
+ #viewbar button:hover { color: var(--pf-text); }
151
+ #viewbar button.on { background: var(--pf-accent); color: var(--pf-on-accent); border-color: var(--pf-accent); }
152
152
 
153
153
  #busy {
154
154
  position: fixed; inset: 0; z-index: 20; pointer-events: none;
@@ -158,30 +158,30 @@ button.action:focus-visible, .adv-toggle:focus-visible, #viewbar button:focus-vi
158
158
  #busy.show { display: flex; }
159
159
  #busy .ring {
160
160
  width: 46px; height: 46px; border-radius: 50%;
161
- border: 4px solid var(--border); border-top-color: var(--accent);
161
+ border: 4px solid var(--pf-border); border-top-color: var(--pf-accent);
162
162
  animation: spin 0.9s linear infinite;
163
163
  }
164
- #busy .phase { color: var(--text); font-size: 13px; text-shadow: 0 1px 6px rgba(0,0,0,.6); }
164
+ #busy .phase { color: var(--pf-text); font-size: 13px; text-shadow: 0 1px 6px rgba(0,0,0,.6); }
165
165
  @keyframes spin { to { transform: rotate(360deg); } }
166
166
 
167
167
  /* info glyph + description popover */
168
168
  .info {
169
169
  appearance: none; border: none; background: none; cursor: pointer;
170
- color: var(--muted); font-size: 12px; line-height: 1; padding: 0 0 0 5px;
170
+ color: var(--pf-muted); font-size: 12px; line-height: 1; padding: 0 0 0 5px;
171
171
  vertical-align: middle;
172
172
  }
173
- .info:hover { color: var(--text-2); }
174
- .info:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; }
173
+ .info:hover { color: var(--pf-text-2); }
174
+ .info:focus-visible { outline: 2px solid var(--pf-accent); outline-offset: 2px; border-radius: 3px; }
175
175
  .popover {
176
176
  position: fixed; z-index: 50; max-width: 280px; max-height: 50vh; overflow: auto;
177
- background: var(--surface); color: var(--text); border: 1px solid var(--border);
177
+ background: var(--pf-surface); color: var(--pf-text); border: 1px solid var(--pf-border);
178
178
  border-radius: 8px; padding: 10px 12px; box-shadow: 0 6px 24px rgba(0,0,0,.35);
179
179
  font-size: 12px; line-height: 1.5;
180
180
  }
181
181
  .popover[hidden] { display: none; }
182
182
  .popover img { max-width: 100%; height: auto; border-radius: 4px; }
183
- .popover code { font-family: var(--mono); background: var(--surface-2); padding: 0.05em 0.35em; border-radius: 4px; font-size: 0.9em; }
184
- .popover a { color: var(--accent); }
183
+ .popover code { font-family: var(--pf-mono); background: var(--pf-surface-2); padding: 0.05em 0.35em; border-radius: 4px; font-size: 0.9em; }
184
+ .popover a { color: var(--pf-accent); }
185
185
  .popover p:first-child { margin-top: 0; }
186
186
  .popover p:last-child { margin-bottom: 0; }
187
187
 
@@ -194,12 +194,12 @@ button.action:focus-visible, .adv-toggle:focus-visible, #viewbar button:focus-vi
194
194
  #pf-hover-tip {
195
195
  position: fixed; z-index: 30; pointer-events: none; display: none;
196
196
  padding: 4px 9px; border-radius: 6px; max-width: 260px;
197
- background: var(--surface); border: 1px solid var(--border);
198
- color: var(--text-strong); font-size: 12px; line-height: 1.35;
197
+ background: var(--pf-surface); border: 1px solid var(--pf-border);
198
+ color: var(--pf-text-strong); font-size: 12px; line-height: 1.35;
199
199
  box-shadow: 0 2px 10px rgba(0, 0, 0, .25);
200
200
  }
201
201
  #pf-hover-tip.show { display: block; }
202
- #pf-hover-tip .pf-hover-sub { color: var(--muted); font-size: 11px; margin-left: 7px; }
202
+ #pf-hover-tip .pf-hover-sub { color: var(--pf-muted); font-size: 11px; margin-left: 7px; }
203
203
  #pf-hover-tip .pf-hover-sub:empty { display: none; }
204
204
 
205
205
  /* request-a-pick: agent prompt banner, floated top-centre well below the part tabs,
@@ -208,8 +208,8 @@ button.action:focus-visible, .adv-toggle:focus-visible, #viewbar button:focus-vi
208
208
  position: fixed; top: 78px; left: 50%; transform: translateX(-50%);
209
209
  z-index: 30; max-width: min(56ch, calc(100vw - 24px));
210
210
  padding: 10px 36px 10px 12px;
211
- background: var(--surface); color: var(--text);
212
- border: 1px solid var(--border); border-radius: 10px;
211
+ background: var(--pf-surface); color: var(--pf-text);
212
+ border: 1px solid var(--pf-border); border-radius: 10px;
213
213
  box-shadow: 0 6px 24px rgba(0,0,0,.35);
214
214
  font-size: 12px; line-height: 1.45;
215
215
  animation: pf-pick-in .18s ease;
@@ -218,20 +218,20 @@ button.action:focus-visible, .adv-toggle:focus-visible, #viewbar button:focus-vi
218
218
  #pf-pick-banner .pf-pick-avatar {
219
219
  flex: none; width: 30px; height: 30px; border-radius: 50%; font-size: 17px;
220
220
  display: flex; align-items: center; justify-content: center;
221
- background: var(--surface-2); border: 1px solid var(--border);
221
+ background: var(--pf-surface-2); border: 1px solid var(--pf-border);
222
222
  }
223
223
  #pf-pick-banner .pf-pick-msg { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
224
- #pf-pick-banner .pf-pick-label { color: var(--muted); font-size: 11px; }
225
- #pf-pick-banner .pf-pick-prompt { color: var(--text-strong); font-weight: 600; }
224
+ #pf-pick-banner .pf-pick-label { color: var(--pf-muted); font-size: 11px; }
225
+ #pf-pick-banner .pf-pick-prompt { color: var(--pf-text-strong); font-weight: 600; }
226
226
  #pf-pick-close {
227
227
  position: absolute; top: 6px; right: 6px; appearance: none;
228
228
  width: 20px; height: 20px; padding: 0;
229
- border: 1px solid var(--border); border-radius: 6px;
230
- background: var(--surface-2); color: var(--muted);
229
+ border: 1px solid var(--pf-border); border-radius: 6px;
230
+ background: var(--pf-surface-2); color: var(--pf-muted);
231
231
  cursor: pointer; font-size: 13px; line-height: 1;
232
232
  display: flex; align-items: center; justify-content: center;
233
233
  }
234
- #pf-pick-close:hover { color: var(--text-strong); border-color: var(--accent); }
234
+ #pf-pick-close:hover { color: var(--pf-text-strong); border-color: var(--pf-accent); }
235
235
  @keyframes pf-pick-in {
236
236
  from { opacity: 0; transform: translateX(-50%) translateY(-10px); }
237
237
  to { opacity: 1; transform: translateX(-50%) translateY(0); }
@@ -1,36 +1,35 @@
1
1
  // The backend-shared kernel front. Each backend builds its primitive mapping and
2
2
  // returns finishKernel(kernel), which layers on everything that is NOT
3
3
  // backend-specific:
4
- // - argument validation (previously copy-pasted into both backends);
4
+ // - the options-object calling convention (op-options.js): one wrapper per
5
+ // factory op normalizes an options-form call to positional args, then runs
6
+ // the op's semantic check on the normalized args (both calling forms), then
7
+ // calls the raw backend op — so backends stay positional and the solid
8
+ // cache hashes normalized args;
5
9
  // - default compound-op compositions — a backend only overrides one when it has
6
10
  // a reason to (Manifold's boredCylinder hashes atomically for its solid cache);
7
11
  // - a KernelCapabilityError stub for toSTEP when the backend can't write B-rep.
8
12
  // The per-Solid twin of this layer is addSugar() in solid-sugar.js.
9
13
  import { KernelCapabilityError } from "./errors.js";
14
+ import { isPlainOptions, KERNEL_OP_SPECS } from "./op-options.js";
10
15
 
11
16
  export function finishKernel(k) {
12
- const rawPrism = k.prism;
13
- k.prism = (pts, h, opts) => {
14
- if ((opts?.scaleTop ?? 1) < 0) throw new Error("prism: scaleTop must be ≥ 0");
15
- return rawPrism(pts, h, opts);
16
- };
17
-
18
- const rawExtrude = k.extrude;
19
- k.extrude = (profile, h, opts) => {
20
- if ((opts?.scaleTop ?? 1) < 0) throw new Error("extrude: scaleTop must be ≥ 0");
21
- return rawExtrude(profile, h, opts);
22
- };
23
-
24
- const rawRevolve = k.revolve;
25
- k.revolve = (pts, opts) => {
26
- for (const [r] of pts) if (r < 0) throw new Error("revolve: profile radius must be ≥ 0");
27
- return rawRevolve(pts, opts);
28
- };
29
-
30
- // Compound: bored-through cylinder (tool overshoots 2 mm each end for a clean cut).
17
+ // Compound default: bored-through cylinder (tool overshoots 2 mm each end for
18
+ // a clean cut). Assigned BEFORE the wrap loop so the fallback composition gets
19
+ // the same key validation as a backend-native override.
31
20
  k.boredCylinder ??= ({ od, h, bore }) =>
32
21
  k.cylinder(od / 2, od / 2, h).cut(k.cylinder(bore / 2, bore / 2, h + 4).translate([0, 0, -2]));
33
22
 
23
+ for (const [op, { toArgs, check }] of Object.entries(KERNEL_OP_SPECS)) {
24
+ const raw = k[op];
25
+ if (!raw) continue;
26
+ k[op] = (...a) => {
27
+ const pos = a.length === 1 && isPlainOptions(a[0]) ? toArgs(a[0]) : a;
28
+ check?.(...pos);
29
+ return raw(...pos);
30
+ };
31
+ }
32
+
34
33
  k.toSTEP ??= () => { throw new KernelCapabilityError("toSTEP requires the OCCT backend"); };
35
34
 
36
35
  return k;
@@ -2,10 +2,14 @@
2
2
  // parity tests (test/kernel-contract.test.js and the OCCT twin in
3
3
  // test/occt-backend.test.js) assert each backend exposes exactly these ops, so the
4
4
  // contract can't silently drift from the implementations — the drift class that
5
- // once broke the probe kernel (see probe.js). The @typedefs document signatures.
6
- // The prose half of the contract conventions, value semantics, conformance
7
- // classes, versioning policy is docs/KERNEL-CONTRACT.md; change either side and
8
- // you must update the other. (2-D polygon helpers live in ./polygon.js.)
5
+ // once broke the probe kernel (see probe.js). The @typedefs document signatures,
6
+ // options-object form first (the canonical calling convention normalizers and
7
+ // exact valid-key lists live in op-options.js, wired in at kernel-front.js
8
+ // (finishKernel) and solid-sugar.js (addSugar); legacy positional forms stay
9
+ // silently accepted until contract v2). The prose half of the contract —
10
+ // conventions, value semantics, conformance classes, versioning policy — is
11
+ // docs/KERNEL-CONTRACT.md; change either side and you must update the other.
12
+ // (2-D polygon helpers live in ./polygon.js.)
9
13
 
10
14
  // The prose half's version: docs/KERNEL-CONTRACT.md's "Contract version" header
11
15
  // must match this number (asserted in kernel-contract.test.js). Bump only on a
@@ -65,22 +69,22 @@ export const OCCT_ONLY_OPS = ["fillet", "chamfer", "shell"];
65
69
  * `edges` = feature-edge line segments (Manifold); quality is advisory — the Manifold kernel bakes it at creation
66
70
  * @property {(opts?: {quality?: "preview"|"print"}) => Promise<ArrayBuffer>} toSTL
67
71
  * @property {() => {positions:Float32Array, indices:Uint32Array}} toIndexedMesh indexed mesh, for 3MF
68
- * @property {(radius:number, selector?:object) => Solid} fillet round edges (OCCT only; Manifold throws KernelCapabilityError)
69
- * @property {(distance:number, selector?:object) => Solid} chamfer bevel edges (OCCT only; Manifold throws KernelCapabilityError)
70
- * @property {(thickness:number, openFaces:object) => Solid} shell hollow inward (OCCT only); openFaces selector required
72
+ * @property {(r:number|{r:number,edges?:object}) => Solid} fillet round edges (OCCT only); fillet(3) or fillet({r,edges}); legacy (r,selector) accepted until v2
73
+ * @property {(d:number|{d:number,edges?:object}) => Solid} chamfer bevel edges (OCCT only); chamfer(1) or chamfer({d,edges}); legacy (d,selector) accepted until v2
74
+ * @property {(o:{t:number,open:object}) => Solid} shell hollow inward (OCCT only); legacy (thickness,openFaces) accepted until v2
71
75
  * @property {() => number} [genus] through-hole count (Manifold only)
72
76
  * @property {() => boolean} [isEmpty] no geometry at all (Manifold only)
73
77
  *
74
78
  * @typedef {Object} GeometryKernel
75
- * @property {(rBottom:number, rTop:number, h:number, opts?:{center?:boolean}) => Solid} cylinder
79
+ * @property {(o:{r?:number,d?:number,r1?:number,r2?:number,d1?:number,d2?:number,h:number,center?:boolean}) => Solid} cylinder canonical: {r|d,h} straight, {r1,r2,h}|{d1,d2,h} cone; legacy (rBottom,rTop,h,opts) accepted until contract v2
76
80
  * @property {(o:{od:number,h:number,bore:number}) => Solid} boredCylinder compound: bored-through cylinder (one cache node)
77
- * @property {(r:number) => Solid} sphere sphere centred at the origin
78
- * @property {(min:number[], max:number[]) => Solid} box
79
- * @property {(points2D:number[][], h:number, opts?:{twist?:number,scaleTop?:number}) => Solid} prism extrude polygon from z=0 (optional twist° + uniform top taper)
80
- * @property {(profile:number[][]|{outer:number[][],holes?:number[][][]}, h:number, opts?:{twist?:number,scaleTop?:number}) => Solid} extrude extrude a polygon-with-holes region from z=0 in one op (bare array = outer only)
81
- * @property {(rings:{polygon?:number[][],sides?:number,radius?:number,z:number,rotate?:number,scale?:number|number[]}[], opts?:{ruled?:boolean,closed?:boolean}) => Solid} loft stack polygon cross-sections (per-ring z/rotate/scale), ruled walls, capped ends
82
- * @property {(profile2D:number[][], path3D:number[][], opts?:{closed?:boolean,cornerRadius?:number,ruled?:boolean,smooth?:boolean}) => Solid} sweep sweep a fixed 2-D profile along a 3-D polyline path (sharp mitered corners or cornerRadius fillets; capped ends; closed:true loops and smooth:true native B-rep are backend-specific)
83
- * @property {(points2D:number[][], opts?:{degrees?:number}) => Solid} revolve revolve a lathe profile [[r,z],…] around Z
81
+ * @property {(o:{r?:number,d?:number}) => Solid} sphere sphere centred at the origin; {r|d}; bare sphere(r) stays valid
82
+ * @property {(o:{size?:number[],center?:boolean,min?:number[],max?:number[]}) => Solid} box {size} = centered X/Y, base z=0 ({center:true} centers Z too) or {min,max}; legacy (min,max) accepted until v2
83
+ * @property {(o:{points:number[][],h:number,twist?:number,scaleTop?:number}) => Solid} prism extrude polygon from z=0; legacy (points,h,opts) accepted until v2
84
+ * @property {(o:{profile:number[][]|{outer:number[][],holes?:number[][][]},h:number,twist?:number,scaleTop?:number}) => Solid} extrude polygon-with-holes region from z=0; legacy (profile,h,opts) accepted until v2
85
+ * @property {(o:{rings:{polygon?:number[][],sides?:number,radius?:number,z:number,rotate?:number,scale?:number|number[]}[],ruled?:boolean,closed?:boolean}) => Solid} loft stack polygon cross-sections; legacy (rings,opts) accepted until v2
86
+ * @property {(o:{profile:number[][],path:number[][],closed?:boolean,cornerRadius?:number,ruled?:boolean,smooth?:boolean}) => Solid} sweep sweep a 2-D profile along a 3-D polyline; legacy (profile,path,opts) accepted until v2
87
+ * @property {(o:{profile:number[][],degrees?:number}) => Solid} revolve revolve a lathe profile [[r,z],…] around Z; legacy (points,opts) accepted until v2
84
88
  * @property {(o:{pathR:number,profileR:number,pitch:number,turns:number,z0:number,lefthand:boolean}) => Solid} helixSweptTube
85
89
  * @property {(solids:Solid[]) => Solid} union
86
90
  * @property {(named:{name:string,solid:Solid}[]) => Promise<ArrayBuffer>} toSTEP OCCT only (Manifold throws KernelCapabilityError)
@@ -0,0 +1,167 @@
1
+ // src/framework/geometry/op-options.js
2
+ // The options-object calling convention: pure normalizers turning each op's
3
+ // canonical options form into the backend's positional argument list, plus the
4
+ // detection predicate. Normative rule (KERNEL-CONTRACT.md "Calling convention"):
5
+ // a call is options form when the op receives exactly one plain-object argument.
6
+ // kernel-front.js and solid-sugar.js apply these at the backend-shared seams, so
7
+ // backends stay positional and the Manifold solid cache hashes normalized args —
8
+ // both spellings of a call share one cache entry. Geometry-free by design.
9
+
10
+ export function isPlainOptions(x) {
11
+ if (x === null || typeof x !== "object") return false;
12
+ const proto = Object.getPrototypeOf(x);
13
+ return proto === Object.prototype || proto === null;
14
+ }
15
+
16
+ // Small capped Levenshtein for did-you-mean hints (distance > 2 reads as "no").
17
+ function editDistance(a, b) {
18
+ if (Math.abs(a.length - b.length) > 2) return 3;
19
+ const dp = Array.from({ length: a.length + 1 }, (_, i) => [i]);
20
+ for (let j = 1; j <= b.length; j++) dp[0][j] = j;
21
+ for (let i = 1; i <= a.length; i++)
22
+ for (let j = 1; j <= b.length; j++)
23
+ dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1,
24
+ dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
25
+ return dp[a.length][b.length];
26
+ }
27
+
28
+ // Prefix match first so long-form names hit their short key (radius→r,
29
+ // height→h, diameter→d), then edit distance ≤ 2 for plain typos. A digit
30
+ // suffix is peeled and re-attached so radius1 hints r1, not r.
31
+ function suggest(key, valid) {
32
+ const lk = key.toLowerCase();
33
+ const m = /^([a-z]+)(\d+)$/.exec(lk);
34
+ if (m) for (const v of valid) if (m[1].startsWith(v.toLowerCase()) && valid.includes(v + m[2])) return v + m[2];
35
+ for (const v of valid) if (lk.startsWith(v.toLowerCase())) return v;
36
+ for (const v of valid) if (editDistance(lk, v.toLowerCase()) <= 2) return v;
37
+ return null;
38
+ }
39
+
40
+ function checkKeys(op, o, valid) {
41
+ for (const key of Object.keys(o)) {
42
+ if (valid.includes(key)) continue;
43
+ const hint = suggest(key, valid);
44
+ throw new Error(`${op}: unknown option ${JSON.stringify(key)}${
45
+ hint ? ` — did you mean ${hint}?` : ` (valid: ${valid.join(", ")})`}`);
46
+ }
47
+ }
48
+
49
+ function req(op, o, key) {
50
+ if (o[key] === undefined) throw new Error(`${op}: ${key} is required`);
51
+ return o[key];
52
+ }
53
+
54
+ // Trailing positional opts object, only if any of `keys` is present — an empty
55
+ // options tail must normalize to *no* argument so it hashes identically to the
56
+ // bare positional call.
57
+ function tail(o, keys) {
58
+ const t = {};
59
+ let any = false;
60
+ for (const key of keys) if (o[key] !== undefined) { t[key] = o[key]; any = true; }
61
+ return any ? [t] : [];
62
+ }
63
+
64
+ export function cylinderArgs(o) {
65
+ checkKeys("cylinder", o, ["r", "d", "r1", "r2", "d1", "d2", "h", "center"]);
66
+ const has = (key) => o[key] !== undefined;
67
+ const straight = has("r") + has("d");
68
+ const coneR = has("r1") + has("r2");
69
+ const coneD = has("d1") + has("d2");
70
+ let rBottom, rTop;
71
+ if (straight === 1 && coneR + coneD === 0) rBottom = rTop = has("r") ? o.r : o.d / 2;
72
+ else if (straight === 0 && coneR === 2 && coneD === 0) { rBottom = o.r1; rTop = o.r2; }
73
+ else if (straight === 0 && coneR === 0 && coneD === 2) { rBottom = o.d1 / 2; rTop = o.d2 / 2; }
74
+ else throw new Error("cylinder: pass exactly one of r/d, or r1+r2 / d1+d2");
75
+ return [rBottom, rTop, req("cylinder", o, "h"), ...tail(o, ["center"])];
76
+ }
77
+
78
+ export function sphereArgs(o) {
79
+ checkKeys("sphere", o, ["r", "d"]);
80
+ const hasR = o.r !== undefined;
81
+ if (hasR === (o.d !== undefined)) throw new Error("sphere: pass exactly one of r/d");
82
+ return [hasR ? o.r : o.d / 2];
83
+ }
84
+
85
+ export function boxArgs(o) {
86
+ checkKeys("box", o, ["size", "center", "min", "max"]);
87
+ if (o.min !== undefined || o.max !== undefined) {
88
+ if (o.size !== undefined) throw new Error("box: pass size or min+max, not both");
89
+ if (o.center !== undefined) throw new Error("box: center only applies to the size form");
90
+ return [req("box", o, "min"), req("box", o, "max")];
91
+ }
92
+ const [x, y, z] = req("box", o, "size");
93
+ return o.center === true
94
+ ? [[-x / 2, -y / 2, -z / 2], [x / 2, y / 2, z / 2]] // centered on all axes
95
+ : [[-x / 2, -y / 2, 0], [x / 2, y / 2, z]]; // canonical: centered X/Y, base at z=0
96
+ }
97
+
98
+ export function prismArgs(o) {
99
+ checkKeys("prism", o, ["points", "h", "twist", "scaleTop"]);
100
+ return [req("prism", o, "points"), req("prism", o, "h"), ...tail(o, ["twist", "scaleTop"])];
101
+ }
102
+
103
+ export function extrudeArgs(o) {
104
+ checkKeys("extrude", o, ["profile", "h", "twist", "scaleTop"]);
105
+ return [req("extrude", o, "profile"), req("extrude", o, "h"), ...tail(o, ["twist", "scaleTop"])];
106
+ }
107
+
108
+ export function revolveArgs(o) {
109
+ checkKeys("revolve", o, ["profile", "degrees"]);
110
+ return [req("revolve", o, "profile"), ...tail(o, ["degrees"])];
111
+ }
112
+
113
+ export function loftArgs(o) {
114
+ checkKeys("loft", o, ["rings", "ruled", "closed"]);
115
+ return [req("loft", o, "rings"), ...tail(o, ["ruled", "closed"])];
116
+ }
117
+
118
+ export function sweepArgs(o) {
119
+ checkKeys("sweep", o, ["profile", "path", "closed", "cornerRadius", "ruled", "smooth"]);
120
+ return [req("sweep", o, "profile"), req("sweep", o, "path"),
121
+ ...tail(o, ["closed", "cornerRadius", "ruled", "smooth"])];
122
+ }
123
+
124
+ // Per-op semantic validations, applied to the NORMALIZED positional args so
125
+ // they cover both calling forms (these moved here from kernel-front.js).
126
+ const checkScaleTop = (op) => (_profile, _h, opts) => {
127
+ if ((opts?.scaleTop ?? 1) < 0) throw new Error(`${op}: scaleTop must be ≥ 0`);
128
+ };
129
+
130
+ // Ops that were always options-only have no positional form to normalize —
131
+ // toArgs validates keys/required and passes the object through unchanged, so a
132
+ // typo'd key fails loudly instead of destructuring to undefined → NaN geometry.
133
+ const passThrough = (op, valid, required) => (o) => {
134
+ checkKeys(op, o, valid);
135
+ for (const key of required) req(op, o, key);
136
+ return [o];
137
+ };
138
+
139
+ // Kernel factory ops under the options convention. finishKernel() wraps each:
140
+ // normalize (if options form) → check → raw backend op.
141
+ export const KERNEL_OP_SPECS = {
142
+ cylinder: { toArgs: cylinderArgs },
143
+ sphere: { toArgs: sphereArgs },
144
+ box: { toArgs: boxArgs },
145
+ prism: { toArgs: prismArgs, check: checkScaleTop("prism") },
146
+ extrude: { toArgs: extrudeArgs, check: checkScaleTop("extrude") },
147
+ revolve: { toArgs: revolveArgs, check: (pts) => {
148
+ for (const [r] of pts) if (r < 0) throw new Error("revolve: profile radius must be ≥ 0");
149
+ } },
150
+ loft: { toArgs: loftArgs },
151
+ sweep: { toArgs: sweepArgs },
152
+ boredCylinder: { toArgs: passThrough("boredCylinder", ["od", "h", "bore"], ["od", "h", "bore"]) },
153
+ helixSweptTube: { toArgs: passThrough("helixSweptTube",
154
+ ["pathR", "profileR", "pitch", "turns", "z0", "lefthand"], ["pathR", "profileR", "pitch", "turns"]) },
155
+ };
156
+
157
+ // Solid ops under the options convention; addSugar() wraps these when the
158
+ // backend provides them natively (OCCT). The Manifold KernelCapabilityError
159
+ // stubs ignore arguments, so options-form calls still throw the routing error.
160
+ export const SOLID_OP_SPECS = {
161
+ fillet: { toArgs: (o) => { checkKeys("fillet", o, ["r", "edges"]);
162
+ return [req("fillet", o, "r"), ...(o.edges !== undefined ? [o.edges] : [])]; } },
163
+ chamfer: { toArgs: (o) => { checkKeys("chamfer", o, ["d", "edges"]);
164
+ return [req("chamfer", o, "d"), ...(o.edges !== undefined ? [o.edges] : [])]; } },
165
+ shell: { toArgs: (o) => { checkKeys("shell", o, ["t", "open"]);
166
+ return [req("shell", o, "t"), req("shell", o, "open")]; } },
167
+ };
@@ -6,10 +6,13 @@
6
6
  // sugar is geometry-identical on Manifold and OCCT alike;
7
7
  // - validates arguments the backends would otherwise each check (scale factor);
8
8
  // - derives boundingBox center/size from the backend's raw {min,max};
9
+ // - normalizes the options-object calling convention for fillet/chamfer/shell
10
+ // when the backend provides them natively (OCCT);
9
11
  // - stubs any OCCT-only op the backend lacks with a KernelCapabilityError, so
10
12
  // the needs-occt reroute works without hand-written per-backend stubs.
11
13
  import { KernelCapabilityError } from "./errors.js";
12
14
  import { OCCT_ONLY_OPS } from "./kernel.js";
15
+ import { isPlainOptions, SOLID_OP_SPECS } from "./op-options.js";
13
16
 
14
17
  const ORIGIN = [0, 0, 0];
15
18
  const AXIS = { X: [1, 0, 0], Y: [0, 1, 0], Z: [0, 0, 1] };
@@ -54,6 +57,14 @@ export function addSugar(s) {
54
57
  };
55
58
  };
56
59
 
60
+ // Options-object calling convention for the multi-param B-rep ops. Wrap only
61
+ // when the backend provides the op natively (OCCT); the Manifold stubs below
62
+ // ignore their arguments, so options-form calls still throw the routing error.
63
+ for (const [op, { toArgs }] of Object.entries(SOLID_OP_SPECS)) {
64
+ const raw = s[op];
65
+ if (raw) s[op] = (...a) => raw(...(a.length === 1 && isPlainOptions(a[0]) ? toArgs(a[0]) : a));
66
+ }
67
+
57
68
  for (const op of OCCT_ONLY_OPS) {
58
69
  s[op] ??= () => { throw new KernelCapabilityError(`${op} requires the OCCT backend`); };
59
70
  }
@@ -5,11 +5,40 @@ import { resolveSelection } from "./resolve.js";
5
5
 
6
6
  export { worldToSubPartLocal };
7
7
 
8
+ const DRAG_THRESHOLD_SQUARED = 4 ** 2;
9
+
8
10
  export function attachPicker(viewer, { part, getContext, onPick }) {
9
11
  let active = false;
12
+ const pointerStarts = new Map();
13
+ let dragged = false;
14
+
15
+ function onPointerDown(ev) {
16
+ if (pointerStarts.size === 0) dragged = false;
17
+ pointerStarts.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
18
+ }
19
+
20
+ function onPointerMove(ev) {
21
+ const pointerStart = pointerStarts.get(ev.pointerId);
22
+ if (!pointerStart || dragged) return;
23
+ const dx = ev.clientX - pointerStart.x;
24
+ const dy = ev.clientY - pointerStart.y;
25
+ dragged = dx * dx + dy * dy > DRAG_THRESHOLD_SQUARED;
26
+ }
27
+
28
+ function onPointerUp(ev) {
29
+ pointerStarts.delete(ev.pointerId);
30
+ }
31
+
32
+ function onPointerCancel(ev) {
33
+ pointerStarts.delete(ev.pointerId);
34
+ if (pointerStarts.size === 0) dragged = false;
35
+ }
10
36
 
11
37
  function onClick(ev) {
12
- if (!active) return;
38
+ const wasDragged = dragged;
39
+ pointerStarts.clear();
40
+ dragged = false;
41
+ if (!active || wasDragged) return;
13
42
  const hit = raycastViewer(viewer, ev.clientX, ev.clientY);
14
43
  if (!hit) return;
15
44
  const selection = resolveSelection(part, getContext(), hit);
@@ -17,9 +46,19 @@ export function attachPicker(viewer, { part, getContext, onPick }) {
17
46
  onPick(selection);
18
47
  }
19
48
 
49
+ viewer.domElement.addEventListener("pointerdown", onPointerDown);
50
+ viewer.domElement.addEventListener("pointermove", onPointerMove);
51
+ viewer.domElement.addEventListener("pointerup", onPointerUp);
52
+ viewer.domElement.addEventListener("pointercancel", onPointerCancel);
20
53
  viewer.domElement.addEventListener("click", onClick);
21
54
  return {
22
55
  setActive: (on) => { active = !!on; },
23
- detach: () => viewer.domElement.removeEventListener("click", onClick),
56
+ detach: () => {
57
+ viewer.domElement.removeEventListener("pointerdown", onPointerDown);
58
+ viewer.domElement.removeEventListener("pointermove", onPointerMove);
59
+ viewer.domElement.removeEventListener("pointerup", onPointerUp);
60
+ viewer.domElement.removeEventListener("pointercancel", onPointerCancel);
61
+ viewer.domElement.removeEventListener("click", onClick);
62
+ },
24
63
  };
25
64
  }
@@ -3,16 +3,16 @@
3
3
  consumers (e.g. partforge-cloud's app chrome) to share one source of truth. */
4
4
  :root {
5
5
  color-scheme: dark;
6
- --bg: #15181d; --surface: #1f242c; --surface-2: #20262e; --border: #2c333d;
7
- --text: #d6dbe2; --text-strong: #e7ebf1; --text-2: #cdd4dd;
8
- --muted: #7d8794; --muted-2: #aab2bd; --status: #8b94a0; --hint: #6b7480;
9
- --accent: #3f7bf0; --accent-soft: #26314a; --on-accent: #fff; --input-bg: #161a20; --err: #f8746c;
10
- --mono: ui-monospace, "SF Mono", SFMono-Regular, "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace;
6
+ --pf-bg: #15181d; --pf-surface: #1f242c; --pf-surface-2: #20262e; --pf-border: #2c333d;
7
+ --pf-text: #d6dbe2; --pf-text-strong: #e7ebf1; --pf-text-2: #cdd4dd;
8
+ --pf-muted: #7d8794; --pf-muted-2: #aab2bd; --pf-status: #8b94a0; --pf-hint: #6b7480;
9
+ --pf-accent: #3f7bf0; --pf-accent-soft: #26314a; --pf-on-accent: #fff; --pf-input-bg: #161a20; --pf-err: #f8746c;
10
+ --pf-mono: ui-monospace, "SF Mono", SFMono-Regular, "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace;
11
11
  }
12
12
  :root[data-theme="light"] {
13
13
  color-scheme: light;
14
- --bg: #eef1f5; --surface: #ffffff; --surface-2: #f4f6f9; --border: #d4dae2;
15
- --text: #2b333d; --text-strong: #1a2129; --text-2: #3a434e;
16
- --muted: #6b7480; --muted-2: #59636f; --status: #6b7480; --hint: #8a93a0;
17
- --accent: #1f5bd6; --accent-soft: #e6edfc; --on-accent: #fff; --input-bg: #ffffff; --err: #d8453d;
14
+ --pf-bg: #eef1f5; --pf-surface: #ffffff; --pf-surface-2: #f4f6f9; --pf-border: #d4dae2;
15
+ --pf-text: #2b333d; --pf-text-strong: #1a2129; --pf-text-2: #3a434e;
16
+ --pf-muted: #6b7480; --pf-muted-2: #59636f; --pf-status: #6b7480; --pf-hint: #8a93a0;
17
+ --pf-accent: #1f5bd6; --pf-accent-soft: #e6edfc; --pf-on-accent: #fff; --pf-input-bg: #ffffff; --pf-err: #d8453d;
18
18
  }
package/src/parts/demo.js CHANGED
@@ -48,9 +48,9 @@ export default {
48
48
  views: ["spacer"],
49
49
  export: { name: "spacer" },
50
50
  build: (k, p, d) => {
51
- let s = k.cylinder(p.od / 2, p.od / 2, p.h);
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).at([0, 0, -2]));
51
+ let s = k.cylinder({ d: p.od, h: p.h });
52
+ if (p.flange_d > 0) s = k.union([s, k.cylinder({ d: p.flange_d, h: p.flange_h })]);
53
+ return s.cut(k.cylinder({ r: d.boreR, h: d.cutH }).at([0, 0, -2]));
54
54
  },
55
55
  },
56
56
  },
@@ -54,10 +54,10 @@ export default {
54
54
  vase: {
55
55
  label: "Vase", views: ["vase"], export: { name: "vase" },
56
56
  build: (k, p) => {
57
- const body = k.loft(vaseRings(p, false)).label("Faceted wall");
57
+ const body = k.loft({ rings: vaseRings(p, false) }).label("Faceted wall");
58
58
  // Hollow it: an inset loft clipped to z ≥ floor (so the base stays solid), cut from the body.
59
- const cavity = k.loft(vaseRings(p, true))
60
- .intersect(k.box([-1e4, -1e4, p.floor], [1e4, 1e4, p.height + 10])).label("Cavity");
59
+ const cavity = k.loft({ rings: vaseRings(p, true) })
60
+ .intersect(k.box({ min: [-1e4, -1e4, p.floor], max: [1e4, 1e4, p.height + 10] })).label("Cavity");
61
61
  return body.cut(cavity);
62
62
  },
63
63
  },
@@ -24,20 +24,20 @@ export default {
24
24
  label: "Body",
25
25
  views: ["box"],
26
26
  build: (k, p) => {
27
- let s = k.box([0, 0, 0], [p.w, p.d, p.h]);
27
+ let s = k.box({ min: [0, 0, 0], max: [p.w, p.d, p.h] });
28
28
  const half = Math.min(p.w, p.d) / 2;
29
29
  // Round the vertical edges, then the top rim — each clamped to the box so a
30
30
  // radius can't exceed the available material.
31
31
  const vFillet = Math.min(p.fillet, half - 0.5, p.h - 0.5);
32
- if (vFillet > 0) s = s.fillet(vFillet, { dir: "Z" }); // 4 vertical edges
32
+ if (vFillet > 0) s = s.fillet({ r: vFillet, edges: { dir: "Z" } }); // 4 vertical edges
33
33
  const topFillet = Math.min(p.top, half - vFillet - 0.5, p.h / 2 - 0.5);
34
- if (topFillet > 0) s = s.fillet(topFillet, { inPlane: "XY", at: p.h }); // top rim — curves all the way around
34
+ if (topFillet > 0) s = s.fillet({ r: topFillet, edges: { inPlane: "XY", at: p.h } }); // top rim — curves all the way around
35
35
  // Base chamfer AFTER the fillets, so it cuts a clean curve across the rounded
36
36
  // corners. No manual limit needed: the backend auto-clamps a chamfer to half
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
- 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).at([p.w / 2, p.d / 2, -1]).label("Bore"));
39
+ if (p.chamfer > 0) s = s.chamfer({ d: p.chamfer, edges: { inPlane: "XY", at: 0 } }); // base edges
40
+ if (p.bore > 0) s = s.cut(k.cylinder({ d: p.bore, h: p.h + 2 }).at([p.w / 2, p.d / 2, -1]).label("Bore"));
41
41
  return s;
42
42
  },
43
43
  },
@@ -89,19 +89,19 @@ export default {
89
89
  views: ["planter"],
90
90
  export: { name: "planter" },
91
91
  build: (k, p, d) => {
92
- const body = k.prism(d.outerPts, p.height, { scaleTop: p.taper, twist: p.twist }).label("Faceted wall");
92
+ const body = k.prism({ points: d.outerPts, h: p.height, scaleTop: p.taper, twist: p.twist }).label("Faceted wall");
93
93
  // Hollow it. The cavity is built from z=0 sharing the body's exact twist RATE and
94
94
  // taper slope (f rescales the ~4 mm overshoot so the rates still match), so the
95
95
  // inner and outer facets stay radially aligned at every height — the wall can't
96
96
  // pinch when twisted. Then clip the cavity to z ≥ floor so the base stays solid.
97
97
  const f = (p.height + 4) / p.height;
98
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]))
99
+ .prism({ points: d.innerPts, h: p.height + 4, scaleTop: 1 + (d.innerTaper - 1) * f, twist: p.twist * f })
100
+ .intersect(k.box({ min: [-1e4, -1e4, p.floor], max: [1e4, 1e4, p.height + 10] }))
101
101
  .label("Cavity");
102
102
  let s = body.cut(cavity);
103
103
  // Optional drainage hole straight through the base.
104
- if (p.drain > 0) s = s.cut(k.cylinder(d.drainR, d.drainR, p.floor + 4).at([0, 0, -2]).label("Drainage hole"));
104
+ if (p.drain > 0) s = s.cut(k.cylinder({ r: d.drainR, h: p.floor + 4 }).at([0, 0, -2]).label("Drainage hole"));
105
105
  return s;
106
106
  },
107
107
  },