partforge 0.13.0 → 0.16.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.
@@ -147,11 +147,15 @@ k.sweep({ profile: circleProfile(3), path: [[0, 0, 0], [0, 0, 20], [15, 0, 20]],
147
147
  // round every corner of any CCW outline, then extrude/loft/prism it
148
148
  k.prism({ points: filletPolygon(bracketOutline, 3), h: 4 }); // tessellated corners (faceted in STEP)
149
149
  k.prism({ points: roundedProfile(bracketOutline, 3), h: 4 }); // true CIRCLE corners in STEP export
150
+
151
+ // print clearance on an arbitrary cut profile, or an inset wall
152
+ k.extrude({ profile: offsetPolygon(slotPolygon(20, 3), 0.2), h: 10 }); // slot cut 0.2 mm looser all around
153
+ offsetPolygon(outline, -wall, { corners: "sharp" }); // inset a wall (see planter.js)
150
154
  ```
151
155
 
152
156
  2-D polygon helpers for `prism`/`extrude`/`loft`: `import { piePolygon, hexPolygon,
153
- regularPolygon, roundedRectPolygon, starPolygon, circleProfile, filletPolygon,
154
- roundedProfile } from "partforge/geometry"`. `filletPolygon(points, r, { segs? })` rounds
157
+ regularPolygon, roundedRectPolygon, starPolygon, slotPolygon, circleProfile, filletPolygon,
158
+ roundedProfile, offsetPolygon } from "partforge/geometry"`. `filletPolygon(points, r, { segs? })` rounds
155
159
  every corner of a CCW polygon (per-corner radius clamped so neighbouring arcs never overlap)
156
160
  and returns points usable by `prism`/`extrude`/`loft` on both backends — but it **bakes each
157
161
  corner into line facets**, so STEP corners are faceted. `roundedProfile(points, r | r[])`
@@ -159,7 +163,16 @@ rounds corners the same way but keeps them **mathematically true** — it carrie
159
163
  symbolically so STEP export gets real circular edges. Use it for `prism`/`extrude` (not yet
160
164
  `loft` — arc rings are rejected there in v1). A scalar `r` rounds every corner; a per-corner
161
165
  `r[]` (length = points) rounds selectively (a `0`, a zero-length edge, or a straight/180°
162
- corner stays sharp).
166
+ corner stays sharp). `offsetPolygon(profile, delta, { corners?, segs? })` offsets a
167
+ point-list polygon or `{ outer, holes }` region by `delta` mm — positive grows material,
168
+ negative insets; regions offset material-wise (outer `+delta`, holes `−delta`, so a
169
+ clearance loosens the whole cut). `corners` picks the convex-corner style: `"round"`
170
+ (default; the true Minkowski clearance), `"chamfer"`, or `"sharp"` (miter, falling back to
171
+ chamfer past a miter length of 2·|delta|). It is **simple polygon in, simple polygon out**:
172
+ an offset whose true result would collapse or split into multiple contours (e.g. insetting a
173
+ dumbbell past its waist) **throws** a greppable error rather than returning degenerate
174
+ geometry. Being pure, it works in `derive()` as well as `build()` — the natural home for
175
+ clearance math.
163
176
  **Import geometry helpers from `partforge/geometry`, never from `partforge`** — the main
164
177
  entry pulls in the DOM viewer/controls, and your build functions run in a Web Worker
165
178
  (importing the main entry there throws `document is not defined`).
@@ -195,6 +195,32 @@ The sphere variant is `sphere: pass exactly one of r/d` (same cause and fix).
195
195
  - **Cause:** `center` was passed alongside `min`/`max`, but explicit corners already fix the placement.
196
196
  - **Fix:** drop `center`, or switch to `{size, center?}` — see [AUTHORING-PARTS.md](AUTHORING-PARTS.md).
197
197
 
198
+ ## offset-polygon-bad-input
199
+
200
+ - **Symptom:** `offsetPolygon: need at least 3 points`
201
+ - **Cause:** malformed input to `offsetPolygon` — too few points after dedup, or (variant messages) a non-finite `delta`, non-finite coordinates, an unknown `corners` style, or a profile that is neither a point list nor `{outer, holes}`.
202
+ - **Fix:** pass a CCW `[[x,y],…]` list (≥ 3 distinct points) or `{outer, holes}`, a finite `delta` in mm, and `corners: "round" | "chamfer" | "sharp"` — see [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Profiles & patterns".
203
+
204
+ Variant literals under this entry: `offsetPolygon: delta must be a finite number`, `offsetPolygon: coordinates must be finite numbers`, `offsetPolygon: corners must be "round" | "chamfer" | "sharp"`, `offsetPolygon: profile must be a point list or {outer, holes}`.
205
+
206
+ ## offset-polygon-input-self-intersects
207
+
208
+ - **Symptom:** `offsetPolygon: input polygon self-intersects`
209
+ - **Cause:** the input contour crosses itself — the profile is broken before any offsetting happens (checked up front so bad input is not blamed on the offset).
210
+ - **Fix:** repair the generating math for the contour; the offset envelope requires simple polygons in and out.
211
+
212
+ ## offset-polygon-collapse
213
+
214
+ - **Symptom:** `offsetPolygon: offset collapses the polygon`
215
+ - **Cause:** the offset consumed the shape — either an inset ate the whole polygon (result area ≤ 0 or fewer than 3 points, `|delta|` past the narrowest half-width; also thrown for a region hole that would vanish), or an offset displaced an edge past its own length so the edge inverts (a large inset, or a large *outset* of a concave profile where `|delta|` exceeds a reflex-adjacent edge — this last case can also depend on `corners`, since `"sharp"` extends edges further than `"round"`/`"chamfer"`).
216
+ - **Fix:** reduce `|delta|`, or clamp it from the shape's dimensions before offsetting (see planter.js's wall cap). If a vanishing hole is intended, remove the hole from the region explicitly. Realistic clearances (fractions of a mm) on any profile, and wall insets up to the narrowest feature, never trip this.
217
+
218
+ ## offset-polygon-result-self-intersects
219
+
220
+ - **Symptom:** `offsetPolygon: offset result self-intersects (reduce |delta| or simplify the profile)`
221
+ - **Cause:** the true offset of this shape at this `|delta|` is not a single simple polygon (e.g. insetting a dumbbell past its waist would split it in two) — out of `offsetPolygon`'s envelope.
222
+ - **Fix:** reduce `|delta|`, or decompose the profile into separately-offset simple contours.
223
+
198
224
  # Hardware library
199
225
 
200
226
  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.13.0",
3
+ "version": "0.16.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",
@@ -1,6 +1,9 @@
1
1
  /* Shared chrome styles for any part app (panel, tabs, viewbar, download row,
2
2
  busy overlay) + the light/dark palettes. Imported by framework/mount.js, so
3
3
  every part-app gets it; each part's HTML only carries structural markup.
4
+ Skinned to the shared design language (partforge-cloud spec
5
+ 2026-07-17-shared-design-language-design.md): zinc-tuned neutrals, blue
6
+ accent, shadcn-shaped radii/shadows/focus rings — plain CSS on --pf-*.
4
7
  See docs/AUTHORING-PARTS.md. */
5
8
  @import "./tokens.css"; /* palette + light overrides; also exported standalone as partforge/tokens.css */
6
9
 
@@ -13,8 +16,11 @@ canvas { display: block; }
13
16
  #panel {
14
17
  position: fixed; top: 12px; left: 12px; width: 256px;
15
18
  max-height: calc(100vh - 24px); overflow-y: auto; z-index: 10;
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);
19
+ background: var(--pf-surface); border: 1px solid var(--pf-border); border-radius: 16px;
20
+ padding: 14px; color: var(--pf-text);
21
+ /* shadow-lg, matching the partforge-cloud chat pane so the two floating
22
+ panes read as siblings (was the smaller shadow-md). */
23
+ box-shadow: 0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1);
18
24
  }
19
25
  #panel h1 { font-size: 14px; margin: 0 0 2px; color: var(--pf-text-strong); letter-spacing: -0.01em; }
20
26
  #panel .sub {
@@ -23,16 +29,18 @@ canvas { display: block; }
23
29
  margin: 0 0 12px; padding-bottom: 12px; border-bottom: 1px solid var(--pf-border);
24
30
  }
25
31
 
26
- .seg { display: flex; gap: 4px; margin-bottom: 12px; }
32
+ .seg { display: flex; gap: 2px; margin-bottom: 12px;
33
+ background: var(--pf-surface-2); border-radius: 10px; padding: 3px; }
27
34
  .seg button {
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;
35
+ flex: 1; padding: 6px 0; border: 0; border-radius: 8px;
36
+ background: transparent; color: var(--pf-muted); cursor: pointer;
30
37
  font-family: var(--pf-mono); font-size: 11px; letter-spacing: 0.02em;
31
38
  }
32
- .seg button.on { background: var(--pf-accent); color: var(--pf-on-accent); border-color: var(--pf-accent); }
39
+ .seg button:hover { color: var(--pf-text-2); }
40
+ .seg button.on { background: var(--pf-accent); color: var(--pf-on-accent); }
33
41
 
34
42
  .section {
35
- border: 1px solid var(--pf-border); border-radius: 8px; padding: 10px;
43
+ border: 1px solid var(--pf-border); border-radius: 10px; padding: 10px;
36
44
  margin-bottom: 8px; background: var(--pf-surface-2);
37
45
  }
38
46
  .sec-title {
@@ -41,7 +49,7 @@ canvas { display: block; }
41
49
  }
42
50
  select.preset {
43
51
  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;
52
+ border: 1px solid var(--pf-border); border-radius: 8px; padding: 7px 9px;
45
53
  font-family: var(--pf-mono); font-size: 11px;
46
54
  }
47
55
  .feat { display: flex; align-items: center; gap: 8px; margin: 6px 0;
@@ -50,7 +58,7 @@ select.preset {
50
58
  .feat-group { margin: 2px 0 8px; padding-left: 10px; border-left: 2px solid var(--pf-border); }
51
59
  .feat-group.hidden { display: none; }
52
60
  .adv-toggle {
53
- margin-top: 8px; padding: 4px 0; width: 100%; border: 0; border-radius: 5px;
61
+ margin-top: 8px; padding: 4px 0; width: 100%; border: 0; border-radius: 6px;
54
62
  background: transparent; color: var(--pf-muted); cursor: pointer;
55
63
  font-family: var(--pf-mono); font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; text-align: left;
56
64
  }
@@ -66,9 +74,10 @@ select.preset {
66
74
  .row .num {
67
75
  width: 54px; text-align: right; font-family: var(--pf-mono); font-size: 12px; font-variant-numeric: tabular-nums;
68
76
  background: var(--pf-input-bg); color: var(--pf-text-strong);
69
- border: 1px solid var(--pf-border); border-radius: 5px; padding: 3px 6px;
77
+ border: 1px solid var(--pf-border); border-radius: 6px; padding: 3px 6px;
70
78
  }
71
- .row .num:focus { outline: none; border-color: var(--pf-accent); box-shadow: 0 0 0 3px var(--pf-accent-soft); }
79
+ .row .num:focus { outline: none; border-color: var(--pf-accent);
80
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--pf-accent) 35%, transparent); }
72
81
  .row .num::-webkit-outer-spin-button, .row .num::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
73
82
  .row .num { -moz-appearance: textfield; }
74
83
  .row .unit { font-family: var(--pf-mono); color: var(--pf-muted); font-size: 10px; }
@@ -87,16 +96,18 @@ input[type="range"]::-moz-range-thumb {
87
96
  width: 14px; height: 14px; border-radius: 50%; background: var(--pf-accent);
88
97
  border: 2px solid var(--pf-surface-2); box-shadow: 0 0 0 1px var(--pf-accent);
89
98
  }
90
- input[type="range"]:hover::-webkit-slider-thumb { box-shadow: 0 0 0 5px var(--pf-accent-soft); }
99
+ input[type="range"]:hover::-webkit-slider-thumb { box-shadow: 0 0 0 5px color-mix(in oklab, var(--pf-accent) 35%, transparent); }
91
100
  input[type="range"]:focus-visible { outline: none; }
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); }
101
+ input[type="range"]:focus-visible::-webkit-slider-thumb { box-shadow: 0 0 0 5px color-mix(in oklab, var(--pf-accent) 35%, transparent); }
102
+ input[type="range"]:focus-visible::-moz-range-thumb { box-shadow: 0 0 0 5px color-mix(in oklab, var(--pf-accent) 35%, transparent); }
94
103
 
95
104
  button.action {
96
- width: 100%; margin-top: 8px; padding: 9px; border: 0; border-radius: 7px;
97
- background: var(--pf-accent); color: var(--pf-on-accent); font-weight: 600; cursor: pointer;
105
+ width: 100%; margin-top: 8px; padding: 9px; border: 0; border-radius: 8px;
106
+ background: var(--pf-accent); color: var(--pf-on-accent); font-weight: 500; cursor: pointer;
98
107
  }
99
- button.ghost { background: var(--pf-border); color: var(--pf-text-2); font-weight: 500; }
108
+ button.action:hover:not(:disabled) { background: color-mix(in oklab, var(--pf-accent) 90%, black); }
109
+ button.ghost { background: transparent; border: 1px solid var(--pf-border); color: var(--pf-text-2); font-weight: 500; }
110
+ button.ghost:hover:not(:disabled) { background: var(--pf-surface-2); }
100
111
  button.action:disabled { opacity: .5; cursor: default; }
101
112
 
102
113
  .dl { margin-top: 14px; }
@@ -106,8 +117,8 @@ button.action:disabled { opacity: .5; cursor: default; }
106
117
  }
107
118
  .dl-row { display: flex; gap: 6px; }
108
119
  .dl-row button {
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);
120
+ flex: 1; padding: 8px 0; border: 1px solid var(--pf-border); border-radius: 8px;
121
+ background: transparent; color: var(--pf-text-2);
111
122
  font-family: var(--pf-mono); font-weight: 600; font-size: 11px; letter-spacing: 0.06em; cursor: pointer;
112
123
  }
113
124
  .dl-row button:hover:not(:disabled) { border-color: var(--pf-accent); color: var(--pf-text-strong); }
@@ -120,7 +131,8 @@ button.action:disabled { opacity: .5; cursor: default; }
120
131
  /* keyboard focus ring shared across the panel's interactive controls */
121
132
  .seg button:focus-visible, select.preset:focus-visible, .dl-row button:focus-visible,
122
133
  button.action:focus-visible, .adv-toggle:focus-visible, #viewbar button:focus-visible {
123
- outline: 2px solid var(--pf-accent); outline-offset: 2px;
134
+ outline: none;
135
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--pf-accent) 35%, transparent);
124
136
  }
125
137
 
126
138
  /* part tabs, floated top-centre over the viewport */
@@ -130,7 +142,8 @@ button.action:focus-visible, .adv-toggle:focus-visible, #viewbar button:focus-vi
130
142
  }
131
143
  #topbar .seg {
132
144
  margin: 0; padding: 4px; background: var(--pf-surface); border: 1px solid var(--pf-border);
133
- border-radius: 9px; box-shadow: 0 6px 24px rgba(0,0,0,.35);
145
+ border-radius: 14px;
146
+ box-shadow: 0 4px 6px -1px rgb(0 0 0 / .1), 0 2px 4px -2px rgb(0 0 0 / .1);
134
147
  }
135
148
  #topbar .seg button { min-width: 70px; padding: 7px 10px; }
136
149
 
@@ -139,16 +152,17 @@ button.action:focus-visible, .adv-toggle:focus-visible, #viewbar button:focus-vi
139
152
  position: fixed; top: 12px; right: 12px; z-index: 15;
140
153
  display: flex; gap: 4px; padding: 4px;
141
154
  background: var(--pf-surface); border: 1px solid var(--pf-border);
142
- border-radius: 9px; box-shadow: 0 6px 24px rgba(0,0,0,.35);
155
+ border-radius: 14px;
156
+ box-shadow: 0 4px 6px -1px rgb(0 0 0 / .1), 0 2px 4px -2px rgb(0 0 0 / .1);
143
157
  }
144
158
  #viewbar button {
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;
159
+ width: 34px; height: 34px; border: 0; border-radius: 10px;
160
+ background: transparent; color: var(--pf-muted-2); cursor: pointer;
147
161
  font-size: 15px; line-height: 1;
148
162
  display: flex; align-items: center; justify-content: center;
149
163
  }
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); }
164
+ #viewbar button:hover { color: var(--pf-text); background: var(--pf-surface-2); }
165
+ #viewbar button.on { background: var(--pf-accent); color: var(--pf-on-accent); }
152
166
 
153
167
  #busy {
154
168
  position: fixed; inset: 0; z-index: 20; pointer-events: none;
@@ -161,7 +175,7 @@ button.action:focus-visible, .adv-toggle:focus-visible, #viewbar button:focus-vi
161
175
  border: 4px solid var(--pf-border); border-top-color: var(--pf-accent);
162
176
  animation: spin 0.9s linear infinite;
163
177
  }
164
- #busy .phase { color: var(--pf-text); font-size: 13px; text-shadow: 0 1px 6px rgba(0,0,0,.6); }
178
+ #busy .phase { color: var(--pf-text); font-size: 13px; }
165
179
  @keyframes spin { to { transform: rotate(360deg); } }
166
180
 
167
181
  /* info glyph + description popover */
@@ -171,15 +185,17 @@ button.action:focus-visible, .adv-toggle:focus-visible, #viewbar button:focus-vi
171
185
  vertical-align: middle;
172
186
  }
173
187
  .info:hover { color: var(--pf-text-2); }
174
- .info:focus-visible { outline: 2px solid var(--pf-accent); outline-offset: 2px; border-radius: 3px; }
188
+ .info:focus-visible { outline: none; border-radius: 4px;
189
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--pf-accent) 35%, transparent); }
175
190
  .popover {
176
191
  position: fixed; z-index: 50; max-width: 280px; max-height: 50vh; overflow: auto;
177
192
  background: var(--pf-surface); color: var(--pf-text); border: 1px solid var(--pf-border);
178
- border-radius: 8px; padding: 10px 12px; box-shadow: 0 6px 24px rgba(0,0,0,.35);
193
+ border-radius: 14px; padding: 10px 12px;
194
+ box-shadow: 0 4px 6px -1px rgb(0 0 0 / .1), 0 2px 4px -2px rgb(0 0 0 / .1);
179
195
  font-size: 12px; line-height: 1.5;
180
196
  }
181
197
  .popover[hidden] { display: none; }
182
- .popover img { max-width: 100%; height: auto; border-radius: 4px; }
198
+ .popover img { max-width: 100%; height: auto; border-radius: 6px; }
183
199
  .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
200
  .popover a { color: var(--pf-accent); }
185
201
  .popover p:first-child { margin-top: 0; }
@@ -193,10 +209,10 @@ button.action:focus-visible, .adv-toggle:focus-visible, #viewbar button:focus-vi
193
209
  /* hover feature-label tooltip (selection/hover.js) */
194
210
  #pf-hover-tip {
195
211
  position: fixed; z-index: 30; pointer-events: none; display: none;
196
- padding: 4px 9px; border-radius: 6px; max-width: 260px;
212
+ padding: 4px 9px; border-radius: 8px; max-width: 260px;
197
213
  background: var(--pf-surface); border: 1px solid var(--pf-border);
198
214
  color: var(--pf-text-strong); font-size: 12px; line-height: 1.35;
199
- box-shadow: 0 2px 10px rgba(0, 0, 0, .25);
215
+ box-shadow: 0 2px 4px -2px rgb(0 0 0 / .15);
200
216
  }
201
217
  #pf-hover-tip.show { display: block; }
202
218
  #pf-hover-tip .pf-hover-sub { color: var(--pf-muted); font-size: 11px; margin-left: 7px; }
@@ -209,8 +225,8 @@ button.action:focus-visible, .adv-toggle:focus-visible, #viewbar button:focus-vi
209
225
  z-index: 30; max-width: min(56ch, calc(100vw - 24px));
210
226
  padding: 10px 36px 10px 12px;
211
227
  background: var(--pf-surface); color: var(--pf-text);
212
- border: 1px solid var(--pf-border); border-radius: 10px;
213
- box-shadow: 0 6px 24px rgba(0,0,0,.35);
228
+ border: 1px solid var(--pf-border); border-radius: 14px;
229
+ box-shadow: 0 4px 6px -1px rgb(0 0 0 / .1), 0 2px 4px -2px rgb(0 0 0 / .1);
214
230
  font-size: 12px; line-height: 1.45;
215
231
  animation: pf-pick-in .18s ease;
216
232
  }
@@ -227,7 +243,7 @@ button.action:focus-visible, .adv-toggle:focus-visible, #viewbar button:focus-vi
227
243
  position: absolute; top: 6px; right: 6px; appearance: none;
228
244
  width: 20px; height: 20px; padding: 0;
229
245
  border: 1px solid var(--pf-border); border-radius: 6px;
230
- background: var(--pf-surface-2); color: var(--pf-muted);
246
+ background: transparent; color: var(--pf-muted);
231
247
  cursor: pointer; font-size: 13px; line-height: 1;
232
248
  display: flex; align-items: center; justify-content: center;
233
249
  }
@@ -242,11 +258,12 @@ button.action:focus-visible, .adv-toggle:focus-visible, #viewbar button:focus-vi
242
258
  position: fixed; left: 12px; bottom: 12px; z-index: 9999;
243
259
  font: 12px system-ui, sans-serif; padding: 6px 10px; cursor: pointer;
244
260
  }
245
- #pf-pick.on { outline: 2px solid #ffcc33; }
261
+ #pf-pick.on { outline: 2px solid var(--pf-accent); }
246
262
  #pf-pick-toast {
247
263
  position: fixed; left: 12px; bottom: 48px; z-index: 9999; max-width: 60ch;
248
- font: 12px ui-monospace, monospace; padding: 6px 10px; border-radius: 4px;
249
- background: rgba(20,24,29,0.92); color: #d8e0ea;
264
+ font: 12px var(--pf-mono); padding: 6px 10px; border-radius: 8px;
265
+ background: var(--pf-surface); color: var(--pf-text);
266
+ border: 1px solid var(--pf-border);
250
267
  white-space: pre-wrap; word-break: break-word; display: none;
251
268
  }
252
269
  #pf-pick-toast.show { display: block; }
@@ -227,3 +227,180 @@ export function circleProfile(r, center = [0, 0], segs = 48) {
227
227
  }
228
228
  return pts;
229
229
  }
230
+
231
+ // --- offsetPolygon ---------------------------------------------------------
232
+
233
+ const OFFSET_EPS = 1e-9;
234
+
235
+ // Fresh copies, consecutive duplicates dropped, closing point (== first) dropped.
236
+ function dedupePoints(points) {
237
+ const out = [];
238
+ for (const p of points) {
239
+ const last = out[out.length - 1];
240
+ if (!last || Math.hypot(p[0] - last[0], p[1] - last[1]) > OFFSET_EPS) out.push([p[0], p[1]]);
241
+ }
242
+ while (out.length > 1 &&
243
+ Math.hypot(out[0][0] - out[out.length - 1][0], out[0][1] - out[out.length - 1][1]) <= OFFSET_EPS) out.pop();
244
+ return out;
245
+ }
246
+
247
+ function polySignedArea(pts) {
248
+ let a = 0;
249
+ for (let i = 0; i < pts.length; i++) {
250
+ const [x1, y1] = pts[i], [x2, y2] = pts[(i + 1) % pts.length];
251
+ a += x1 * y2 - x2 * y1;
252
+ }
253
+ return a / 2;
254
+ }
255
+
256
+ // True where segments a-b and c-d properly cross. Shared endpoints and collinear
257
+ // touches don't count — adjacent edges always share a vertex, and the collinear
258
+ // case is degenerate input the dedupe/straight-vertex paths already absorb.
259
+ function segmentsCross(a, b, c, d) {
260
+ const orient = (p, q, r) => (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]);
261
+ return orient(a, b, c) * orient(a, b, d) < 0 && orient(c, d, a) * orient(c, d, b) < 0;
262
+ }
263
+
264
+ // True where p lies strictly inside segment a-b (not at either endpoint) —
265
+ // a degenerate touch that segmentsCross's proper-crossing test doesn't see.
266
+ function pointOnSegment(p, a, b) {
267
+ const cross = (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]);
268
+ if (Math.abs(cross) > OFFSET_EPS) return false;
269
+ const dot = (p[0] - a[0]) * (b[0] - a[0]) + (p[1] - a[1]) * (b[1] - a[1]);
270
+ const lenSq = (b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2;
271
+ return dot > OFFSET_EPS && dot < lenSq - OFFSET_EPS;
272
+ }
273
+
274
+ // O(n²) simplicity test — trivial at profile point counts (tens to hundreds).
275
+ function isSimplePolygon(pts) {
276
+ const n = pts.length;
277
+ for (let i = 0; i < n; i++) {
278
+ for (let j = i + 1; j < n; j++) {
279
+ if (j === i + 1 || (i === 0 && j === n - 1)) continue; // adjacent edges share a vertex
280
+ const [a, b] = [pts[i], pts[(i + 1) % n]], [c, d] = [pts[j], pts[(j + 1) % n]];
281
+ if (segmentsCross(a, b, c, d)) return false;
282
+ // A vertex landing exactly on a non-adjacent edge is also a self-touch —
283
+ // e.g. an inset waist pinching two opposite edges together.
284
+ if (pointOnSegment(a, c, d) || pointOnSegment(b, c, d) ||
285
+ pointOnSegment(c, a, b) || pointOnSegment(d, a, b)) return false;
286
+ }
287
+ }
288
+ return true;
289
+ }
290
+
291
+ // Intersection of two infinite lines given as point + unit direction; null if parallel.
292
+ function lineIntersect(p, dp, q, dq) {
293
+ const denom = dp[0] * dq[1] - dp[1] * dq[0];
294
+ if (Math.abs(denom) < OFFSET_EPS) return null;
295
+ const t = ((q[0] - p[0]) * dq[1] - (q[1] - p[1]) * dq[0]) / denom;
296
+ return [p[0] + dp[0] * t, p[1] + dp[1] * t];
297
+ }
298
+
299
+ // Offset a profile by `delta` mm: positive grows material outward, negative
300
+ // insets. `profile` is a CCW [[x,y],…] point list (either winding accepted,
301
+ // output always CCW) or an {outer, holes} region — regions offset as material:
302
+ // outer by +delta, holes by −delta, so a +0.2 clearance on a cut region loosens
303
+ // the whole cut. Corners where the offset edges diverge fill per `corners`:
304
+ // "round" (arc of radius |delta| about the original vertex, `segs` segments —
305
+ // the true Minkowski clearance), "chamfer" (the arc's chord), or "sharp" (true
306
+ // miter, falling back to chamfer past a miter length of 2·|delta|). Where
307
+ // offset edges cross (reflex on outset, convex on inset) they trim to their
308
+ // intersection regardless of style. Simple polygon in, simple polygon out:
309
+ // a result that self-intersects or vanishes THROWS (greppable errors below)
310
+ // rather than returning degenerate geometry — offsets that would split a
311
+ // region (dumbbell insets) are out of scope. Pure and deterministic; usable in
312
+ // derive() and build() alike. See AUTHORING-PARTS.md "Profiles & patterns".
313
+ export function offsetPolygon(profile, delta, opts = {}) {
314
+ const { corners = "round", segs = 8 } = opts;
315
+ if (profile !== null && typeof profile === "object" && !Array.isArray(profile)) {
316
+ if (!Array.isArray(profile.outer)) throw new Error("offsetPolygon: profile must be a point list or {outer, holes}");
317
+ const region = { outer: offsetPolygon(profile.outer, delta, opts) };
318
+ if (profile.holes) region.holes = profile.holes.map((h) => offsetPolygon(h, -delta, opts));
319
+ return region;
320
+ }
321
+ if (!Array.isArray(profile)) throw new Error("offsetPolygon: profile must be a point list or {outer, holes}");
322
+ if (typeof delta !== "number" || !Number.isFinite(delta)) throw new Error("offsetPolygon: delta must be a finite number");
323
+ if (corners !== "round" && corners !== "chamfer" && corners !== "sharp")
324
+ throw new Error('offsetPolygon: corners must be "round" | "chamfer" | "sharp"');
325
+ for (const p of profile)
326
+ if (!Array.isArray(p) || !Number.isFinite(p[0]) || !Number.isFinite(p[1]))
327
+ throw new Error("offsetPolygon: coordinates must be finite numbers");
328
+
329
+ const pts = dedupePoints(profile);
330
+ if (pts.length < 3) throw new Error("offsetPolygon: need at least 3 points");
331
+ if (polySignedArea(pts) < 0) pts.reverse(); // work in CCW
332
+ if (!isSimplePolygon(pts)) throw new Error("offsetPolygon: input polygon self-intersects");
333
+ if (delta === 0) return pts;
334
+
335
+ // Each edge i (pts[i] → pts[i+1]): unit direction and endpoints displaced
336
+ // along the outward normal (CCW ⇒ outward = (dy, −dx)).
337
+ const n = pts.length, dir = [], off = [];
338
+ for (let i = 0; i < n; i++) {
339
+ const p = pts[i], q = pts[(i + 1) % n];
340
+ const len = Math.hypot(q[0] - p[0], q[1] - p[1]);
341
+ const d = [(q[0] - p[0]) / len, (q[1] - p[1]) / len];
342
+ const mx = d[1] * delta, my = -d[0] * delta;
343
+ dir.push(d);
344
+ off.push([[p[0] + mx, p[1] + my], [q[0] + mx, q[1] + my]]);
345
+ }
346
+
347
+ // Join edge (i−1)'s offset to edge i's offset at each original vertex. Also
348
+ // track each edge's start/end parameter along its own direction: a heavily
349
+ // over-inset edge can be trimmed from both ends past its own start and still
350
+ // trace out a simple-looking (but phantom, reflected) loop — isSimplePolygon
351
+ // only catches crossing segments, not a reversed edge, so that case needs
352
+ // this separate check below.
353
+ const out = [];
354
+ const tStart = new Array(n), tEnd = new Array(n);
355
+ const paramOf = (i, p) => (p[0] - off[i][0][0]) * dir[i][0] + (p[1] - off[i][0][1]) * dir[i][1];
356
+ const join = (prev, i, point) => {
357
+ out.push(point);
358
+ tEnd[prev] = paramOf(prev, point);
359
+ tStart[i] = paramOf(i, point);
360
+ };
361
+ for (let i = 0; i < n; i++) {
362
+ const prev = (i + n - 1) % n, V = pts[i];
363
+ const endPrev = off[prev][1], startNext = off[i][0];
364
+ const cross = dir[prev][0] * dir[i][1] - dir[prev][1] * dir[i][0];
365
+
366
+ if (Math.abs(cross) < OFFSET_EPS) { join(prev, i, endPrev); continue; } // straight vertex
367
+
368
+ if (cross * delta < 0) { // offset edges cross → trim
369
+ const m = lineIntersect(off[prev][0], dir[prev], off[i][0], dir[i]);
370
+ join(prev, i, m ?? endPrev);
371
+ continue;
372
+ }
373
+
374
+ // Offset edges diverge → fill the wedge per style.
375
+ if (corners === "sharp") {
376
+ const m = lineIntersect(off[prev][0], dir[prev], off[i][0], dir[i]);
377
+ if (m && Math.hypot(m[0] - V[0], m[1] - V[1]) <= 2 * Math.abs(delta)) { join(prev, i, m); continue; }
378
+ out.push(endPrev, startNext); // past the miter limit → chamfer
379
+ tEnd[prev] = paramOf(prev, endPrev); tStart[i] = paramOf(i, startNext);
380
+ } else if (corners === "chamfer") {
381
+ out.push(endPrev, startNext);
382
+ tEnd[prev] = paramOf(prev, endPrev); tStart[i] = paramOf(i, startNext);
383
+ } else { // round: short arc about V
384
+ tEnd[prev] = paramOf(prev, endPrev); tStart[i] = paramOf(i, startNext);
385
+ const a0 = Math.atan2(endPrev[1] - V[1], endPrev[0] - V[0]);
386
+ let dA = Math.atan2(startNext[1] - V[1], startNext[0] - V[0]) - a0;
387
+ while (dA <= -Math.PI) dA += 2 * Math.PI;
388
+ while (dA > Math.PI) dA -= 2 * Math.PI;
389
+ const r = Math.abs(delta);
390
+ for (let s = 0; s <= segs; s++) {
391
+ const a = a0 + (dA * s) / segs;
392
+ out.push([V[0] + r * Math.cos(a), V[1] + r * Math.sin(a)]);
393
+ }
394
+ }
395
+ }
396
+
397
+ for (let i = 0; i < n; i++)
398
+ if (tEnd[i] < tStart[i] - OFFSET_EPS) throw new Error("offsetPolygon: offset collapses the polygon");
399
+
400
+ const cleaned = dedupePoints(out);
401
+ if (cleaned.length < 3 || polySignedArea(cleaned) <= OFFSET_EPS)
402
+ throw new Error("offsetPolygon: offset collapses the polygon");
403
+ if (!isSimplePolygon(cleaned))
404
+ throw new Error("offsetPolygon: offset result self-intersects (reduce |delta| or simplify the profile)");
405
+ return cleaned;
406
+ }
@@ -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
- --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;
6
+ --pf-bg: oklch(0.19 0.01 262); --pf-surface: oklch(0.23 0.011 262); --pf-surface-2: oklch(0.274 0.006 286.033); --pf-border: oklch(1 0 0 / 12%);
7
+ --pf-text: oklch(0.985 0 0); --pf-text-strong: oklch(1 0 0); --pf-text-2: oklch(0.92 0.004 286.32);
8
+ --pf-muted: oklch(0.705 0.015 286.067); --pf-muted-2: oklch(0.80 0.01 286); --pf-status: oklch(0.705 0.015 286.067); --pf-hint: oklch(0.552 0.016 285.938);
9
+ --pf-accent: #3f7bf0; --pf-accent-soft: #26314a; --pf-on-accent: #fff; --pf-input-bg: oklch(0.165 0.009 262); --pf-err: oklch(0.704 0.191 22.216);
10
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
- --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;
14
+ --pf-bg: oklch(1 0 0); --pf-surface: oklch(1 0 0); --pf-surface-2: oklch(0.967 0.001 286.375); --pf-border: oklch(0.92 0.004 286.32);
15
+ --pf-text: oklch(0.141 0.005 285.823); --pf-text-strong: oklch(0.10 0.005 285.823); --pf-text-2: oklch(0.21 0.006 285.885);
16
+ --pf-muted: oklch(0.552 0.016 285.938); --pf-muted-2: oklch(0.44 0.015 285.9); --pf-status: oklch(0.552 0.016 285.938); --pf-hint: oklch(0.705 0.015 286.067);
17
+ --pf-accent: #1f5bd6; --pf-accent-soft: #e6edfc; --pf-on-accent: #fff; --pf-input-bg: #ffffff; --pf-err: oklch(0.577 0.245 27.325);
18
18
  }
@@ -9,6 +9,8 @@
9
9
  // pens, the drainage hole is a real functional choice (planter vs. cup), and dropping
10
10
  // Wall below the fdm-pla 1.2 mm minimum trips partforge's min-wall warning.
11
11
 
12
+ import { offsetPolygon } from "partforge/geometry";
13
+
12
14
  // A regular n-gon of circumradius R, in the XY plane, as [[x,y],…] for k.prism.
13
15
  // A small rotation seats a flat edge toward the viewer so even-sided shapes read right.
14
16
  const ngon = (R, n) => {
@@ -69,14 +71,18 @@ export default {
69
71
  // build needs, sized so the wall stays even (see build()).
70
72
  derive: (p) => {
71
73
  const Rout = p.dia / 2;
72
- // Offset the inner polygon inward by `wall` along the FACE normals, not the radius:
73
- // for a regular n-gon an edge offset of `wall` shrinks the circumradius by
74
- // wall / cos(π/n). This keeps the perpendicular wall = `wall` on every flat.
75
- // clamp only matters if wall is set past the slider bounds via the API
76
- const Rin = Math.max(Rout - p.wall / Math.cos(Math.PI / p.facets), 1);
74
+ // Inset the outer polygon inward by `wall` along the FACE normals via
75
+ // offsetPolygon (sharp corners keep the n-gon exact see the closed-form
76
+ // pin in test/offset-polygon.test.js). Cap the wall so the inset can never
77
+ // collapse below circumradius 1 same clamp as the old closed form (only
78
+ // matters if wall is set past the slider bounds via the API).
79
+ const wall = Math.min(p.wall, (Rout - 1) * Math.cos(Math.PI / p.facets));
80
+ const outerPts = ngon(Rout, p.facets);
81
+ const innerPts = offsetPolygon(outerPts, -wall, { corners: "sharp" });
82
+ const Rin = Math.hypot(innerPts[0][0], innerPts[0][1]); // read back from the geometry
77
83
  return {
78
- outerPts: ngon(Rout, p.facets),
79
- innerPts: ngon(Rin, p.facets),
84
+ outerPts,
85
+ innerPts,
80
86
  // Inner taper that holds the wall constant top-to-bottom even as the body flares:
81
87
  // pick it so inner_radius(top) = outer_radius(top) − wall.
82
88
  innerTaper: 1 + (Rout * (p.taper - 1)) / Rin,