partforge 0.17.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.
@@ -474,6 +474,30 @@ const hole = k.cylinder({ r: 2, h: 20 }).translate([20, 0, 0]);
474
474
  body = body.cutAll(circularPattern(hole, 8, { axis: "Z" })); // 8 bolt holes on a 40mm circle
475
475
  ```
476
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
+
477
501
  ---
478
502
 
479
503
  ## Wiring a part into a runnable app
@@ -233,6 +233,21 @@ Variant literals under this entry: `offsetPolygon: delta must be a finite number
233
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
234
  - **Fix:** Provide both control points as finite `[x,y]`. A cubic Bézier needs two controls between the previous point and `to`.
235
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
+
236
251
  # Hardware library
237
252
 
238
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.17.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
@@ -106,6 +109,90 @@ export function createOcctKernel(replicad) {
106
109
  return pen.close();
107
110
  };
108
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
+
109
196
  // extrude a 2-D polygon from z=0 (arguments validated by the kernel front)
110
197
  const prism = (pts, h, { twist = 0, scaleTop = 1 } = {}) => {
111
198
  const sketch = contourDrawing(pts).sketchOnPlane("XY");
@@ -117,15 +204,17 @@ export function createOcctKernel(replicad) {
117
204
  };
118
205
 
119
206
  // revolve a lathe profile [[r,z],…] around the Z axis (degrees defaults to 360)
120
- const revolve = (pts, { degrees = 360 } = {}) =>
121
- 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
+ };
122
211
 
123
212
  // extrude a polygon-with-holes region from z=0: cut each hole Drawing out of the outer
124
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).
125
216
  const extrude = (profile, h, { twist = 0, scaleTop = 1 } = {}) => {
126
- const { outer, holes } = normalizeProfile(profile);
127
- let region = contourDrawing(outer);
128
- for (const hole of holes) region = region.cut(contourDrawing(hole));
217
+ const region = profile && profile._shape2d ? profile._drawing.clone() : drawingFromProfile(profile);
129
218
  const sketch = region.sketchOnPlane("XY");
130
219
  if (twist === 0 && scaleTop === 1) return wrap(sketch.extrude(h));
131
220
  const cfg = {};
@@ -184,6 +273,7 @@ export function createOcctKernel(replicad) {
184
273
  solids.map((s) => s._s).reduce((a, b) => a.fuse(b)),
185
274
  solids.flatMap((s) => cloneLabels(s._labels ?? []))
186
275
  ),
276
+ shape2d,
187
277
  toSTEP: (named) => exportSTEP(named.map(({ name, solid }) => ({ name, shape: solid._s }))).arrayBuffer(),
188
278
  });
189
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 },
@@ -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
  },