partforge 0.14.0 → 0.17.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,22 @@ 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)
154
+
155
+ // A tab with one free-form curved side (exact on STEP, faceted at mesh LOD):
156
+ const tab = pathProfile([0, 0])
157
+ .lineTo([20, 0]).lineTo([20, 8])
158
+ .cubicTo([0, 8], [14, 16], [6, 16]) // curved top edge
159
+ .close();
160
+ k.extrude({ profile: tab, h: 3 });
150
161
  ```
151
162
 
152
163
  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
164
+ regularPolygon, roundedRectPolygon, starPolygon, slotPolygon, circleProfile, filletPolygon,
165
+ roundedProfile, offsetPolygon, pathProfile } from "partforge/geometry"`. `filletPolygon(points, r, { segs? })` rounds
155
166
  every corner of a CCW polygon (per-corner radius clamped so neighbouring arcs never overlap)
156
167
  and returns points usable by `prism`/`extrude`/`loft` on both backends — but it **bakes each
157
168
  corner into line facets**, so STEP corners are faceted. `roundedProfile(points, r | r[])`
@@ -159,7 +170,17 @@ rounds corners the same way but keeps them **mathematically true** — it carrie
159
170
  symbolically so STEP export gets real circular edges. Use it for `prism`/`extrude` (not yet
160
171
  `loft` — arc rings are rejected there in v1). A scalar `r` rounds every corner; a per-corner
161
172
  `r[]` (length = points) rounds selectively (a `0`, a zero-length edge, or a straight/180°
162
- corner stays sharp).
173
+ corner stays sharp). `offsetPolygon(profile, delta, { corners?, segs? })` offsets a
174
+ point-list polygon or `{ outer, holes }` region by `delta` mm — positive grows material,
175
+ negative insets; regions offset material-wise (outer `+delta`, holes `−delta`, so a
176
+ clearance loosens the whole cut). `corners` picks the convex-corner style: `"round"`
177
+ (default; the true Minkowski clearance), `"chamfer"`, or `"sharp"` (miter, falling back to
178
+ chamfer past a miter length of 2·|delta|). It is **simple polygon in, simple polygon out**:
179
+ an offset whose true result would collapse or split into multiple contours (e.g. insetting a
180
+ dumbbell past its waist) **throws** a greppable error rather than returning degenerate
181
+ geometry. Being pure, it works in `derive()` as well as `build()` — the natural home for
182
+ clearance math.
183
+ `pathProfile(start)` is a fluent builder for a curve-native path contour (`lineTo` / `arcTo` / `cubicTo` / `close`); cubic segments become exact B-rep spline edges on the OCCT/STEP backend and facet at the mesh LOD on Manifold — the same exact-vs-faceted split as `roundedProfile` arcs.
163
184
  **Import geometry helpers from `partforge/geometry`, never from `partforge`** — the main
164
185
  entry pulls in the DOM viewer/controls, and your build functions run in a Web Worker
165
186
  (importing the main entry there throws `document is not defined`).
@@ -195,6 +195,44 @@ 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
+
224
+ ## cubic-segment-mixes-arc-and-cubic
225
+
226
+ - **Symptom:** `extrude: <role> segment cannot mix arc (via) and cubic (c1/c2)`
227
+ - **Cause:** A path-contour segment carries both `via` (three-point arc) and `c1`/`c2` (cubic Bézier). A segment is exactly one kind.
228
+ - **Fix:** Drop `via` for a cubic, or drop `c1`/`c2` for an arc. Use `pathProfile().arcTo(to, via)` or `.cubicTo(to, c1, c2)` to build segments.
229
+
230
+ ## cubic-segment-missing-controls
231
+
232
+ - **Symptom:** `extrude: <role> cubic segment needs c1 and c2 as finite [x,y]`
233
+ - **Cause:** A cubic segment is missing `c1` or `c2`, or a control point is not a finite `[x,y]` (e.g. `NaN`, wrong length).
234
+ - **Fix:** Provide both control points as finite `[x,y]`. A cubic Bézier needs two controls between the previous point and `to`.
235
+
198
236
  # Hardware library
199
237
 
200
238
  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.14.0",
3
+ "version": "0.17.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",
@@ -16,9 +16,11 @@ canvas { display: block; }
16
16
  #panel {
17
17
  position: fixed; top: 12px; left: 12px; width: 256px;
18
18
  max-height: calc(100vh - 24px); overflow-y: auto; z-index: 10;
19
- background: var(--pf-surface); border: 1px solid var(--pf-border); border-radius: 14px;
19
+ background: var(--pf-surface); border: 1px solid var(--pf-border); border-radius: 16px;
20
20
  padding: 14px; color: var(--pf-text);
21
- box-shadow: 0 4px 6px -1px rgb(0 0 0 / .1), 0 2px 4px -2px rgb(0 0 0 / .1);
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);
22
24
  }
23
25
  #panel h1 { font-size: 14px; margin: 0 0 2px; color: var(--pf-text-strong); letter-spacing: -0.01em; }
24
26
  #panel .sub {
@@ -89,8 +89,9 @@ export function createOcctKernel(replicad) {
89
89
  // Draw a closed Drawing from a Contour: a legacy 2-D point list (all straight edges,
90
90
  // the former polyDrawing) OR an ArcContour whose { to, via } segments become true
91
91
  // OCCT arc edges via threePointsArcTo — so a rounded corner survives to STEP as a
92
- // real CIRCLE B-rep entity, not a fan of LINEs. close() joins the last point back to
93
- // the start with a straight edge (mirrors the implied ArcContour closure).
92
+ // real CIRCLE B-rep entity, not a fan of LINEs. Cubic segments ({ to, c1, c2 })
93
+ // map to cubicBezierCurveTo for exact B-rep spline edges. close() joins the last
94
+ // point back to the start with a straight edge (mirrors the implied ArcContour closure).
94
95
  const contourDrawing = (contour) => {
95
96
  if (Array.isArray(contour)) {
96
97
  let pen = draw(contour[0]);
@@ -98,7 +99,10 @@ export function createOcctKernel(replicad) {
98
99
  return pen.close();
99
100
  }
100
101
  let pen = draw(contour.start);
101
- for (const seg of contour.segments) pen = seg.via ? pen.threePointsArcTo(seg.to, seg.via) : pen.lineTo(seg.to);
102
+ for (const seg of contour.segments)
103
+ pen = seg.c1 ? pen.cubicBezierCurveTo(seg.to, seg.c1, seg.c2)
104
+ : seg.via ? pen.threePointsArcTo(seg.to, seg.via)
105
+ : pen.lineTo(seg.to);
102
106
  return pen.close();
103
107
  };
104
108
 
@@ -156,6 +156,32 @@ export function filletPolygon(points, r, { segs = 8 } = {}) {
156
156
  return out;
157
157
  }
158
158
 
159
+ // Fluent builder for a curve-native path contour { start, segments }. Segment kinds:
160
+ // lineTo → {to}, arcTo → {to,via} (three-point arc), cubicTo → {to,c1,c2} (cubic Bézier).
161
+ // close() returns the plain contour object (feeds extrude/revolve/prism), not a Solid.
162
+ export function pathProfile(start) {
163
+ const fin2 = (p, what) => {
164
+ if (!Array.isArray(p) || p.length < 2 || !Number.isFinite(p[0]) || !Number.isFinite(p[1]))
165
+ throw new Error(`pathProfile: ${what} must be a finite [x,y]`);
166
+ return [p[0], p[1]];
167
+ };
168
+ const s = fin2(start, "start");
169
+ const segments = [];
170
+ const api = {
171
+ lineTo(to) { segments.push({ to: fin2(to, "lineTo point") }); return api; },
172
+ arcTo(to, via) { segments.push({ to: fin2(to, "arcTo point"), via: fin2(via, "arcTo via") }); return api; },
173
+ cubicTo(to, c1, c2) {
174
+ segments.push({ to: fin2(to, "cubicTo point"), c1: fin2(c1, "cubicTo c1"), c2: fin2(c2, "cubicTo c2") });
175
+ return api;
176
+ },
177
+ close() {
178
+ if (segments.length < 1) throw new Error("pathProfile: need ≥1 segment before close()");
179
+ return { start: [s[0], s[1]], segments: segments.slice() }; // snapshot — chaining after close() must not mutate the returned contour
180
+ },
181
+ };
182
+ return api;
183
+ }
184
+
159
185
  // Arc-aware sibling of filletPolygon: rounds the corners of a CCW polygon with the SAME
160
186
  // tangent/centre/sweep math (via cornerArc), but instead of tessellating each arc into
161
187
  // line facets it emits a canonical ArcContour { start, segments:[{to}|{to,via}], arc:true }
@@ -227,3 +253,180 @@ export function circleProfile(r, center = [0, 0], segs = 48) {
227
253
  }
228
254
  return pts;
229
255
  }
256
+
257
+ // --- offsetPolygon ---------------------------------------------------------
258
+
259
+ const OFFSET_EPS = 1e-9;
260
+
261
+ // Fresh copies, consecutive duplicates dropped, closing point (== first) dropped.
262
+ function dedupePoints(points) {
263
+ const out = [];
264
+ for (const p of points) {
265
+ const last = out[out.length - 1];
266
+ if (!last || Math.hypot(p[0] - last[0], p[1] - last[1]) > OFFSET_EPS) out.push([p[0], p[1]]);
267
+ }
268
+ while (out.length > 1 &&
269
+ Math.hypot(out[0][0] - out[out.length - 1][0], out[0][1] - out[out.length - 1][1]) <= OFFSET_EPS) out.pop();
270
+ return out;
271
+ }
272
+
273
+ function polySignedArea(pts) {
274
+ let a = 0;
275
+ for (let i = 0; i < pts.length; i++) {
276
+ const [x1, y1] = pts[i], [x2, y2] = pts[(i + 1) % pts.length];
277
+ a += x1 * y2 - x2 * y1;
278
+ }
279
+ return a / 2;
280
+ }
281
+
282
+ // True where segments a-b and c-d properly cross. Shared endpoints and collinear
283
+ // touches don't count — adjacent edges always share a vertex, and the collinear
284
+ // case is degenerate input the dedupe/straight-vertex paths already absorb.
285
+ function segmentsCross(a, b, c, d) {
286
+ const orient = (p, q, r) => (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]);
287
+ return orient(a, b, c) * orient(a, b, d) < 0 && orient(c, d, a) * orient(c, d, b) < 0;
288
+ }
289
+
290
+ // True where p lies strictly inside segment a-b (not at either endpoint) —
291
+ // a degenerate touch that segmentsCross's proper-crossing test doesn't see.
292
+ function pointOnSegment(p, a, b) {
293
+ const cross = (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]);
294
+ if (Math.abs(cross) > OFFSET_EPS) return false;
295
+ const dot = (p[0] - a[0]) * (b[0] - a[0]) + (p[1] - a[1]) * (b[1] - a[1]);
296
+ const lenSq = (b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2;
297
+ return dot > OFFSET_EPS && dot < lenSq - OFFSET_EPS;
298
+ }
299
+
300
+ // O(n²) simplicity test — trivial at profile point counts (tens to hundreds).
301
+ function isSimplePolygon(pts) {
302
+ const n = pts.length;
303
+ for (let i = 0; i < n; i++) {
304
+ for (let j = i + 1; j < n; j++) {
305
+ if (j === i + 1 || (i === 0 && j === n - 1)) continue; // adjacent edges share a vertex
306
+ const [a, b] = [pts[i], pts[(i + 1) % n]], [c, d] = [pts[j], pts[(j + 1) % n]];
307
+ if (segmentsCross(a, b, c, d)) return false;
308
+ // A vertex landing exactly on a non-adjacent edge is also a self-touch —
309
+ // e.g. an inset waist pinching two opposite edges together.
310
+ if (pointOnSegment(a, c, d) || pointOnSegment(b, c, d) ||
311
+ pointOnSegment(c, a, b) || pointOnSegment(d, a, b)) return false;
312
+ }
313
+ }
314
+ return true;
315
+ }
316
+
317
+ // Intersection of two infinite lines given as point + unit direction; null if parallel.
318
+ function lineIntersect(p, dp, q, dq) {
319
+ const denom = dp[0] * dq[1] - dp[1] * dq[0];
320
+ if (Math.abs(denom) < OFFSET_EPS) return null;
321
+ const t = ((q[0] - p[0]) * dq[1] - (q[1] - p[1]) * dq[0]) / denom;
322
+ return [p[0] + dp[0] * t, p[1] + dp[1] * t];
323
+ }
324
+
325
+ // Offset a profile by `delta` mm: positive grows material outward, negative
326
+ // insets. `profile` is a CCW [[x,y],…] point list (either winding accepted,
327
+ // output always CCW) or an {outer, holes} region — regions offset as material:
328
+ // outer by +delta, holes by −delta, so a +0.2 clearance on a cut region loosens
329
+ // the whole cut. Corners where the offset edges diverge fill per `corners`:
330
+ // "round" (arc of radius |delta| about the original vertex, `segs` segments —
331
+ // the true Minkowski clearance), "chamfer" (the arc's chord), or "sharp" (true
332
+ // miter, falling back to chamfer past a miter length of 2·|delta|). Where
333
+ // offset edges cross (reflex on outset, convex on inset) they trim to their
334
+ // intersection regardless of style. Simple polygon in, simple polygon out:
335
+ // a result that self-intersects or vanishes THROWS (greppable errors below)
336
+ // rather than returning degenerate geometry — offsets that would split a
337
+ // region (dumbbell insets) are out of scope. Pure and deterministic; usable in
338
+ // derive() and build() alike. See AUTHORING-PARTS.md "Profiles & patterns".
339
+ export function offsetPolygon(profile, delta, opts = {}) {
340
+ const { corners = "round", segs = 8 } = opts;
341
+ if (profile !== null && typeof profile === "object" && !Array.isArray(profile)) {
342
+ if (!Array.isArray(profile.outer)) throw new Error("offsetPolygon: profile must be a point list or {outer, holes}");
343
+ const region = { outer: offsetPolygon(profile.outer, delta, opts) };
344
+ if (profile.holes) region.holes = profile.holes.map((h) => offsetPolygon(h, -delta, opts));
345
+ return region;
346
+ }
347
+ if (!Array.isArray(profile)) throw new Error("offsetPolygon: profile must be a point list or {outer, holes}");
348
+ if (typeof delta !== "number" || !Number.isFinite(delta)) throw new Error("offsetPolygon: delta must be a finite number");
349
+ if (corners !== "round" && corners !== "chamfer" && corners !== "sharp")
350
+ throw new Error('offsetPolygon: corners must be "round" | "chamfer" | "sharp"');
351
+ for (const p of profile)
352
+ if (!Array.isArray(p) || !Number.isFinite(p[0]) || !Number.isFinite(p[1]))
353
+ throw new Error("offsetPolygon: coordinates must be finite numbers");
354
+
355
+ const pts = dedupePoints(profile);
356
+ if (pts.length < 3) throw new Error("offsetPolygon: need at least 3 points");
357
+ if (polySignedArea(pts) < 0) pts.reverse(); // work in CCW
358
+ if (!isSimplePolygon(pts)) throw new Error("offsetPolygon: input polygon self-intersects");
359
+ if (delta === 0) return pts;
360
+
361
+ // Each edge i (pts[i] → pts[i+1]): unit direction and endpoints displaced
362
+ // along the outward normal (CCW ⇒ outward = (dy, −dx)).
363
+ const n = pts.length, dir = [], off = [];
364
+ for (let i = 0; i < n; i++) {
365
+ const p = pts[i], q = pts[(i + 1) % n];
366
+ const len = Math.hypot(q[0] - p[0], q[1] - p[1]);
367
+ const d = [(q[0] - p[0]) / len, (q[1] - p[1]) / len];
368
+ const mx = d[1] * delta, my = -d[0] * delta;
369
+ dir.push(d);
370
+ off.push([[p[0] + mx, p[1] + my], [q[0] + mx, q[1] + my]]);
371
+ }
372
+
373
+ // Join edge (i−1)'s offset to edge i's offset at each original vertex. Also
374
+ // track each edge's start/end parameter along its own direction: a heavily
375
+ // over-inset edge can be trimmed from both ends past its own start and still
376
+ // trace out a simple-looking (but phantom, reflected) loop — isSimplePolygon
377
+ // only catches crossing segments, not a reversed edge, so that case needs
378
+ // this separate check below.
379
+ const out = [];
380
+ const tStart = new Array(n), tEnd = new Array(n);
381
+ const paramOf = (i, p) => (p[0] - off[i][0][0]) * dir[i][0] + (p[1] - off[i][0][1]) * dir[i][1];
382
+ const join = (prev, i, point) => {
383
+ out.push(point);
384
+ tEnd[prev] = paramOf(prev, point);
385
+ tStart[i] = paramOf(i, point);
386
+ };
387
+ for (let i = 0; i < n; i++) {
388
+ const prev = (i + n - 1) % n, V = pts[i];
389
+ const endPrev = off[prev][1], startNext = off[i][0];
390
+ const cross = dir[prev][0] * dir[i][1] - dir[prev][1] * dir[i][0];
391
+
392
+ if (Math.abs(cross) < OFFSET_EPS) { join(prev, i, endPrev); continue; } // straight vertex
393
+
394
+ if (cross * delta < 0) { // offset edges cross → trim
395
+ const m = lineIntersect(off[prev][0], dir[prev], off[i][0], dir[i]);
396
+ join(prev, i, m ?? endPrev);
397
+ continue;
398
+ }
399
+
400
+ // Offset edges diverge → fill the wedge per style.
401
+ if (corners === "sharp") {
402
+ const m = lineIntersect(off[prev][0], dir[prev], off[i][0], dir[i]);
403
+ if (m && Math.hypot(m[0] - V[0], m[1] - V[1]) <= 2 * Math.abs(delta)) { join(prev, i, m); continue; }
404
+ out.push(endPrev, startNext); // past the miter limit → chamfer
405
+ tEnd[prev] = paramOf(prev, endPrev); tStart[i] = paramOf(i, startNext);
406
+ } else if (corners === "chamfer") {
407
+ out.push(endPrev, startNext);
408
+ tEnd[prev] = paramOf(prev, endPrev); tStart[i] = paramOf(i, startNext);
409
+ } else { // round: short arc about V
410
+ tEnd[prev] = paramOf(prev, endPrev); tStart[i] = paramOf(i, startNext);
411
+ const a0 = Math.atan2(endPrev[1] - V[1], endPrev[0] - V[0]);
412
+ let dA = Math.atan2(startNext[1] - V[1], startNext[0] - V[0]) - a0;
413
+ while (dA <= -Math.PI) dA += 2 * Math.PI;
414
+ while (dA > Math.PI) dA -= 2 * Math.PI;
415
+ const r = Math.abs(delta);
416
+ for (let s = 0; s <= segs; s++) {
417
+ const a = a0 + (dA * s) / segs;
418
+ out.push([V[0] + r * Math.cos(a), V[1] + r * Math.sin(a)]);
419
+ }
420
+ }
421
+ }
422
+
423
+ for (let i = 0; i < n; i++)
424
+ if (tEnd[i] < tStart[i] - OFFSET_EPS) throw new Error("offsetPolygon: offset collapses the polygon");
425
+
426
+ const cleaned = dedupePoints(out);
427
+ if (cleaned.length < 3 || polySignedArea(cleaned) <= OFFSET_EPS)
428
+ throw new Error("offsetPolygon: offset collapses the polygon");
429
+ if (!isSimplePolygon(cleaned))
430
+ throw new Error("offsetPolygon: offset result self-intersects (reduce |delta| or simplify the profile)");
431
+ return cleaned;
432
+ }
@@ -1,23 +1,37 @@
1
1
  // Backend-shared 2-D region normalization + tessellation for extrude()/prism(). A contour
2
- // is EITHER a bare points array (legacy, all straight edges) OR a canonical ArcContour
3
- // { start:[x,y], segments:[{to}|{to,via}], arc:true } carrying true circular arcs (from
4
- // roundedProfile). normalizeProfile validates the polymorphic { outer, holes } envelope
5
- // (bare array = outer only), preserving each contour's shape; tessellateProfile turns the
6
- // arcs into point rings for the Manifold (mesh) path. The OCCT path consumes the same
7
- // ArcContour directly (contourDrawing threePointsArcTo) for true CIRCLE B-rep edges.
8
- // Legacy point-array contours take the exact former path byte-for-byte no cache-busting.
2
+ // is EITHER a bare points array (legacy, all straight edges) OR a canonical path contour
3
+ // { start:[x,y], segments:[{to}|{to,via}|{to,c1,c2}] } carrying true circular arcs ({to,via},
4
+ // from roundedProfile) and/or cubic Béziers ({to,c1,c2}, from pathProfile). normalizeProfile
5
+ // validates the polymorphic { outer, holes } envelope (bare array = outer only), preserving
6
+ // each contour's shape; tessellateProfile turns arcs/cubics into point rings for the Manifold
7
+ // (mesh) path at the mesh LOD. The OCCT path consumes the same contour directly (contourDrawing
8
+ // threePointsArcTo / cubicBezierCurveTo) for true CIRCLE / B-spline B-rep edges. Legacy
9
+ // point-array contours take the exact former path byte-for-byte — no cache-busting.
9
10
 
10
11
  // An ArcContour is a non-array object carrying arcs symbolically.
11
12
  export function isArcContour(c) {
12
13
  return !!c && typeof c === "object" && !Array.isArray(c) && (c.arc === true || Array.isArray(c.segments));
13
14
  }
14
15
 
16
+ // Curves generalize arcs; the symbolic-form predicate is the same. Prefer this name.
17
+ export const isPathContour = isArcContour;
18
+
15
19
  function validateContour(c, role) {
16
20
  if (isArcContour(c)) {
17
21
  if (!Array.isArray(c.start) || c.start.length < 2)
18
22
  throw new Error(`extrude: ${role} arc contour needs a start [x,y]`);
19
23
  if (!Array.isArray(c.segments) || c.segments.length < 1)
20
24
  throw new Error(`extrude: ${role} arc contour needs ≥1 segment`);
25
+ for (const s of c.segments) {
26
+ const hasCubic = s.c1 != null || s.c2 != null;
27
+ if (hasCubic) {
28
+ if (s.via != null)
29
+ throw new Error(`extrude: ${role} segment cannot mix arc (via) and cubic (c1/c2)`);
30
+ const ok = (p) => Array.isArray(p) && p.length >= 2 && Number.isFinite(p[0]) && Number.isFinite(p[1]);
31
+ if (!ok(s.c1) || !ok(s.c2))
32
+ throw new Error(`extrude: ${role} cubic segment needs c1 and c2 as finite [x,y]`);
33
+ }
34
+ }
21
35
  return;
22
36
  }
23
37
  if (!Array.isArray(c) || c.length < 3) throw new Error(`extrude: ${role} needs ≥3 points`);
@@ -73,15 +87,50 @@ export function sampleArc(p0, via, p1, segs) {
73
87
  return out;
74
88
  }
75
89
 
90
+ // Flatten the cubic Bézier (p0,c1,c2,p1) into points p1…pN — EXCLUDING the start
91
+ // p0 (the ring already holds it), last point pinned exactly to p1. Adaptive: split
92
+ // at t=½ (de Casteljau) until the control polygon's total unsigned turn is ≤ 2π/segs
93
+ // — the exact generalization of sampleArc's "a point every 2π/segs of sweep", so a
94
+ // cubic tracing a circular arc facets like the arc primitive at the same segs. Summing
95
+ // |turn| at BOTH interior control points also catches S-curves a pure endpoint-tangent
96
+ // test would miss. Depth cap guarantees termination. Pure in (args, segs).
97
+ export function sampleBezier(p0, c1, c2, p1, segs) {
98
+ const maxTurn = (2 * Math.PI) / Math.max(3, segs);
99
+ const out = [];
100
+ const mid = (a, b) => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
101
+ const turn = (u, v) => {
102
+ const du = Math.hypot(u[0], u[1]), dv = Math.hypot(v[0], v[1]);
103
+ if (du < 1e-12 || dv < 1e-12) return 0;
104
+ let c = (u[0] * v[0] + u[1] * v[1]) / (du * dv);
105
+ if (c > 1) c = 1; else if (c < -1) c = -1;
106
+ return Math.acos(c);
107
+ };
108
+ const recurse = (a, b, c, d, depth) => {
109
+ const ab = [b[0] - a[0], b[1] - a[1]];
110
+ const bc = [c[0] - b[0], c[1] - b[1]];
111
+ const cd = [d[0] - c[0], d[1] - c[1]];
112
+ if (depth >= 12 || turn(ab, bc) + turn(bc, cd) <= maxTurn) { out.push([d[0], d[1]]); return; }
113
+ const p01 = mid(a, b), p12 = mid(b, c), p23 = mid(c, d);
114
+ const p012 = mid(p01, p12), p123 = mid(p12, p23), m = mid(p012, p123);
115
+ recurse(a, p01, p012, m, depth + 1);
116
+ recurse(m, p123, p23, d, depth + 1);
117
+ };
118
+ recurse(p0, c1, c2, p1, 0);
119
+ if (out.length === 0) out.push([p1[0], p1[1]]);
120
+ out[out.length - 1] = [p1[0], p1[1]]; // pin the exact endpoint
121
+ return out;
122
+ }
123
+
76
124
  // Tessellate a single contour into a CCW point ring. A legacy array is returned unchanged
77
- // (identical to the former path); an ArcContour is walked start→segment→segment, lines
78
- // pushing their `to` and arcs pushing their sampled points.
125
+ // (identical to the former path); a path contour is walked start→segment→segment, lines
126
+ // pushing their `to`, arcs and cubics pushing their sampled points (sampleArc/sampleBezier).
79
127
  export function tessellateContour(contour, segs) {
80
128
  if (Array.isArray(contour)) return contour;
81
129
  const ring = [[contour.start[0], contour.start[1]]];
82
130
  let prev = contour.start;
83
131
  for (const seg of contour.segments) {
84
- if (seg.via) for (const p of sampleArc(prev, seg.via, seg.to, segs)) ring.push(p);
132
+ if (seg.c1) for (const p of sampleBezier(prev, seg.c1, seg.c2, seg.to, segs)) ring.push(p);
133
+ else if (seg.via) for (const p of sampleArc(prev, seg.via, seg.to, segs)) ring.push(p);
85
134
  else ring.push([seg.to[0], seg.to[1]]);
86
135
  prev = seg.to;
87
136
  }
@@ -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,