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.
- package/docs/AUTHORING-PARTS.md +40 -29
- package/docs/ERROR-PATTERNS.md +33 -1
- package/package.json +1 -1
- package/src/framework/app.css +75 -75
- package/src/framework/geometry/kernel-front.js +19 -20
- package/src/framework/geometry/kernel.js +19 -15
- package/src/framework/geometry/op-options.js +167 -0
- package/src/framework/geometry/solid-sugar.js +11 -0
- package/src/framework/selection/pick.js +41 -2
- package/src/framework/tokens.css +9 -9
- package/src/parts/demo.js +3 -3
- package/src/parts/faceted-vase.js +3 -3
- package/src/parts/filleted-box.js +5 -5
- package/src/parts/planter.js +4 -4
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -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(
|
|
102
|
-
| `k.box(min, max)` |
|
|
103
|
-
| `k.prism(
|
|
104
|
-
| `k.extrude(profile, h,
|
|
105
|
-
| `k.loft(rings,
|
|
106
|
-
| `k.sweep(
|
|
107
|
-
| `k.sphere(r)` | sphere centred at the origin |
|
|
108
|
-
| `k.revolve(
|
|
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 `
|
|
120
|
-
`
|
|
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);
|
|
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]],
|
|
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);
|
|
138
|
-
k.prism(roundedProfile(bracketOutline, 3), 4);
|
|
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(
|
|
201
|
+
k.cylinder({ r, h: L }).rotate(-90, [0, 0, 0], [1, 0, 0]).translate([rp, y1, sz])
|
|
191
202
|
// ✓ legible
|
|
192
|
-
k.cylinder(
|
|
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,
|
|
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(
|
|
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(
|
|
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,
|
|
748
|
-
| `s.chamfer(distance,
|
|
749
|
-
| `s.shell(
|
|
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
|
-
`
|
|
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.
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -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
package/src/framework/app.css
CHANGED
|
@@ -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
|
-
// -
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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 {(
|
|
69
|
-
* @property {(
|
|
70
|
-
* @property {(
|
|
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 {(
|
|
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
|
|
78
|
-
* @property {(
|
|
79
|
-
* @property {(
|
|
80
|
-
* @property {(profile:number[][]|{outer:number[][],holes?:number[][][]},
|
|
81
|
-
* @property {(rings:{polygon?:number[][],sides?:number,radius?:number,z:number,rotate?:number,scale?:number|number[]}[],
|
|
82
|
-
* @property {(
|
|
83
|
-
* @property {(
|
|
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
|
-
|
|
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: () =>
|
|
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
|
}
|
package/src/framework/tokens.css
CHANGED
|
@@ -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(
|
|
52
|
-
if (p.flange_d > 0) s = k.union([s, k.cylinder(
|
|
53
|
-
return s.cut(k.cylinder(
|
|
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" });
|
|
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 });
|
|
40
|
-
if (p.bore > 0) s = s.cut(k.cylinder(
|
|
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
|
},
|
package/src/parts/planter.js
CHANGED
|
@@ -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,
|
|
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,
|
|
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(
|
|
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
|
},
|