partforge 0.16.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.
@@ -151,11 +151,18 @@ k.prism({ points: roundedProfile(bracketOutline, 3), h: 4 }); // true CIRCLE co
151
151
  // print clearance on an arbitrary cut profile, or an inset wall
152
152
  k.extrude({ profile: offsetPolygon(slotPolygon(20, 3), 0.2), h: 10 }); // slot cut 0.2 mm looser all around
153
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 });
154
161
  ```
155
162
 
156
163
  2-D polygon helpers for `prism`/`extrude`/`loft`: `import { piePolygon, hexPolygon,
157
164
  regularPolygon, roundedRectPolygon, starPolygon, slotPolygon, circleProfile, filletPolygon,
158
- roundedProfile, offsetPolygon } from "partforge/geometry"`. `filletPolygon(points, r, { segs? })` rounds
165
+ roundedProfile, offsetPolygon, pathProfile } from "partforge/geometry"`. `filletPolygon(points, r, { segs? })` rounds
159
166
  every corner of a CCW polygon (per-corner radius clamped so neighbouring arcs never overlap)
160
167
  and returns points usable by `prism`/`extrude`/`loft` on both backends — but it **bakes each
161
168
  corner into line facets**, so STEP corners are faceted. `roundedProfile(points, r | r[])`
@@ -173,6 +180,7 @@ an offset whose true result would collapse or split into multiple contours (e.g.
173
180
  dumbbell past its waist) **throws** a greppable error rather than returning degenerate
174
181
  geometry. Being pure, it works in `derive()` as well as `build()` — the natural home for
175
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.
176
184
  **Import geometry helpers from `partforge/geometry`, never from `partforge`** — the main
177
185
  entry pulls in the DOM viewer/controls, and your build functions run in a Web Worker
178
186
  (importing the main entry there throws `document is not defined`).
@@ -221,6 +221,18 @@ Variant literals under this entry: `offsetPolygon: delta must be a finite number
221
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
222
  - **Fix:** reduce `|delta|`, or decompose the profile into separately-offset simple contours.
223
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
+
224
236
  # Hardware library
225
237
 
226
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.16.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",
@@ -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 }
@@ -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
  }