partforge 0.16.0 → 0.19.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`).
@@ -466,6 +474,30 @@ const hole = k.cylinder({ r: 2, h: 20 }).translate([20, 0, 0]);
466
474
  body = body.cutAll(circularPattern(hole, 8, { axis: "Z" })); // 8 bolt holes on a 40mm circle
467
475
  ```
468
476
 
477
+ ## 2-D booleans
478
+
479
+ `k.shape2d(profile)` lifts a point list, arc profile, or region into a `Shape2D` — an opaque 2-D boolean value. You can then compose booleans, and feed the result directly to `extrude` or `revolve` without materializing intermediate regions. The same `content-hash caching` discipline applies: identical arguments produce identical geometry.
480
+
481
+ **Shape2D booleans are a build-time operation** (not `derive()`), and the curve semantics differ between backends: on OCCT the result carries exact circular arcs and Bézier curves into STEP export; on Manifold the curves facet to mesh LOD.
482
+
483
+ ```js
484
+ // Keyhole plate: union a disc onto a rect, punch a slot, extrude.
485
+ const plate = k.shape2d(roundedRectPolygon(40, 24, 4))
486
+ .union(circleProfile(8))
487
+ .cut(slotPolygon(16, 3));
488
+ k.extrude({ profile: plate, h: 3 });
489
+ ```
490
+
491
+ ```js
492
+ // A 0.2 mm printer clearance around a bore, then a 2 mm wall inset:
493
+ const bore = k.shape2d(circleProfile(3)).offset(0.2); // looser
494
+ const wall = k.shape2d(outer).offset(-2, { corners: "sharp" }); // inset, mitered
495
+ ```
496
+
497
+ (This achieves the same geometry as building the profiles separately and using `k.extrude({ profile: { outer, holes }, h })`, but the Shape2D path is more idiomatic for complex 2-D operations.)
498
+
499
+ `Shape2D.offset(delta, {corners})` grows (`delta>0`) or insets (`delta<0`) a shape with round/chamfer/sharp corners — curve-preserving on OCCT, faceted at mesh LOD on Manifold; it throws if the offset collapses the shape. (For `derive()`/main-thread clearance math on plain point lists, use the pure `offsetPolygon` helper instead.)
500
+
469
501
  ---
470
502
 
471
503
  ## Wiring a part into a runnable app
@@ -221,6 +221,33 @@ 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
+
236
+ ## shape2d-simple-not-single-region
237
+
238
+ - **Symptom:** `Shape2D.simple: result has N regions, not 1 (use toRegions())`
239
+ - **Cause:** `.simple()` was called on a boolean result that is empty or split into multiple disjoint regions (e.g. `intersect` of disjoint shapes, or a `cut` that severs a shape in two).
240
+ - **Fix:** Use `.toRegions()` to get the array, or adjust the operands so the result is a single connected region. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "2-D booleans".
241
+
242
+ ## shape2d-offset-collapses
243
+
244
+ - **Symptom:** `Shape2D.offset: offset collapses the shape (reduce |delta|)`
245
+ - **Cause:** A negative (inset) `offset` removed more than the shape's half-width,
246
+ leaving no geometry — or the delta is larger than the feature it offsets.
247
+ - **Fix:** Reduce `|delta|`, or check the source profile is large enough for the
248
+ inset. Realistic clearances (fractions of a mm) and wall insets up to the
249
+ narrowest feature never trip this.
250
+
224
251
  # Hardware library
225
252
 
226
253
  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.19.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",
@@ -8,7 +8,9 @@
8
8
  // cache hashes normalized args;
9
9
  // - default compound-op compositions — a backend only overrides one when it has
10
10
  // a reason to (Manifold's boredCylinder hashes atomically for its solid cache);
11
- // - a KernelCapabilityError stub for toSTEP when the backend can't write B-rep.
11
+ // - a KernelCapabilityError stub for toSTEP / shape2d when a backend lacks that
12
+ // capability (Manifold can't do toSTEP; both backends now define shape2d, so
13
+ // that stub is dead in practice — kept as a safety net for a future backend).
12
14
  // The per-Solid twin of this layer is addSugar() in solid-sugar.js.
13
15
  import { KernelCapabilityError } from "./errors.js";
14
16
  import { isPlainOptions, KERNEL_OP_SPECS } from "./op-options.js";
@@ -31,6 +33,7 @@ export function finishKernel(k) {
31
33
  }
32
34
 
33
35
  k.toSTEP ??= () => { throw new KernelCapabilityError("toSTEP requires the OCCT backend"); };
36
+ k.shape2d ??= () => { throw new KernelCapabilityError("shape2d requires the Manifold backend"); };
34
37
 
35
38
  return k;
36
39
  }
@@ -19,7 +19,7 @@ export const CONTRACT_VERSION = 1;
19
19
  // Ops every backend kernel must implement.
20
20
  export const KERNEL_OPS = [
21
21
  "cylinder", "boredCylinder", "sphere", "box", "prism", "extrude", "revolve",
22
- "loft", "sweep", "helixSweptTube", "union", "toSTEP",
22
+ "loft", "sweep", "helixSweptTube", "union", "shape2d", "toSTEP",
23
23
  ];
24
24
 
25
25
  // Backend-optional kernel ops: the Manifold cache brackets + WASM lifetime hooks.
@@ -30,7 +30,7 @@ export const KERNEL_OPTIONAL_OPS = [
30
30
 
31
31
  // Ops every Solid must implement (including the sugar addSugar() attaches).
32
32
  export const SOLID_OPS = [
33
- "cut", "cutAll", "intersect", "clone", "label", "boundingBox", "volume",
33
+ "cut", "cutAll", "intersect", "union", "clone", "label", "boundingBox", "volume",
34
34
  "translate", "rotate", "rotateX", "rotateY", "rotateZ", "rotateAbout", "along", "at",
35
35
  "mirror", "scale", "toMesh", "toSTL", "toIndexedMesh",
36
36
  "fillet", "chamfer", "shell",
@@ -40,6 +40,11 @@ export const SOLID_OPS = [
40
40
  // guards with `typeof`); OCCT has no cheap equivalent.
41
41
  export const SOLID_OPTIONAL_OPS = ["genus", "isEmpty"];
42
42
 
43
+ // Public methods every Shape2D exposes (2-D boolean value; contract-linted).
44
+ export const SHAPE2D_OPS = [
45
+ "union", "cut", "cutAll", "intersect", "offset", "area", "boundingBox", "toRegions", "simple", "clone",
46
+ ];
47
+
43
48
  // Solid ops only OCCT implements natively. Single source of truth: probe.js routes
44
49
  // a part to OCCT when its build uses one of these, and the Manifold backend
45
50
  // generates its KernelCapabilityError stubs from the same list — adding an op here
@@ -51,6 +56,7 @@ export const OCCT_ONLY_OPS = ["fillet", "chamfer", "shell"];
51
56
  * @property {(tool: Solid) => Solid} cut
52
57
  * @property {(tools: Solid[]) => Solid} cutAll batch subtract (backend-optimized)
53
58
  * @property {(other: Solid) => Solid} intersect boolean intersection (both backends)
59
+ * @property {(other: Solid) => Solid} union boolean union with one other solid (n-ary: k.union([...]))
54
60
  * @property {() => Solid} clone independent copy (replicad consumes solids on transform)
55
61
  * @property {(name: string) => Solid} label name this solid's surface for hover/pick feature attribution (survives transforms + booleans; same name on several solids merges into one feature)
56
62
  * @property {() => {min:number[],max:number[],center:number[],size:number[]}} boundingBox axis-aligned bounds (query)
@@ -75,6 +81,17 @@ export const OCCT_ONLY_OPS = ["fillet", "chamfer", "shell"];
75
81
  * @property {() => number} [genus] through-hole count (Manifold only)
76
82
  * @property {() => boolean} [isEmpty] no geometry at all (Manifold only)
77
83
  *
84
+ * @typedef {Object} Shape2D An opaque 2-D boolean value (both backends: Manifold wraps a CrossSection, OCCT a replicad Drawing). `_`-prefixed keys are backend internals.
85
+ * @property {(other: Shape2D|number[][]) => Shape2D} union
86
+ * @property {(other: Shape2D|number[][]) => Shape2D} cut
87
+ * @property {(others: (Shape2D|number[][])[]) => Shape2D} cutAll batch subtract
88
+ * @property {(other: Shape2D|number[][]) => Shape2D} intersect
89
+ * @property {() => number} area net area (outers minus holes), mm²
90
+ * @property {() => {min:number[],max:number[]}} boundingBox axis-aligned 2-D bounds
91
+ * @property {() => {outer:number[][],holes:number[][][]}[]} toRegions materialize into region arrays (assembleRegions)
92
+ * @property {() => {outer:number[][],holes:number[][][]}} simple toRegions(), unwrapped — throws unless exactly 1 region
93
+ * @property {() => Shape2D} clone independent handle
94
+ *
78
95
  * @typedef {Object} GeometryKernel
79
96
  * @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
80
97
  * @property {(o:{od:number,h:number,bore:number}) => Solid} boredCylinder compound: bored-through cylinder (one cache node)
@@ -87,6 +104,7 @@ export const OCCT_ONLY_OPS = ["fillet", "chamfer", "shell"];
87
104
  * @property {(o:{profile:number[][],degrees?:number}) => Solid} revolve revolve a lathe profile [[r,z],…] around Z; legacy (points,opts) accepted until v2
88
105
  * @property {(o:{pathR:number,profileR:number,pitch:number,turns:number,z0:number,lefthand:boolean}) => Solid} helixSweptTube
89
106
  * @property {(solids:Solid[]) => Solid} union
107
+ * @property {(profile: number[][]|{outer:number[][],holes?:number[][][]}|Shape2D) => Shape2D} shape2d 2-D boolean value (both backends: Manifold wraps a CrossSection, OCCT a replicad Drawing)
90
108
  * @property {(named:{name:string,solid:Solid}[]) => Promise<ArrayBuffer>} toSTEP OCCT only (Manifold throws KernelCapabilityError)
91
109
  * @property {(name:string) => void} [beginSubPart] open a per-sub-part solid-cache round (Manifold only)
92
110
  * @property {() => void} [endSubPart] close the cache round (always pair with beginSubPart)
@@ -5,6 +5,8 @@ import { tessellateContour, tessellateProfile } from "./profile.js";
5
5
  import { h } from "./solid-hash.js";
6
6
  import { createSolidCache } from "./solid-cache.js";
7
7
  import { addSugar } from "./solid-sugar.js";
8
+ import { addShape2dSugar } from "./shape2d-sugar.js";
9
+ import { assembleRegions } from "./shape2d-regions.js";
8
10
  import { finishKernel } from "./kernel-front.js";
9
11
 
10
12
  const PLANE_NORMAL = { XY: [0, 0, 1], XZ: [0, 1, 0], YZ: [1, 0, 0] };
@@ -49,6 +51,63 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
49
51
  return { value: wrap(m, hash), pin: m, dispose: () => m.delete?.() };
50
52
  });
51
53
 
54
+ // 2-D cross-section value. Mirrors wrap()/cached(): booleans route through the
55
+ // solid cache (dispose frees the CrossSection); operands fold by _hash.
56
+ const cachedCS = (hash, computeCS) => cache.lookup(hash, () => {
57
+ const cs = computeCS(); // already T()-tracked
58
+ return { value: wrapShape2d(cs, hash), pin: cs, dispose: () => cs.delete?.() };
59
+ });
60
+ const liftCS = (x) => (x && x._shape2d ? x : shape2d(x));
61
+ const wrapShape2d = (cs, hash) => addShape2dSugar({
62
+ _cs: cs,
63
+ _shape2d: true,
64
+ _hash: hash,
65
+ union: (o) => { const t = liftCS(o); return cachedCS(h("union2d", hash, t._hash), () => T(cs.add(t._cs))); },
66
+ cut: (o) => { const t = liftCS(o); return cachedCS(h("cut2d", hash, t._hash), () => T(cs.subtract(t._cs))); },
67
+ cutAll: (os) => {
68
+ if (os.length === 0) return wrapShape2d(cs, hash); // identity — no new WASM / cache entry (avoids double-free)
69
+ const ts = os.map(liftCS);
70
+ // The reducer's inner T() already tracks every step (incl. the final) — no
71
+ // outer T() around the reduce, or the result lands in `tracked` twice and
72
+ // cleanup() double-frees it. os is non-empty here, so the seed cs is never
73
+ // returned; the result is always a fresh subtract, never aliasing the input.
74
+ return cachedCS(h("cutAll2d", hash, ts.map((t) => t._hash)), () => ts.reduce((acc, t) => T(acc.subtract(t._cs)), cs));
75
+ },
76
+ intersect: (o) => { const t = liftCS(o); return cachedCS(h("intersect2d", hash, t._hash), () => T(cs.intersect(t._cs))); },
77
+ offset: (delta, { corners = "round", segs: nSeg = segs } = {}) => {
78
+ if (!["round", "chamfer", "sharp"].includes(corners))
79
+ throw new Error('Shape2D.offset: corners must be "round" | "chamfer" | "sharp"');
80
+ if (!Number.isFinite(delta)) throw new Error("Shape2D.offset: delta must be a finite number");
81
+ // chamfer is a true 45° bevel — Clipper2 has no bevel join, but a Round join
82
+ // forced to a single chord per corner (circularSegments=4 → 1 segment per corner
83
+ // whose turn ≤ 90°, i.e. interior angle ≥ 90°) IS the bevel: round's tangent points
84
+ // are exactly the bevel's endpoints. Matches OCCT's `bevel` to float precision for
85
+ // interior angle ≥ 90° (square 142.0000, pentagon 298.920). At acute (<90°) convex
86
+ // corners Clipper2 emits 2 chords (ceil(turn/90°)), so Manifold bulges ~0.4% beyond
87
+ // OCCT's single-chord bevel there. round = arc at mesh LOD; sharp = miter.
88
+ const [joinType, cseg] = corners === "sharp" ? ["Miter", nSeg]
89
+ : corners === "chamfer" ? ["Round", 4]
90
+ : ["Round", nSeg];
91
+ return cachedCS(h("offset2d", hash, delta, corners, cseg), () => {
92
+ const out = T(cs.offset(delta, joinType, 2, cseg)); // miterLimit 2 (Clipper2 default)
93
+ if (out.numContour() === 0) throw new Error("Shape2D.offset: offset collapses the shape (reduce |delta|)");
94
+ return out;
95
+ });
96
+ },
97
+ area: () => cs.area(),
98
+ boundingBox: () => { const r = cs.bounds(); return { min: [r.min[0], r.min[1]], max: [r.max[0], r.max[1]] }; },
99
+ toRegions: () => assembleRegions(cs.toPolygons()),
100
+ clone: () => wrapShape2d(cs, hash),
101
+ });
102
+ const shape2d = (profile) => {
103
+ if (profile && profile._shape2d) return profile; // idempotent
104
+ const hash = h("shape2d", profile, segs);
105
+ return cachedCS(hash, () => {
106
+ const { outer, holes } = tessellateProfile(profile, segs);
107
+ return T(CrossSection.ofPolygons([outer, ...holes], "EvenOdd"));
108
+ });
109
+ };
110
+
52
111
  // Copy the mesh out into JS-owned arrays (so it survives cleanup) and free the
53
112
  // transient mesh handle.
54
113
  function meshOut(m, asStl) {
@@ -82,6 +141,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
82
141
  cutAll: (tools) => cached(h("cutAll", hash, tools.map((t) => t._hash)),
83
142
  () => T(m.subtract(unionRaw(tools.map((t) => t._m))))),
84
143
  intersect: (t) => cached(h("intersect", hash, t._hash), () => T(m.intersect(t._m))),
144
+ union: (t) => cached(h("union", [hash, t._hash]), () => unionRaw([m, t._m])),
85
145
  clone: () => wrap(m, hash),
86
146
  // Name this solid's surface for hover/pick feature attribution. asOriginal()
87
147
  // stamps a fresh originalID that survives transforms and booleans, so every
@@ -152,14 +212,21 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
152
212
  }),
153
213
  // Polygon-with-holes extrude in one op: even/odd fill turns the extra contours into
154
214
  // holes regardless of their winding (outer + holes, no per-hole boolean cut).
155
- extrude: (profile, height, { twist = 0, scaleTop = 1 } = {}) =>
156
- cached(h("extrude", profile, height, twist, scaleTop, segs), () => {
157
- const { outer, holes } = tessellateProfile(profile, segs);
158
- const cs = T(CrossSection.ofPolygons([outer, ...holes], "EvenOdd"));
215
+ // A Shape2D `profile` (already a CrossSection, possibly multi-region) extrudes
216
+ // directly off its own `_cs` no re-tessellation — and folds into the cache
217
+ // key by `_hash` like any other solid operand.
218
+ extrude: (profile, height, { twist = 0, scaleTop = 1 } = {}) => {
219
+ const shape = profile && profile._shape2d ? profile : null;
220
+ return cached(h("extrude", shape ? shape._hash : profile, height, twist, scaleTop, segs), () => {
221
+ const cs = shape ? shape._cs : (() => {
222
+ const { outer, holes } = tessellateProfile(profile, segs);
223
+ return T(CrossSection.ofPolygons([outer, ...holes], "EvenOdd"));
224
+ })();
159
225
  if (twist === 0 && scaleTop === 1) return T(cs.extrude(height));
160
226
  const nDiv = Math.max(1, Math.ceil(Math.abs(twist) / 5));
161
227
  return T(cs.extrude(height, nDiv, twist, [scaleTop, scaleTop]));
162
- }),
228
+ });
229
+ },
163
230
  // Ring loft: hand-meshed via the shared ring-mesh helpers (helix-tube recipe).
164
231
  // Cached atomically; the hash folds every ring's points/z/rotate/scale and the opts.
165
232
  loft: (rings, opts = {}) => cached(h("loft", rings, opts), () => T(loftMesh(wasm, rings, opts))),
@@ -169,9 +236,13 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
169
236
  // (closed/cornerRadius) so a shape change is a fresh node and an identical rebuild hits.
170
237
  sweep: (profile, path, opts = {}) => cached(h("sweep", profile, path, opts), () => T(sweepMesh(wasm, profile, path, opts))),
171
238
  helixSweptTube: (o) => cached(h("helixSweptTube", o, tube), () => T(helixTube(wasm, { ...o, ...tube }))),
172
- revolve: (pts, { degrees = 360 } = {}) =>
173
- cached(h("revolve", pts, degrees, segs), () => T(Manifold.revolve([pts], segs, degrees))),
239
+ revolve: (pts, { degrees = 360 } = {}) => {
240
+ if (pts && pts._shape2d)
241
+ return cached(h("revolve", pts._hash, degrees, segs), () => T(pts._cs.revolve(segs, degrees)));
242
+ return cached(h("revolve", pts, degrees, segs), () => T(Manifold.revolve([pts], segs, degrees)));
243
+ },
174
244
  union: (solids) => cached(h("union", solids.map((s) => s._hash)), () => unionRaw(solids.map((s) => s._m))),
245
+ shape2d,
175
246
  beginSubPart: (name) => cache.begin(name),
176
247
  endSubPart: () => cache.end(),
177
248
  cacheStats: () => cache.stats(),
@@ -4,6 +4,8 @@
4
4
  import { toEdgeFinder } from "./edge-selector.js";
5
5
  import { toFaceFinder } from "./face-selector.js";
6
6
  import { addSugar } from "./solid-sugar.js";
7
+ import { addShape2dSugar } from "./shape2d-sugar.js";
8
+ import { assembleRegions, svgPathToRings, regionsArea, pointInRing, ringArea } from "./shape2d-regions.js";
7
9
  import { finishKernel } from "./kernel-front.js";
8
10
  import { createOcctRepair } from "./occt-repair.js";
9
11
  import { classifyFaceGroups } from "./feature-attribution.js";
@@ -36,6 +38,7 @@ export function createOcctKernel(replicad) {
36
38
  [...cloneLabels(labels), ...tools.flatMap((t) => cloneLabels(t._labels ?? []))]
37
39
  ),
38
40
  intersect: (t) => wrap(shape.intersect(t._s), [...cloneLabels(labels), ...cloneLabels(t._labels ?? [])]),
41
+ union: (t) => wrap(shape.fuse(t._s), [...cloneLabels(labels), ...cloneLabels(t._labels ?? [])]),
39
42
  clone: () => wrap(shape.clone(), cloneLabels(labels)),
40
43
  boundingBox: () => {
41
44
  const [min, max] = shape.boundingBox.bounds; // addSugar derives center/size
@@ -89,8 +92,9 @@ export function createOcctKernel(replicad) {
89
92
  // Draw a closed Drawing from a Contour: a legacy 2-D point list (all straight edges,
90
93
  // the former polyDrawing) OR an ArcContour whose { to, via } segments become true
91
94
  // 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).
95
+ // real CIRCLE B-rep entity, not a fan of LINEs. Cubic segments ({ to, c1, c2 })
96
+ // map to cubicBezierCurveTo for exact B-rep spline edges. close() joins the last
97
+ // point back to the start with a straight edge (mirrors the implied ArcContour closure).
94
98
  const contourDrawing = (contour) => {
95
99
  if (Array.isArray(contour)) {
96
100
  let pen = draw(contour[0]);
@@ -98,10 +102,97 @@ export function createOcctKernel(replicad) {
98
102
  return pen.close();
99
103
  }
100
104
  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);
105
+ for (const seg of contour.segments)
106
+ pen = seg.c1 ? pen.cubicBezierCurveTo(seg.to, seg.c1, seg.c2)
107
+ : seg.via ? pen.threePointsArcTo(seg.to, seg.via)
108
+ : pen.lineTo(seg.to);
102
109
  return pen.close();
103
110
  };
104
111
 
112
+ // Region (outer + holes) -> Drawing, exactly like extrude's former inline region
113
+ // path: draw the outer contour, then .cut() each hole Drawing out of it.
114
+ const SHAPE2D_SEGS = 64; // materialization LOD for toRegions() discretization
115
+ const drawingFromProfile = (profile) => {
116
+ const { outer, holes } = normalizeProfile(profile);
117
+ let region = contourDrawing(outer);
118
+ for (const hole of holes) region = region.cut(contourDrawing(hole));
119
+ return region;
120
+ };
121
+ const liftDrawing = (x) => (x && x._shape2d ? x : shape2d(x));
122
+ // Materialize a replicad Drawing into flat rings ready for the shared
123
+ // assembleRegions (which buckets outer/hole by ring winding SIGN). TWO
124
+ // corrections here, both confirmed against real replicad output rather than
125
+ // guessed (see task-4-report.md's probe results):
126
+ //
127
+ // 1. toSVGPathD() renders in SVG's y-down convention, so every coordinate is
128
+ // negated back to model space.
129
+ // 2. Drawing.toSVGPaths() nests 0-2 levels deep depending on the result shape,
130
+ // INCONSISTENTLY — e.g. a single interior hole nests as
131
+ // [[outerD, holeD]], but two disjoint holes built via sequential .cut()
132
+ // calls (cutAll) come back as a flat [outerD, hole1D, hole2D] with no
133
+ // grouping at all. So which array position is "the outer" can't be read off
134
+ // the nesting shape. Worse: unlike Manifold's CrossSection.toPolygons()
135
+ // (outer CCW/positive, hole CW/negative), replicad emits EVERY loop of a
136
+ // region — outer or hole — with the same rotational sense, so ring winding
137
+ // carries no outer/hole signal either (verified: an interior hole in a
138
+ // 20x20 square, classified by sign alone, came back as 2 disjoint "outer"
139
+ // regions summing 400+36 instead of one region netting 364).
140
+ // The one signal that IS reliable is geometric containment DEPTH: classify
141
+ // each ring by how many OTHER rings contain it (even-odd nesting), then
142
+ // force each ring's winding to match its depth parity ABSOLUTELY — even depth
143
+ // is an outer (CCW / positive area), odd depth is a hole (CW / negative area).
144
+ // Setting the orientation absolutely (rather than reversing relative to the
145
+ // emitted sense) is what makes this winding-agnostic: a CW-wound cut tool
146
+ // makes replicad emit the hole loop with the opposite sense, and a relative
147
+ // reversal would double-flip it back to a positive area — misbucketing the
148
+ // hole as a second outer (409/2-regions/0-holes instead of 391/1/1).
149
+ const drawingRegionRings = (drawing) => {
150
+ const rings = drawing.toSVGPaths().flat(Infinity)
151
+ .flatMap((d) => svgPathToRings(d, SHAPE2D_SEGS))
152
+ .map((ring) => ring.map(([x, y]) => [x, -y]));
153
+ const containedBy = rings.map((r, i) =>
154
+ rings.reduce((n, other, j) => (i !== j && pointInRing(r[0], other) ? n + 1 : n), 0));
155
+ return rings.map((r, i) => {
156
+ const wantOuter = containedBy[i] % 2 === 0; // even depth = outer
157
+ return (ringArea(r) >= 0) === wantOuter ? r : r.slice().reverse();
158
+ });
159
+ };
160
+ const wrapShape2d = (drawing) => {
161
+ const toRegions = () => assembleRegions(drawingRegionRings(drawing));
162
+ return addShape2dSugar({
163
+ _drawing: drawing,
164
+ _shape2d: true,
165
+ union: (o) => wrapShape2d(drawing.clone().fuse(liftDrawing(o)._drawing.clone())),
166
+ cut: (o) => wrapShape2d(drawing.clone().cut(liftDrawing(o)._drawing.clone())),
167
+ cutAll: (os) => wrapShape2d(os.map(liftDrawing).reduce((acc, t) => acc.cut(t._drawing.clone()), drawing.clone())),
168
+ intersect: (o) => wrapShape2d(drawing.clone().intersect(liftDrawing(o)._drawing.clone())),
169
+ // corners map onto replicad's Offset2DConfig.lineJoinType; "chamfer" → "bevel", a
170
+ // true 45° corner cut (a straight chord). Manifold now matches this via a
171
+ // single-chord Round join (see manifold-backend offset) — the two agree to float
172
+ // precision for convex corners with interior angle ≥ 90°; at acute (<90°) corners
173
+ // Manifold uses a 2-facet approximation that departs slightly. See KERNEL-CONTRACT.
174
+ offset: (delta, { corners = "round" } = {}) => {
175
+ const lineJoinType = { round: "round", chamfer: "bevel", sharp: "miter" }[corners];
176
+ if (!lineJoinType) throw new Error('Shape2D.offset: corners must be "round" | "chamfer" | "sharp"');
177
+ if (!Number.isFinite(delta)) throw new Error("Shape2D.offset: delta must be a finite number");
178
+ const result = drawing.clone().offset(delta, { lineJoinType }); // clone — replicad consumes the operand
179
+ // Collapse doesn't throw and Drawing has no public `blueprints` array (that's on
180
+ // Blueprints/CompoundBlueprint, not Drawing) — replicad instead returns a Drawing
181
+ // whose private `innerShape` is null (confirmed by probe). That's the collapse signal.
182
+ // NB: `innerShape` is replicad-internal; the "collapse throws immediately (OCCT)" test
183
+ // guards this — a replicad upgrade that renames it must keep that test green.
184
+ if (!result || !result.innerShape)
185
+ throw new Error("Shape2D.offset: offset collapses the shape (reduce |delta|)");
186
+ return wrapShape2d(result);
187
+ },
188
+ area: () => regionsArea(toRegions()), // no native Drawing area → derive from materialized regions
189
+ boundingBox: () => { const b = drawing.boundingBox; return { min: [b.bounds[0][0], b.bounds[0][1]], max: [b.bounds[1][0], b.bounds[1][1]] }; },
190
+ toRegions,
191
+ clone: () => wrapShape2d(drawing.clone()),
192
+ });
193
+ };
194
+ const shape2d = (profile) => (profile && profile._shape2d ? profile : wrapShape2d(drawingFromProfile(profile)));
195
+
105
196
  // extrude a 2-D polygon from z=0 (arguments validated by the kernel front)
106
197
  const prism = (pts, h, { twist = 0, scaleTop = 1 } = {}) => {
107
198
  const sketch = contourDrawing(pts).sketchOnPlane("XY");
@@ -113,15 +204,17 @@ export function createOcctKernel(replicad) {
113
204
  };
114
205
 
115
206
  // revolve a lathe profile [[r,z],…] around the Z axis (degrees defaults to 360)
116
- const revolve = (pts, { degrees = 360 } = {}) =>
117
- wrap(contourDrawing(pts).sketchOnPlane("XZ").revolve([0, 0, 1], { angle: degrees }));
207
+ const revolve = (pts, { degrees = 360 } = {}) => {
208
+ const region = pts && pts._shape2d ? pts._drawing.clone() : contourDrawing(pts);
209
+ return wrap(region.sketchOnPlane("XZ").revolve([0, 0, 1], { angle: degrees }));
210
+ };
118
211
 
119
212
  // extrude a polygon-with-holes region from z=0: cut each hole Drawing out of the outer
120
213
  // Drawing (winding-agnostic 2-D boolean), sketch it, then extrude (twist/taper via cfg).
214
+ // A Shape2D `profile` (already a Drawing, possibly multi-region) extrudes directly off
215
+ // its own `_drawing` (cloned — replicad booleans/extrude consume their operand).
121
216
  const extrude = (profile, h, { twist = 0, scaleTop = 1 } = {}) => {
122
- const { outer, holes } = normalizeProfile(profile);
123
- let region = contourDrawing(outer);
124
- for (const hole of holes) region = region.cut(contourDrawing(hole));
217
+ const region = profile && profile._shape2d ? profile._drawing.clone() : drawingFromProfile(profile);
125
218
  const sketch = region.sketchOnPlane("XY");
126
219
  if (twist === 0 && scaleTop === 1) return wrap(sketch.extrude(h));
127
220
  const cfg = {};
@@ -180,6 +273,7 @@ export function createOcctKernel(replicad) {
180
273
  solids.map((s) => s._s).reduce((a, b) => a.fuse(b)),
181
274
  solids.flatMap((s) => cloneLabels(s._labels ?? []))
182
275
  ),
276
+ shape2d,
183
277
  toSTEP: (named) => exportSTEP(named.map(({ name, solid }) => ({ name, shape: solid._s }))).arrayBuffer(),
184
278
  });
185
279
  }
@@ -145,6 +145,10 @@ export const KERNEL_OP_SPECS = {
145
145
  prism: { toArgs: prismArgs, check: checkScaleTop("prism") },
146
146
  extrude: { toArgs: extrudeArgs, check: checkScaleTop("extrude") },
147
147
  revolve: { toArgs: revolveArgs, check: (pts) => {
148
+ if (pts && pts._shape2d) {
149
+ if (pts.boundingBox().min[0] < 0) throw new Error("revolve: profile radius must be ≥ 0");
150
+ return;
151
+ }
148
152
  for (const [r] of pts) if (r < 0) throw new Error("revolve: profile radius must be ≥ 0");
149
153
  } },
150
154
  loft: { toArgs: loftArgs },
@@ -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
  }
@@ -0,0 +1,134 @@
1
+ // Pure (WASM-free) helpers for materializing a 2-D boolean result into region
2
+ // arrays. assembleRegions groups a flat set of point-rings into {outer,holes}
3
+ // regions by winding + point-in-polygon nesting. svgPathToRings discretizes a
4
+ // replicad Drawing's SVG path (from toSVGPathD) into rings, reusing F1's
5
+ // sampleBezier / sampleArc so an OCCT-materialized curve facets like Manifold.
6
+ import { sampleBezier } from "./profile.js";
7
+
8
+ // Signed shoelace area of a ring (CCW positive). Exported (in addition to its use
9
+ // below) for the OCCT backend's absolute outer/hole orientation — see
10
+ // occt-backend.js's drawingRegionRings.
11
+ export function ringArea(p) {
12
+ let a = 0;
13
+ for (let i = 0; i < p.length; i++) { const [x1, y1] = p[i], [x2, y2] = p[(i + 1) % p.length]; a += x1 * y2 - x2 * y1; }
14
+ return a / 2;
15
+ }
16
+
17
+ // Ray-cast point-in-polygon (even-odd). ring: [[x,y],…]. Exported (in addition to
18
+ // its use below) for the OCCT backend's containment-based outer/hole classification
19
+ // — see occt-backend.js's drawingRegionRings for why sign-based classification
20
+ // (this module's own convention, below) doesn't hold for replicad's SVG output.
21
+ export function pointInRing([px, py], ring) {
22
+ let inside = false;
23
+ for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
24
+ const [xi, yi] = ring[i], [xj, yj] = ring[j];
25
+ if ((yi > py) !== (yj > py) && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi) inside = !inside;
26
+ }
27
+ return inside;
28
+ }
29
+
30
+ // Group rings: positive-area rings are outers, negative-area are holes; nest each
31
+ // hole into the smallest-area outer that contains its first vertex.
32
+ export function assembleRegions(rings) {
33
+ const outers = [], holes = [];
34
+ for (const r of rings) {
35
+ if (r.length < 3) continue;
36
+ (ringArea(r) >= 0 ? outers : holes).push(r);
37
+ }
38
+ const regions = outers.map((outer) => ({ outer, holes: [] }));
39
+ regions.sort((a, b) => Math.abs(ringArea(a.outer)) - Math.abs(ringArea(b.outer)));
40
+ for (const hole of holes) {
41
+ const home = regions.find((rg) => pointInRing(hole[0], rg.outer));
42
+ if (home) home.holes.push(hole);
43
+ }
44
+ // largest-first for a stable, readable order
45
+ regions.sort((a, b) => Math.abs(ringArea(b.outer)) - Math.abs(ringArea(a.outer)));
46
+ return regions;
47
+ }
48
+
49
+ // Net area of assembled regions: Σ|outer| − Σ|holes|.
50
+ export function regionsArea(regions) {
51
+ let a = 0;
52
+ for (const rg of regions) {
53
+ a += Math.abs(ringArea(rg.outer));
54
+ for (const hole of rg.holes) a -= Math.abs(ringArea(hole));
55
+ }
56
+ return a;
57
+ }
58
+
59
+ // Sample an SVG elliptical-arc segment (endpoint parameterization → center form,
60
+ // W3C SVG 1.1 notes F.6) from `from` to `to` into points AFTER `from` (last pinned
61
+ // to `to`), honoring rx/ry/x-rotation and the large-arc/sweep flags. Exact for the
62
+ // semicircle case a three-point circle fit degenerates on.
63
+ function sampleSvgArc(from, rx, ry, rotDeg, largeArc, sweep, to, segs) {
64
+ const [x1, y1] = from, [x2, y2] = to;
65
+ if (rx === 0 || ry === 0) return [[x2, y2]];
66
+ const phi = (rotDeg * Math.PI) / 180, cosP = Math.cos(phi), sinP = Math.sin(phi);
67
+ const dx = (x1 - x2) / 2, dy = (y1 - y2) / 2;
68
+ const x1p = cosP * dx + sinP * dy, y1p = -sinP * dx + cosP * dy;
69
+ let RX = Math.abs(rx), RY = Math.abs(ry);
70
+ const lambda = (x1p * x1p) / (RX * RX) + (y1p * y1p) / (RY * RY);
71
+ if (lambda > 1) { const s = Math.sqrt(lambda); RX *= s; RY *= s; }
72
+ const numr = RX * RX * RY * RY - RX * RX * y1p * y1p - RY * RY * x1p * x1p;
73
+ const den = RX * RX * y1p * y1p + RY * RY * x1p * x1p;
74
+ let coef = Math.sqrt(Math.max(0, numr / den));
75
+ if (Boolean(largeArc) === Boolean(sweep)) coef = -coef;
76
+ const cxp = (coef * RX * y1p) / RY, cyp = (-coef * RY * x1p) / RX;
77
+ const cx = cosP * cxp - sinP * cyp + (x1 + x2) / 2;
78
+ const cy = sinP * cxp + cosP * cyp + (y1 + y2) / 2;
79
+ const angle = (ux, uy, vx, vy) => {
80
+ const dot = ux * vx + uy * vy, len = Math.hypot(ux, uy) * Math.hypot(vx, vy) || 1e-12;
81
+ let a = Math.acos(Math.min(1, Math.max(-1, dot / len)));
82
+ if (ux * vy - uy * vx < 0) a = -a;
83
+ return a;
84
+ };
85
+ const theta1 = angle(1, 0, (x1p - cxp) / RX, (y1p - cyp) / RY);
86
+ let dTheta = angle((x1p - cxp) / RX, (y1p - cyp) / RY, (-x1p - cxp) / RX, (-y1p - cyp) / RY);
87
+ if (!sweep && dTheta > 0) dTheta -= 2 * Math.PI;
88
+ if (sweep && dTheta < 0) dTheta += 2 * Math.PI;
89
+ const steps = Math.max(2, Math.ceil((segs * Math.abs(dTheta)) / (2 * Math.PI)));
90
+ const out = [];
91
+ for (let i = 1; i <= steps; i++) {
92
+ const t = theta1 + dTheta * (i / steps);
93
+ const ex = RX * Math.cos(t), ey = RY * Math.sin(t);
94
+ out.push([cx + cosP * ex - sinP * ey, cy + sinP * ex + cosP * ey]);
95
+ }
96
+ out[out.length - 1] = [x2, y2];
97
+ return out;
98
+ }
99
+
100
+ // Minimal SVG-path tokenizer for the absolute commands replicad emits: M, L, C,
101
+ // Q, A, Z. Coordinates are numbers separated by spaces or commas; a command may
102
+ // be followed by several coordinate sets (implicit repeat). One subpath (M…Z) →
103
+ // one ring; the start point is not duplicated. Throws on unsupported commands.
104
+ export function svgPathToRings(d, segs) {
105
+ const toks = d.match(/[a-zA-Z]|-?\d*\.?\d+(?:e[-+]?\d+)?/g) ?? [];
106
+ const rings = [];
107
+ let ring = null, cur = [0, 0], cmd = null, i = 0;
108
+ const num = () => Number(toks[i++]);
109
+ const pt = () => [num(), num()];
110
+ const pushRing = () => { if (ring && ring.length >= 3) rings.push(ring); ring = null; };
111
+ while (i < toks.length) {
112
+ if (/^[a-zA-Z]$/.test(toks[i])) {
113
+ cmd = toks[i++];
114
+ if (!"MLCQAZ".includes(cmd)) throw new Error(`svgPathToRings: unsupported SVG command "${cmd}"`);
115
+ }
116
+ if (cmd === "M") { pushRing(); cur = pt(); ring = [cur.slice()]; cmd = "L"; }
117
+ else if (cmd === "L") { cur = pt(); ring.push(cur.slice()); }
118
+ else if (cmd === "C") { const c1 = pt(), c2 = pt(), end = pt(); for (const p of sampleBezier(cur, c1, c2, end, segs)) ring.push(p); cur = end; }
119
+ else if (cmd === "Q") {
120
+ const q = pt(), end = pt();
121
+ const c1 = [cur[0] + (2 / 3) * (q[0] - cur[0]), cur[1] + (2 / 3) * (q[1] - cur[1])];
122
+ const c2 = [end[0] + (2 / 3) * (q[0] - end[0]), end[1] + (2 / 3) * (q[1] - end[1])];
123
+ for (const p of sampleBezier(cur, c1, c2, end, segs)) ring.push(p); cur = end;
124
+ }
125
+ else if (cmd === "A") {
126
+ const rx = num(), ry = num(), rot = num(), large = num(), sweep = num(), end = pt();
127
+ for (const p of sampleSvgArc(cur, rx, ry, rot, large, sweep, end, segs)) ring.push(p); cur = end;
128
+ }
129
+ else if (cmd === "Z") { pushRing(); cmd = null; }
130
+ else throw new Error("svgPathToRings: coordinate before or after a command");
131
+ }
132
+ pushRing();
133
+ return rings;
134
+ }
@@ -0,0 +1,11 @@
1
+ // Backend-shared Shape2D front. Like solid-sugar for Solids, but the 2-D shared
2
+ // surface is small: .simple() unwraps a single-region materialization or throws.
3
+ // Backends attach the geometry ops (booleans, area, boundingBox, toRegions).
4
+ export function addShape2dSugar(s) {
5
+ s.simple = () => {
6
+ const regions = s.toRegions();
7
+ if (regions.length !== 1) throw new Error(`Shape2D.simple: result has ${regions.length} regions, not 1 (use toRegions())`);
8
+ return regions[0];
9
+ };
10
+ return s;
11
+ }
package/src/parts/demo.js CHANGED
@@ -49,7 +49,7 @@ export default {
49
49
  export: { name: "spacer" },
50
50
  build: (k, p, d) => {
51
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 })]);
52
+ if (p.flange_d > 0) s = s.union(k.cylinder({ d: p.flange_d, h: p.flange_h }));
53
53
  return s.cut(k.cylinder({ r: d.boreR, h: d.cutH }).at([0, 0, -2]));
54
54
  },
55
55
  },