partforge 0.112.0 → 0.113.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.
@@ -436,6 +436,7 @@ offsetPolygon(outline, -wall, { corners: "sharp" }); // inse
436
436
  const tab = pathProfile([0, -w / 2])
437
437
  .lineTo([len, -w / 2])
438
438
  .arcTo([len, w / 2], [len + w / 2, 0]) // tip, via the apex
439
+ // same arc: .arcTo([len, w / 2], { r: w / 2 }) — radius form, no via to compute
439
440
  .lineTo([0, w / 2])
440
441
  .close();
441
442
  k.extrude({ profile: tab, h: 3 });
@@ -445,6 +446,7 @@ const lip = pathProfile([0, 0])
445
446
  .lineTo([20, 0]).lineTo([20, 8])
446
447
  .cubicTo([0, 8], [14, 16], [6, 16]) // curved top edge
447
448
  .close();
449
+ k.extrude({ profile: lip, h: 3 });
448
450
 
449
451
  // Rounded enclosure: soft vertical edges, a softer lid, a flat base.
450
452
  const shell = k.roundedBox({ size: [60, 40, 22], round: { side: 4, top: 2, bottom: 0 } });
@@ -470,7 +472,7 @@ dumbbell past its waist) **throws** a greppable error rather than returning dege
470
472
  geometry. Being pure, it works in `derive()` as well as `build()` — the natural home for
471
473
  clearance math.
472
474
  `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.
473
- `arcTo(to, via)` is a **three-point arc**: `via` is any point on the arc between the current point and `to` (its midpoint is the natural choice), and the sweep is whichever direction passes through it — so an arc's direction is a property of a point you can see, never of a sign. Build the symmetric half of a profile once and `mirrorProfile` it (see "Editing profiles") rather than writing the mirrored arcs by hand. `loft` accepts these contours as rings (every ring with the same segment signature lofts curve-to-curve).
475
+ `arcTo(to, via)` is a **three-point arc**: `via` is any point on the arc between the current point and `to` (its midpoint is the natural choice), and the sweep is whichever direction passes through it — so an arc's direction is a property of a point you can see, never of a sign. `arcTo(to, { r, sweep?, large? })` is the **radius form** for when you know the radius, not a point on the arc: it computes `via` from the current point, `to`, and `r`, emitting the exact same `{to, via}` segment the three-point form does. `sweep` names the direction the arc itself is traversed (default `"ccw"`), so on a counter-clockwise outline `"ccw"` bulges OUTWARD (a convex bump), and inward on a clockwise hole; `"cw"` is the reverse. `large` (default `false`) picks the major arc over the minor one when both are possible. A radius shorter than half the distance between the current point and `to` throws rather than being silently scaled up (the way SVG's arc command does) — the smallest circle joining the two points is a semicircle at `r = d/2`. Build the symmetric half of a profile once and `mirrorProfile` it (see "Editing profiles") rather than writing the mirrored arcs by hand. `loft` accepts these contours as rings (every ring with the same segment signature lofts curve-to-curve).
474
476
  **`pathProfile` or an authored vector file?** Reach for `pathProfile` (and the polygon helpers above) when the geometry is **computed from parameters** — a profile whose dimensions come from `p`/`d`, which a JSON file cannot see. Reach for an authored `partforge-vector` document (`k.vector2d`, see "Vector geometry" below) when the geometry is **drawn** — a logo, a faceplate outline, a decorative cutout, where each number means one thing and gets edited on its own. The two are freely composable: both produce ordinary 2-D geometry that the same booleans and editing ops accept.
475
477
  **Import geometry helpers from `partforge/geometry`, never from `partforge`** — the main
476
478
  entry pulls in the DOM viewer/controls, and your build functions run in a Web Worker
@@ -316,6 +316,12 @@ Variant literals under this entry: `offsetPolygon: delta must be a finite number
316
316
  - **Cause:** A cubic segment is missing `c1` or `c2`, or a control point is not a finite `[x,y]` (e.g. `NaN`, wrong length).
317
317
  - **Fix:** Provide both control points as finite `[x,y]`. A cubic Bézier needs two controls between the previous point and `to`.
318
318
 
319
+ ## arcto-radius-too-short
320
+
321
+ - **Symptom:** `pathProfile: arcTo r=<r> is shorter than half the chord (<half-chord>) from (<x0>, <y0>) to (<x1>, <y1>) — the smallest arc that can join these points has r=<half-chord> (a semicircle)`
322
+ - **Cause:** `pathProfile().arcTo(to, { r, sweep?, large? })`'s `r` is smaller than half the distance between the current point and `to` — no circle of that radius passes through both points.
323
+ - **Fix:** Raise `r` to at least half the chord (the message states the exact minimum), or move the endpoint closer. Unlike SVG's arc command, partforge refuses rather than silently scaling `r` up to fit — the model should learn the number it wrote was wrong rather than have it quietly corrected.
324
+
319
325
  ## shape2d-simple-not-single-region
320
326
 
321
327
  - **Symptom:** `Shape2D.simple: result has N regions, not 1 (use toRegions())`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.112.0",
3
+ "version": "0.113.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",
@@ -157,8 +157,9 @@ export function filletPolygon(points, r, { segs = 8 } = {}) {
157
157
  }
158
158
 
159
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.
160
+ // lineTo → {to}, arcTo → {to,via} (three-point arc) or {to,via} computed from a radius
161
+ // spec, cubicTo → {to,c1,c2} (cubic Bézier). close() returns the plain contour object
162
+ // (feeds extrude/revolve/prism), not a Solid.
162
163
  export function pathProfile(start) {
163
164
  const fin2 = (p, what) => {
164
165
  if (!Array.isArray(p) || p.length < 2 || !Number.isFinite(p[0]) || !Number.isFinite(p[1]))
@@ -166,12 +167,73 @@ export function pathProfile(start) {
166
167
  return [p[0], p[1]];
167
168
  };
168
169
  const s = fin2(start, "start");
170
+ let cur = s;
169
171
  const segments = [];
170
172
  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
+ lineTo(to) {
174
+ const p = fin2(to, "lineTo point");
175
+ segments.push({ to: p });
176
+ cur = p;
177
+ return api;
178
+ },
179
+ // arcTo(to, via) is the three-point form: `via` is any point on the arc.
180
+ // arcTo(to, { r, sweep?, large? }) is the radius form: `via` is computed here
181
+ // from the current point, `to`, and the radius spec so the emitted segment is
182
+ // byte-for-byte what the three-point form emits (see docs/AUTHORING-PARTS.md).
183
+ arcTo(to, second) {
184
+ const p1 = fin2(to, "arcTo point");
185
+ if (Array.isArray(second)) {
186
+ const via = fin2(second, "arcTo via");
187
+ segments.push({ to: p1, via });
188
+ cur = p1;
189
+ return api;
190
+ }
191
+ if (second !== null && typeof second === "object") {
192
+ const ARC_SPEC_KEYS = ["r", "sweep", "large"];
193
+ const unknownKeys = Object.keys(second).filter((k) => !ARC_SPEC_KEYS.includes(k));
194
+ if (unknownKeys.length > 0)
195
+ throw new Error(
196
+ `pathProfile: arcTo arc spec has unknown ${unknownKeys.length > 1 ? "keys" : "key"} ${unknownKeys.map((k) => JSON.stringify(k)).join(", ")} — the keys are r, sweep, large`,
197
+ );
198
+ const { r, sweep = "ccw", large = false } = second;
199
+ // Cheap key/enum/boolean checks run BEFORE the numeric ones below, so a
200
+ // typo'd sweep/large is reported on its own rather than being masked by
201
+ // an unrelated radius complaint on the same call.
202
+ if (sweep !== "ccw" && sweep !== "cw")
203
+ throw new Error(`pathProfile: arcTo sweep must be "ccw" or "cw", got ${JSON.stringify(sweep)}`);
204
+ if (typeof large !== "boolean")
205
+ throw new Error("pathProfile: arcTo large must be a boolean");
206
+ const [x0, y0] = cur;
207
+ const [x1, y1] = p1;
208
+ const dx = x1 - x0, dy = y1 - y0;
209
+ const d = Math.hypot(dx, dy);
210
+ if (d < 1e-9)
211
+ throw new Error(`pathProfile: arcTo to (${x1}, ${y1}) coincides with the current point`);
212
+ if (!(r > 0) || !Number.isFinite(r))
213
+ throw new Error(`pathProfile: arcTo r must be > 0 and finite, got ${JSON.stringify(r)}`);
214
+ if (r < d / 2 - 1e-9)
215
+ throw new Error(
216
+ `pathProfile: arcTo r=${r} is shorter than half the chord (${(d / 2).toFixed(4)}) from (${x0}, ${y0}) to (${x1}, ${y1}) — the smallest arc that can join these points has r=${(d / 2).toFixed(4)} (a semicircle)`,
217
+ );
218
+ const rr = Math.max(r, d / 2); // absorb the 1e-9 tolerance so h is never NaN
219
+ const h = Math.sqrt(rr * rr - (d / 2) * (d / 2)); // centre's distance from the chord midpoint
220
+ const ux = dx / d, uy = dy / d; // unit chord direction
221
+ const nx = -uy, ny = ux; // unit LEFT normal of the direction of travel
222
+ const mx = (x0 + x1) / 2, my = (y0 + y1) / 2;
223
+ const side = sweep === "ccw" ? -1 : 1; // which side of the chord the arc bulges to
224
+ const sgn = large ? side : -side;
225
+ const cx = mx + nx * h * sgn, cy = my + ny * h * sgn;
226
+ const via = [cx + nx * rr * side, cy + ny * rr * side]; // the arc's midpoint
227
+ segments.push({ to: p1, via });
228
+ cur = p1;
229
+ return api;
230
+ }
231
+ throw new Error("pathProfile: arcTo needs a via [x,y] or an { r, sweep?, large? } arc spec");
232
+ },
173
233
  cubicTo(to, c1, c2) {
174
- segments.push({ to: fin2(to, "cubicTo point"), c1: fin2(c1, "cubicTo c1"), c2: fin2(c2, "cubicTo c2") });
234
+ const p = fin2(to, "cubicTo point");
235
+ segments.push({ to: p, c1: fin2(c1, "cubicTo c1"), c2: fin2(c2, "cubicTo c2") });
236
+ cur = p;
175
237
  return api;
176
238
  },
177
239
  close() {
@@ -86,6 +86,8 @@ export interface PathProfileBuilder {
86
86
  lineTo(to: Point2): PathProfileBuilder;
87
87
  /** A circular arc to `to` passing through `via`. */
88
88
  arcTo(to: Point2, via: Point2): PathProfileBuilder;
89
+ /** A circular arc to `to` of radius `r`; `via` is computed from the current point. */
90
+ arcTo(to: Point2, arc: { r: number; sweep?: "ccw" | "cw"; large?: boolean }): PathProfileBuilder;
89
91
  /** A cubic Bézier to `to` with control points `c1`/`c2`. */
90
92
  cubicTo(to: Point2, c1: Point2, c2: Point2): PathProfileBuilder;
91
93
  /** Close the contour and return it. Needs at least one segment. */