partforge 0.55.0 → 0.56.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.
@@ -7,65 +7,7 @@
7
7
  // 2. CompoundPath of all the simple sub-paths;
8
8
  // 3. set the font's nonzero/evenodd rule;
9
9
  // 4. unite(self) to normalize overlaps and crossings into simple paths.
10
- import paper from "paper/dist/paper-core.js";
11
-
12
- // Lazy, private PaperScope: built on first use (not at module load), so parts that never
13
- // call k.text2d don't pull paper-core's setup onto the geometry worker. Never paper's
14
- // package-global project — another consumer in the same worker may import paper too.
15
- let _scope = null;
16
- function paperScope() {
17
- if (!_scope) { _scope = new paper.PaperScope(); _scope.setup(new _scope.Size(1, 1)); }
18
- return _scope;
19
- }
20
-
21
- function toPaperPath(scope, contour) {
22
- const path = new scope.Path({ insert: false });
23
- path.moveTo(new scope.Point(contour.start[0], contour.start[1]));
24
- for (const s of contour.segments) {
25
- if (s.c1) path.cubicCurveTo(
26
- new scope.Point(s.c1[0], s.c1[1]),
27
- new scope.Point(s.c2[0], s.c2[1]),
28
- new scope.Point(s.to[0], s.to[1]));
29
- else path.lineTo(new scope.Point(s.to[0], s.to[1]));
30
- }
31
- path.closePath();
32
- return path;
33
- }
34
-
35
- function toContour(path) {
36
- const segs = path.segments;
37
- const start = [segs[0].point.x, segs[0].point.y];
38
- const out = { start, segments: [] };
39
- for (let i = 0; i < segs.length; i++) {
40
- const a = segs[i], b = segs[(i + 1) % segs.length];
41
- const straight = a.handleOut.isZero() && b.handleIn.isZero();
42
- const closing = i === segs.length - 1;
43
- if (closing && straight) continue; // implicit straight close
44
- const to = [b.point.x, b.point.y];
45
- if (straight) out.segments.push({ to });
46
- else out.segments.push({ to, c1: [a.point.x + a.handleOut.x, a.point.y + a.handleOut.y], c2: [b.point.x + b.handleIn.x, b.point.y + b.handleIn.y] });
47
- }
48
- return out;
49
- }
50
-
51
- // Group while paths are still Paper geometry. Path.area includes cubic handles and
52
- // interiorPoint is guaranteed to lie inside the curve; never reduce curves to endpoint rings.
53
- function groupPaperPaths(paths) {
54
- const largest = paths.reduce((a, b) => Math.abs(b.area) > Math.abs(a.area) ? b : a);
55
- const outerClockwise = largest.clockwise;
56
- const outers = paths.filter((p) => p.clockwise === outerClockwise)
57
- .map((path) => ({ path, holes: [] }));
58
- for (const hole of paths.filter((p) => p.clockwise !== outerClockwise)) {
59
- const home = outers.filter((o) => o.path.contains(hole.interiorPoint))
60
- .sort((a, b) => Math.abs(a.path.area) - Math.abs(b.path.area))[0];
61
- if (!home) throw new Error("curve-fill: resolved hole has no containing outer");
62
- home.holes.push(hole);
63
- }
64
- return outers.map(({ path, holes }) => ({
65
- outer: toContour(path),
66
- holes: holes.map(toContour),
67
- }));
68
- }
10
+ import { paperScope, toPaperPath, groupPaperPaths } from "./paper-bridge.js";
69
11
 
70
12
  export function resolveCurveFill(contours, { fillRule = "nonzero" } = {}) {
71
13
  if (fillRule !== "nonzero" && fillRule !== "evenodd")
@@ -44,9 +44,13 @@ export const SOLID_OPS = [
44
44
  export const SOLID_OPTIONAL_OPS = ["genus", "isEmpty"];
45
45
 
46
46
  // Public methods every Shape2D exposes (2-D boolean value; contract-linted).
47
+ // One shared implementation backs both backends (geometry/shape2d.js) — storage is
48
+ // the curve-native contour IR, so booleans/transforms/queries are backend-identical
49
+ // and only `offset` routes into the backend's own 2-D engine.
47
50
  export const SHAPE2D_OPS = [
48
51
  "union", "cut", "cutAll", "intersect", "offset", "area", "boundingBox", "toRegions", "simple", "regions", "clone",
49
52
  "extrude", "revolve",
53
+ "translate", "rotate", "scale", "mirror", "toContours", "fillet", "chamfer", "simplify", "corners", "contains",
50
54
  ];
51
55
 
52
56
  // Solid ops only OCCT implements natively. Single source of truth: probe.js routes
@@ -85,16 +89,30 @@ export const OCCT_ONLY_OPS = ["fillet", "chamfer", "shell"];
85
89
  * @property {() => number} [genus] through-hole count (Manifold only)
86
90
  * @property {() => boolean} [isEmpty] no geometry at all (Manifold only)
87
91
  *
88
- * @typedef {Object} Shape2D An opaque 2-D boolean value (both backends: Manifold wraps a CrossSection, OCCT a replicad Drawing). `_`-prefixed keys are backend internals.
92
+ * @typedef {Object} Shape2D A 2-D boolean value. ONE shared implementation on both backends: storage is the curve-native contour IR (arcs/cubics survive every op), so results are backend-identical except `offset`. `_`-prefixed keys are internals.
89
93
  * @property {(other: Shape2D|number[][]) => Shape2D} union
90
94
  * @property {(other: Shape2D|number[][]) => Shape2D} cut
91
95
  * @property {(others: (Shape2D|number[][])[]) => Shape2D} cutAll batch subtract
92
96
  * @property {(other: Shape2D|number[][]) => Shape2D} intersect
93
- * @property {() => number} area net area (outers minus holes), mm²
94
- * @property {() => {min:number[],max:number[]}} boundingBox axis-aligned 2-D bounds
95
- * @property {() => {outer:number[][],holes:number[][][]}[]} toRegions materialize into region arrays (assembleRegions)
97
+ * @property {(delta:number, opts?:{corners?:"round"|"chamfer"|"sharp",segs?:number}) => Shape2D} offset grow (+) / shrink (−) by delta; the one backend-specific op (Clipper2 vs OCCT) — throws when the shape collapses
98
+ * @property {() => number} area net area (outers minus holes), mm² — curve-exact, not tessellated
99
+ * @property {() => {min:number[],max:number[]}} boundingBox axis-aligned 2-D bounds (curve-exact)
100
+ * @property {() => {outer:number[][],holes:number[][][]}[]} toRegions materialize into point-ring region arrays (tessellated at the backend's LOD)
96
101
  * @property {() => {outer:number[][],holes:number[][][]}} simple toRegions(), unwrapped — throws unless exactly 1 region
97
- * @property {() => Shape2D} clone independent handle
102
+ * @property {() => Shape2D[]} regions scission: each disjoint region as its own Shape2D
103
+ * @property {() => {outer:object,holes:object[]}[]} toContours the stored contour IR (curve-native, lossless) — a deep copy, safe to mutate
104
+ * @property {() => Shape2D} clone independent copy
105
+ * @property {(v:number[]) => Shape2D} translate translate by [dx,dy]
106
+ * @property {(deg:number, center?:number[]) => Shape2D} rotate rotate about center (default origin)
107
+ * @property {(f:number|number[], center?:number[]) => Shape2D} scale scale about center (default origin); a bare number scales uniformly, [sx,sy] scales each axis independently
108
+ * @property {(axis:"x"|"y"|{point:number[],dir:number[]}) => Shape2D} mirror reflect across an axis line
109
+ * @property {(r:number|number[], opts?:{corners?:"all"|"convex"|"concave"|{indices:number[]}|{near:number[],count?:number}}) => Shape2D} fillet round selected corners with true arcs
110
+ * @property {(d:number|number[], opts?:{corners?:"all"|"convex"|"concave"|{indices:number[]}|{near:number[],count?:number}}) => Shape2D} chamfer bevel selected corners with straight chords
111
+ * @property {(tolerance:number) => Shape2D} simplify corner-preserving decimation/refit within tolerance
112
+ * @property {() => {index:number,point:number[],interiorAngleDeg:number,convex:boolean,segTypes:string[]}[]} corners corner list (the positional order fillet/chamfer `{indices}` index into)
113
+ * @property {(p:number[]) => boolean} contains is point [x,y] inside the shape (holes excluded)
114
+ * @property {(o?:{h:number,twist?:number,scaleTop?:number}) => Solid} extrude sugar for k.extrude({profile:this,…})
115
+ * @property {(o?:{degrees?:number}) => Solid} revolve sugar for k.revolve({profile:this,…})
98
116
  *
99
117
  * @typedef {Object} GeometryKernel
100
118
  * @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
@@ -112,7 +130,7 @@ export const OCCT_ONLY_OPS = ["fillet", "chamfer", "shell"];
112
130
  * @property {(o:{pathR:number,profileR:number,pitch:number,turns:number,z0:number,lefthand:boolean}) => Solid} helixSweptTube
113
131
  * @property {(o:{profile:number[][],pitch:number,turns:number,lefthand?:boolean}) => Solid} screwSweep screw-motion sweep of an axial [[r,z]] profile — threads; options-only
114
132
  * @property {(solids:Solid[]) => Solid} union
115
- * @property {(profile: number[][]|{outer:number[][],holes?:number[][][]}|Shape2D) => Shape2D} shape2d 2-D boolean value (both backends: Manifold wraps a CrossSection, OCCT a replicad Drawing)
133
+ * @property {(profile: number[][]|{outer:number[][],holes?:number[][][]}|{start:number[],segments:object[]}|Shape2D) => Shape2D} shape2d 2-D boolean value; one shared contour-storage implementation on both backends
116
134
  * @property {(inputs: (Shape2D|number[][]|{start:number[],segments:object[]})[]) => Shape2D} hull convex hull of all inputs → a convex Shape2D (faceted; pure-JS monotone chain)
117
135
  * @property {(inputs: (Shape2D|number[][]|{start:number[],segments:object[]})[]) => Shape2D} hullChain swept hull over an ordered sequence (≥2): union of hull([inᵢ,inᵢ₊₁])
118
136
  * @property {(named:{name:string,solid:Solid}[]) => Promise<ArrayBuffer>} toSTEP OCCT only (Manifold throws KernelCapabilityError)
@@ -2,11 +2,11 @@ import { helixTube } from "./helix-tube.js";
2
2
  import { loftMesh } from "./loft.js";
3
3
  import { sweepMesh } from "./sweep.js";
4
4
  import { roundedBoxRings } from "./rounded-solids.js";
5
- import { tessellateContour, tessellateProfile } from "./profile.js";
5
+ import { tessellateContour, tessellateProfile, pointsToContour } from "./profile.js";
6
6
  import { h } from "./solid-hash.js";
7
7
  import { createSolidCache } from "./solid-cache.js";
8
8
  import { addSugar } from "./solid-sugar.js";
9
- import { addShape2dSugar } from "./shape2d-sugar.js";
9
+ import { makeShape2dFactory } from "./shape2d.js";
10
10
  import { assembleRegions } from "./shape2d-regions.js";
11
11
  import { finishKernel } from "./kernel-front.js";
12
12
  import { meshToStl } from "./mesh-stl.js";
@@ -53,63 +53,70 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
53
53
  return { value: wrap(m, hash), pin: m, dispose: () => m.delete?.() };
54
54
  });
55
55
 
56
- // 2-D cross-section value. Mirrors wrap()/cached(): booleans route through the
57
- // solid cache (dispose frees the CrossSection); operands fold by _hash.
58
- const cachedCS = (hash, computeCS) => cache.lookup(hash, () => {
59
- const cs = computeCS(); // already T()-tracked
60
- return { value: wrapShape2d(cs, hash), pin: cs, dispose: () => cs.delete?.() };
61
- });
62
- const liftCS = (x) => (x && x._shape2d ? x : shape2d(x));
63
- const wrapShape2d = (cs, hash) => addShape2dSugar({
64
- _cs: cs,
65
- _shape2d: true,
66
- _hash: hash,
67
- union: (o) => { const t = liftCS(o); return cachedCS(h("union2d", hash, t._hash), () => T(cs.add(t._cs))); },
68
- cut: (o) => { const t = liftCS(o); return cachedCS(h("cut2d", hash, t._hash), () => T(cs.subtract(t._cs))); },
69
- cutAll: (os) => {
70
- if (os.length === 0) return wrapShape2d(cs, hash); // identity no new WASM / cache entry (avoids double-free)
71
- const ts = os.map(liftCS);
72
- // The reducer's inner T() already tracks every step (incl. the final) — no
73
- // outer T() around the reduce, or the result lands in `tracked` twice and
74
- // cleanup() double-frees it. os is non-empty here, so the seed cs is never
75
- // returned; the result is always a fresh subtract, never aliasing the input.
76
- return cachedCS(h("cutAll2d", hash, ts.map((t) => t._hash)), () => ts.reduce((acc, t) => T(acc.subtract(t._cs)), cs));
77
- },
78
- intersect: (o) => { const t = liftCS(o); return cachedCS(h("intersect2d", hash, t._hash), () => T(cs.intersect(t._cs))); },
79
- offset: (delta, { corners = "round", segs: nSeg = segs } = {}) => {
80
- if (!["round", "chamfer", "sharp"].includes(corners))
81
- throw new Error('Shape2D.offset: corners must be "round" | "chamfer" | "sharp"');
82
- if (!Number.isFinite(delta)) throw new Error("Shape2D.offset: delta must be a finite number");
83
- // chamfer is a true 45° bevel — Clipper2 has no bevel join, but a Round join
84
- // forced to a single chord per corner (circularSegments=4 → 1 segment per corner
85
- // whose turn ≤ 90°, i.e. interior angle ≥ 90°) IS the bevel: round's tangent points
86
- // are exactly the bevel's endpoints. Matches OCCT's `bevel` to float precision for
87
- // interior angle ≥ 90° (square 142.0000, pentagon 298.920). At acute (<90°) convex
88
- // corners Clipper2 emits 2 chords (ceil(turn/90°)), so Manifold bulges ~0.4% beyond
89
- // OCCT's single-chord bevel there. round = arc at mesh LOD; sharp = miter.
90
- const [joinType, cseg] = corners === "sharp" ? ["Miter", nSeg]
91
- : corners === "chamfer" ? ["Round", 4]
92
- : ["Round", nSeg];
93
- return cachedCS(h("offset2d", hash, delta, corners, cseg), () => {
94
- const out = T(cs.offset(delta, joinType, 2, cseg)); // miterLimit 2 (Clipper2 default)
95
- if (out.numContour() === 0) throw new Error("Shape2D.offset: offset collapses the shape (reduce |delta|)");
96
- return out;
97
- });
98
- },
99
- area: () => cs.area(),
100
- boundingBox: () => { const r = cs.bounds(); return { min: [r.min[0], r.min[1]], max: [r.max[0], r.max[1]] }; },
101
- toRegions: () => assembleRegions(cs.toPolygons()),
102
- clone: () => wrapShape2d(cs, hash),
103
- }, { shape2d, extrude: kernel.extrude, revolve: kernel.revolve });
104
- const shape2d = (profile) => {
105
- if (profile && profile._shape2d) return profile; // idempotent
106
- const hash = h("shape2d", profile, segs);
107
- return cachedCS(hash, () => {
108
- const { outer, holes } = tessellateProfile(profile, segs);
109
- return T(CrossSection.ofPolygons([outer, ...holes], "EvenOdd"));
110
- });
56
+ // 2-D offset logic. This is the ONE op the shared Shape2D cannot do on the
57
+ // contour IR itself, so it is the backend's hook into the factory below (and
58
+ // is also published as k._offsetRegions). resolveOffsetJoin validates corners/delta and picks
59
+ // Clipper2's join type + segment count:
60
+ // chamfer is a true 45° bevel — Clipper2 has no bevel join, but a Round join
61
+ // forced to a single chord per corner (circularSegments=4 → 1 segment per corner
62
+ // whose turn 90°, i.e. interior angle 90°) IS the bevel: round's tangent points
63
+ // are exactly the bevel's endpoints. Matches OCCT's `bevel` to float precision for
64
+ // interior angle ≥ 90° (square 142.0000, pentagon 298.920). At acute (<90°) convex
65
+ // corners Clipper2 emits 2 chords (ceil(turn/90°)), so Manifold bulges ~0.4% beyond
66
+ // OCCT's single-chord bevel there. round = arc at mesh LOD; sharp = miter.
67
+ const resolveOffsetJoin = (delta, corners, nSeg) => {
68
+ if (!["round", "chamfer", "sharp"].includes(corners))
69
+ throw new Error('Shape2D.offset: corners must be "round" | "chamfer" | "sharp"');
70
+ if (!Number.isFinite(delta)) throw new Error("Shape2D.offset: delta must be a finite number");
71
+ return corners === "sharp" ? ["Miter", nSeg]
72
+ : corners === "chamfer" ? ["Round", 4]
73
+ : ["Round", nSeg];
74
+ };
75
+ // Run the offset op on a T-tracked CrossSection; throws the pinned collapse message.
76
+ const offsetCS = (cs, delta, joinType, cseg) => {
77
+ const out = T(cs.offset(delta, joinType, 2, cseg)); // miterLimit 2 (Clipper2 default)
78
+ if (out.numContour() === 0) throw new Error("Shape2D.offset: offset collapses the shape (reduce |delta|)");
79
+ return out;
111
80
  };
112
81
 
82
+ // Contour-IR region list -> flat point rings at `nSeg` (outer + holes, even/odd
83
+ // fill sorts them out). The one place the IR meets CrossSection.ofPolygons.
84
+ const regionPolys = (regions, nSeg) => regions.flatMap((rg) =>
85
+ [tessellateContour(rg.outer, nSeg), ...rg.holes.map((hl) => tessellateContour(hl, nSeg))]);
86
+
87
+ // Region-in / region-out offset: tessellate the contour IR at `nSeg`, build a
88
+ // CrossSection, run the shared offset logic above, then lift the resulting point
89
+ // rings back into line contours via pointsToContour. This is Shape2D.offset's
90
+ // engine (wired into the factory below) and is also published as k._offsetRegions.
91
+ const offsetRegions = (regions, delta, { corners = "round", segs: nSeg = segs } = {}) => {
92
+ const [joinType, cseg] = resolveOffsetJoin(delta, corners, nSeg);
93
+ const cs = T(CrossSection.ofPolygons(regionPolys(regions, nSeg), "EvenOdd"));
94
+ const out = offsetCS(cs, delta, joinType, cseg);
95
+ return assembleRegions(out.toPolygons()).map((rg) => ({
96
+ outer: pointsToContour(rg.outer),
97
+ holes: rg.holes.map(pointsToContour),
98
+ }));
99
+ };
100
+
101
+ // 2-D boolean value: the SHARED Shape2D (shape2d.js). Storage is the curve-native
102
+ // contour IR and every op but `offset` runs on it in pure JS — no CrossSection is
103
+ // built until a shape is handed to a kernel op. `extrude`/`revolve` are thunks
104
+ // because `kernel` below is defined after this.
105
+ const shape2d = makeShape2dFactory({
106
+ segs,
107
+ offsetRegions,
108
+ extrude: (o) => kernel.extrude(o),
109
+ revolve: (o) => kernel.revolve(o),
110
+ });
111
+ // Lazy CrossSection materialization, memoized through the solid cache by content
112
+ // hash + LOD: the same shape extruded twice (or extruded and revolved) tessellates
113
+ // once, and the cache's pin/dispose keeps the WASM object alive exactly as long as
114
+ // the entry (cleanup() skips pinned objects).
115
+ const csFor = (shape) => cache.lookup(h("cs2d", shape._hash, segs), () => {
116
+ const cs = T(CrossSection.ofPolygons(regionPolys(shape._regions, segs), "EvenOdd"));
117
+ return { value: cs, pin: cs, dispose: () => cs.delete?.() };
118
+ });
119
+
113
120
  // Copy the mesh out into JS-owned arrays (so it survives cleanup) and free the
114
121
  // transient mesh handle.
115
122
  function meshOut(m, asStl) {
@@ -270,13 +277,13 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
270
277
  }),
271
278
  // Polygon-with-holes extrude in one op: even/odd fill turns the extra contours into
272
279
  // holes regardless of their winding (outer + holes, no per-hole boolean cut).
273
- // A Shape2D `profile` (already a CrossSection, possibly multi-region) extrudes
274
- // directly off its own `_cs` no re-tessellation — and folds into the cache
275
- // key by `_hash` like any other solid operand.
280
+ // A Shape2D `profile` (curve-native, possibly multi-region) materializes through
281
+ // csFor one memoized tessellation per shape+LOD — and folds into the cache key
282
+ // by `_hash` like any other solid operand.
276
283
  extrude: (profile, height, { twist = 0, scaleTop = 1 } = {}) => {
277
284
  const shape = profile && profile._shape2d ? profile : null;
278
285
  return cached(h("extrude", shape ? shape._hash : profile, height, twist, scaleTop, segs), () => {
279
- const cs = shape ? shape._cs : (() => {
286
+ const cs = shape ? csFor(shape) : (() => {
280
287
  const { outer, holes } = tessellateProfile(profile, segs);
281
288
  return T(CrossSection.ofPolygons([outer, ...holes], "EvenOdd"));
282
289
  })();
@@ -309,7 +316,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
309
316
  helixSweptTube: (o) => cached(h("helixSweptTube", o, tube), () => T(helixTube(wasm, { ...o, ...tube }))),
310
317
  revolve: (pts, { degrees = 360 } = {}) => {
311
318
  if (pts && pts._shape2d)
312
- return cached(h("revolve", pts._hash, degrees, segs), () => T(pts._cs.revolve(segs, degrees)));
319
+ return cached(h("revolve", pts._hash, degrees, segs), () => T(csFor(pts).revolve(segs, degrees)));
313
320
  return cached(h("revolve", pts, degrees, segs), () => T(Manifold.revolve([pts], segs, degrees)));
314
321
  },
315
322
  // A one-solid union is an identity — no new WASM / cache entry (avoids double-free):
@@ -319,6 +326,9 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
319
326
  ? solids[0]
320
327
  : cached(h("union", solids.map((s) => s._hash)), () => unionRaw(solids.map((s) => s._m))),
321
328
  shape2d,
329
+ // Backend-internal region adapter: the same function Shape2D.offset runs on
330
+ // (defined above). `_`-prefixed — not part of the public kernel surface.
331
+ _offsetRegions: offsetRegions,
322
332
  beginSubPart: (name) => cache.begin(name),
323
333
  endSubPart: () => cache.end(),
324
334
  sweepCache: () => cache.sweep(),
@@ -18,14 +18,14 @@
18
18
  import { toEdgeFinder } from "./edge-selector.js";
19
19
  import { toFaceFinder } from "./face-selector.js";
20
20
  import { addSugar } from "./solid-sugar.js";
21
- import { addShape2dSugar } from "./shape2d-sugar.js";
22
- import { assembleRegions, svgPathToRings, regionsArea, pointInRing, ringArea } from "./shape2d-regions.js";
21
+ import { makeShape2dFactory } from "./shape2d.js";
22
+ import { assembleRegions, svgPathToContours, pointInRing, ringArea } from "./shape2d-regions.js";
23
23
  import { finishKernel } from "./kernel-front.js";
24
24
  import { createOcctRepair } from "./occt-repair.js";
25
25
  import { classifyFaceGroups } from "./feature-attribution.js";
26
26
  import { resolveRings } from "./loft.js";
27
27
  import { resolveSweepStations } from "./sweep.js";
28
- import { normalizeProfile } from "./profile.js";
28
+ import { normalizeProfile, tessellateContour, reverseContour } from "./profile.js";
29
29
  import { roundedRectContour } from "./rounded-solids.js";
30
30
  import { h } from "./solid-hash.js";
31
31
  import { createSolidCache } from "./solid-cache.js";
@@ -318,96 +318,129 @@ export function createOcctKernel(replicad) {
318
318
  return pen.close();
319
319
  };
320
320
 
321
+ // Shape2D's tessellation LOD: the segment count toRegions()/simple() discretize
322
+ // curve contours at. 64 preserves the LOD the Drawing-backed Shape2D materialized
323
+ // at, so toRegions() output keeps the resolution parts already depend on.
324
+ const SHAPE2D_SEGS = 64;
321
325
  // Region (outer + holes) -> Drawing, exactly like extrude's former inline region
322
326
  // path: draw the outer contour, then .cut() each hole Drawing out of it.
323
- const SHAPE2D_SEGS = 64; // materialization LOD for toRegions() discretization
324
327
  const drawingFromProfile = (profile) => {
325
328
  const { outer, holes } = normalizeProfile(profile);
326
329
  let region = contourDrawing(outer);
327
330
  for (const hole of holes) region = region.cut(contourDrawing(hole));
328
331
  return region;
329
332
  };
330
- const liftDrawing = (x) => (x && x._shape2d ? x : shape2d(x));
331
- // Materialize a replicad Drawing into flat rings ready for the shared
332
- // assembleRegions (which buckets outer/hole by ring winding SIGN). TWO
333
- // corrections here, both confirmed against real replicad output rather than
334
- // guessed (see task-4-report.md's probe results):
333
+ // Region list -> fused Drawing: draw each region's outer contour, cut its holes
334
+ // out, then fuse every region together the multi-region generalization of
335
+ // drawingFromProfile above. Used by _offsetRegions to materialize an arbitrary
336
+ // region LIST (not a single { outer, holes }) into one Drawing to offset, and by
337
+ // extrude/revolve to materialize a Shape2D's stored contours into a Drawing.
338
+ const drawingFromRegions = (regions) => regions.reduce((acc, rg) => {
339
+ let region = contourDrawing(rg.outer);
340
+ for (const hole of rg.holes) region = region.cut(contourDrawing(hole));
341
+ return acc ? acc.fuse(region) : region;
342
+ }, null);
343
+
344
+ // y-negate a whole contour (start + every segment's to/via/c1/c2): toSVGPathD()
345
+ // renders in SVG's y-down convention, so everything read back out of a Drawing
346
+ // via SVG paths must be negated back into model space.
347
+ const negateContourY = (c) => ({
348
+ start: [c.start[0], -c.start[1]],
349
+ segments: c.segments.map((s) => {
350
+ const m = { to: [s.to[0], -s.to[1]] };
351
+ if (s.via) m.via = [s.via[0], -s.via[1]];
352
+ if (s.c1) { m.c1 = [s.c1[0], -s.c1[1]]; m.c2 = [s.c2[0], -s.c2[1]]; }
353
+ return m;
354
+ }),
355
+ });
356
+
357
+ // Classify + nest a flat list of curve-native contours into a region list.
335
358
  //
336
- // 1. toSVGPathD() renders in SVG's y-down convention, so every coordinate is
337
- // negated back to model space.
338
- // 2. Drawing.toSVGPaths() nests 0-2 levels deep depending on the result shape,
339
- // INCONSISTENTLY e.g. a single interior hole nests as
340
- // [[outerD, holeD]], but two disjoint holes built via sequential .cut()
341
- // calls (cutAll) come back as a flat [outerD, hole1D, hole2D] with no
342
- // grouping at all. So which array position is "the outer" can't be read off
343
- // the nesting shape. Worse: unlike Manifold's CrossSection.toPolygons()
344
- // (outer CCW/positive, hole CW/negative), replicad emits EVERY loop of a
345
- // region outer or hole with the same rotational sense, so ring winding
346
- // carries no outer/hole signal either (verified: an interior hole in a
347
- // 20x20 square, classified by sign alone, came back as 2 disjoint "outer"
348
- // regions summing 400+36 instead of one region netting 364).
349
- // The one signal that IS reliable is geometric containment DEPTH: classify
350
- // each ring by how many OTHER rings contain it (even-odd nesting), then
351
- // force each ring's winding to match its depth parity ABSOLUTELY — even depth
352
- // is an outer (CCW / positive area), odd depth is a hole (CW / negative area).
353
- // Setting the orientation absolutely (rather than reversing relative to the
354
- // emitted sense) is what makes this winding-agnostic: a CW-wound cut tool
355
- // makes replicad emit the hole loop with the opposite sense, and a relative
356
- // reversal would double-flip it back to a positive area — misbucketing the
357
- // hole as a second outer (409/2-regions/0-holes instead of 391/1/1).
358
- const drawingRegionRings = (drawing) => {
359
- const rings = drawing.toSVGPaths().flat(Infinity)
360
- .flatMap((d) => svgPathToRings(d, SHAPE2D_SEGS))
361
- .map((ring) => ring.map(([x, y]) => [x, -y]));
362
- const containedBy = rings.map((r, i) =>
363
- rings.reduce((n, other, j) => (i !== j && pointInRing(r[0], other) ? n + 1 : n), 0));
364
- return rings.map((r, i) => {
359
+ // Drawing.toSVGPaths() nests 0-2 levels deep depending on the result shape,
360
+ // INCONSISTENTLY (a single interior hole nests as [[outerD, holeD]], but two
361
+ // disjoint holes from sequential .cut() calls come back flat), so array position
362
+ // carries no outer/hole signal. Neither does winding: unlike Manifold's
363
+ // CrossSection.toPolygons() (outer CCW, hole CW), replicad emits EVERY loop of a
364
+ // region with the same rotational sense. The one signal that IS reliable is
365
+ // geometric containment DEPTH: count how many OTHER contours contain a sample
366
+ // point, then force each contour's winding to match its depth parity ABSOLUTELY
367
+ // (even depth = outer/CCW, odd = hole/CW) via reverseContour setting it
368
+ // absolutely rather than reversing relative to the emitted sense is what makes
369
+ // this winding-agnostic. The now-correctly-signed tessellations then go to
370
+ // assembleRegions for the actual smallest-containing-outer nesting, reusing that
371
+ // logic rather than reimplementing it. 32-segment sampling (vs. SHAPE2D_SEGS=64)
372
+ // is plenty for classification, which only needs containment and area sign.
373
+ const OFFSET_CLASSIFY_SEGS = 32;
374
+ const groupOffsetContours = (contours) => {
375
+ const samples = contours.map((c) => tessellateContour(c, OFFSET_CLASSIFY_SEGS));
376
+ const containedBy = contours.map((_, i) =>
377
+ samples.reduce((n, ring, j) => (i !== j && pointInRing(samples[i][0], ring) ? n + 1 : n), 0));
378
+ const oriented = contours.map((c, i) => {
365
379
  const wantOuter = containedBy[i] % 2 === 0; // even depth = outer
366
- return (ringArea(r) >= 0) === wantOuter ? r : r.slice().reverse();
380
+ const isCCW = ringArea(samples[i]) >= 0;
381
+ return isCCW === wantOuter
382
+ ? { contour: c, ring: samples[i] }
383
+ : { contour: reverseContour(c), ring: samples[i].slice().reverse() };
367
384
  });
385
+ const byRing = new Map(oriented.map(({ contour, ring }) => [ring, contour]));
386
+ const regions = assembleRegions(oriented.map(({ ring }) => ring));
387
+ return regions.map((rg) => ({ outer: byRing.get(rg.outer), holes: rg.holes.map((ring) => byRing.get(ring)) }));
368
388
  };
369
- // Shape2D carries a content hash (so a Solid op over a profile can key on it)
370
- // but stays UNCACHED Drawings already clone before every consuming replicad
371
- // call, and 2-D booleans are cheap next to the B-rep work they feed.
372
- const wrapShape2d = (drawing, hash) => {
373
- const toRegions = () => assembleRegions(drawingRegionRings(drawing));
374
- return addShape2dSugar({
375
- _drawing: drawing,
376
- _shape2d: true,
377
- _hash: hash,
378
- union: (o) => { const t = liftDrawing(o); return wrapShape2d(drawing.clone().fuse(t._drawing.clone()), h("union2d", hash, t._hash)); },
379
- cut: (o) => { const t = liftDrawing(o); return wrapShape2d(drawing.clone().cut(t._drawing.clone()), h("cut2d", hash, t._hash)); },
380
- cutAll: (os) => {
381
- const ts = os.map(liftDrawing);
382
- return wrapShape2d(ts.reduce((acc, t) => acc.cut(t._drawing.clone()), drawing.clone()), h("cutAll2d", hash, ts.map((t) => t._hash)));
383
- },
384
- intersect: (o) => { const t = liftDrawing(o); return wrapShape2d(drawing.clone().intersect(t._drawing.clone()), h("intersect2d", hash, t._hash)); },
385
- // corners map onto replicad's Offset2DConfig.lineJoinType; "chamfer" → "bevel", a
386
- // true 45° corner cut (a straight chord). Manifold now matches this via a
387
- // single-chord Round join (see manifold-backend offset) — the two agree to float
388
- // precision for convex corners with interior angle 90°; at acute (<90°) corners
389
- // Manifold uses a 2-facet approximation that departs slightly. See KERNEL-CONTRACT.
390
- offset: (delta, { corners = "round" } = {}) => {
391
- const lineJoinType = { round: "round", chamfer: "bevel", sharp: "miter" }[corners];
392
- if (!lineJoinType) throw new Error('Shape2D.offset: corners must be "round" | "chamfer" | "sharp"');
393
- if (!Number.isFinite(delta)) throw new Error("Shape2D.offset: delta must be a finite number");
394
- const result = drawing.clone().offset(delta, { lineJoinType }); // clone — replicad consumes the operand
395
- // Collapse doesn't throw and Drawing has no public `blueprints` array (that's on
396
- // Blueprints/CompoundBlueprint, not Drawing) — replicad instead returns a Drawing
397
- // whose private `innerShape` is null (confirmed by probe). That's the collapse signal.
398
- // NB: `innerShape` is replicad-internal; the "collapse throws immediately (OCCT)" test
399
- // guards this — a replicad upgrade that renames it must keep that test green.
400
- if (!result || !result.innerShape)
401
- throw new Error("Shape2D.offset: offset collapses the shape (reduce |delta|)");
402
- return wrapShape2d(result, h("offset2d", hash, delta, corners));
403
- },
404
- area: () => regionsArea(toRegions()), // no native Drawing area → derive from materialized regions
405
- boundingBox: () => { const b = drawing.boundingBox; return { min: [b.bounds[0][0], b.bounds[0][1]], max: [b.bounds[1][0], b.bounds[1][1]] }; },
406
- toRegions,
407
- clone: () => wrapShape2d(drawing.clone(), hash),
408
- }, { shape2d, extrude: kernel.extrude, revolve: kernel.revolve });
389
+
390
+ // Offset logic (corners validation, replicad Offset2DConfig join-type mapping,
391
+ // collapse detection via `innerShape`), Drawing-in/Drawing-out. This is the ONE
392
+ // op the shared Shape2D cannot do on the contour IR itself — offsetRegions below
393
+ // wraps it as the backend's hook. corners map onto replicad's Offset2DConfig
394
+ // lineJoinType; "chamfer" -> "bevel", a true 45° bevel — a straight chord.
395
+ // Manifold now matches this via a single-chord Round join (see
396
+ // manifold-backend offset) — the two agree to float precision for convex
397
+ // corners with interior angle >= 90°; at acute (<90°) corners Manifold uses a
398
+ // 2-facet approximation that departs slightly. See KERNEL-CONTRACT.
399
+ const offsetDrawing = (drawing, delta, corners) => {
400
+ const lineJoinType = { round: "round", chamfer: "bevel", sharp: "miter" }[corners];
401
+ if (!lineJoinType) throw new Error('Shape2D.offset: corners must be "round" | "chamfer" | "sharp"');
402
+ if (!Number.isFinite(delta)) throw new Error("Shape2D.offset: delta must be a finite number");
403
+ // No clone: offset CONSUMES its operand, and the sole caller (offsetRegions) hands
404
+ // in a Drawing it just built from the contour IR, owned by nobody else.
405
+ const result = drawing.offset(delta, { lineJoinType });
406
+ // Collapse doesn't throw and Drawing has no public `blueprints` array (that's on
407
+ // Blueprints/CompoundBlueprint, not Drawing) — replicad instead returns a Drawing
408
+ // whose private `innerShape` is null (confirmed by probe). That's the collapse signal.
409
+ // NB: `innerShape` is replicad-internal; the "collapse throws immediately (OCCT)" test
410
+ // guards this a replicad upgrade that renames it must keep that test green.
411
+ if (!result || !result.innerShape)
412
+ throw new Error("Shape2D.offset: offset collapses the shape (reduce |delta|)");
413
+ return result;
409
414
  };
410
- const shape2d = (profile) => (profile && profile._shape2d ? profile : wrapShape2d(drawingFromProfile(profile), h("shape2d", profile)));
415
+ // Region-in / region-out offset: fuse the region list into one Drawing, run the
416
+ // shared offset logic, then read the curve-native result back via SVG paths —
417
+ // y-negate (toSVGPathD is y-down), svgPathToContours per subpath (curves survive
418
+ // as cubics, no facet fan), then classify/orient/nest via groupOffsetContours.
419
+ // This is Shape2D.offset's engine (wired into the factory below) and is also
420
+ // published as k._offsetRegions.
421
+ const offsetRegions = (regions, delta, { corners = "round" } = {}) => {
422
+ const result = offsetDrawing(drawingFromRegions(regions), delta, corners);
423
+ const contours = result.toSVGPaths().flat(Infinity)
424
+ .flatMap((d) => svgPathToContours(d).map(negateContourY));
425
+ return groupOffsetContours(contours);
426
+ };
427
+ // 2-D boolean value: the SHARED Shape2D (shape2d.js), identical to the Manifold
428
+ // backend's. Storage is the curve-native contour IR and every op but `offset`
429
+ // runs on it in pure JS, so no Drawing exists until a shape is handed to a
430
+ // kernel op. `extrude`/`revolve` are thunks because `kernel` is defined below.
431
+ const shape2d = makeShape2dFactory({
432
+ segs: SHAPE2D_SEGS,
433
+ offsetRegions,
434
+ extrude: (o) => kernel.extrude(o),
435
+ revolve: (o) => kernel.revolve(o),
436
+ });
437
+ // Lazy Drawing materialization for the kernel ops that need one. drawingFromRegions
438
+ // draws a FRESH Drawing on every call, so callers never need to .clone() the result
439
+ // before handing it to a consuming replicad op.
440
+ // An empty shape (e.g. the intersection of two disjoint shapes) has no regions to
441
+ // draw; say so rather than letting the null reach replicad as a TypeError.
442
+ const drawingFor = (shape) => drawingFromRegions(shape._regions)
443
+ ?? (() => { throw new Error("Shape2D: the shape is empty — nothing to build"); })();
411
444
 
412
445
  // extrude a 2-D polygon from z=0 (arguments validated by the kernel front)
413
446
  const prism = (pts, hgt, { twist = 0, scaleTop = 1 } = {}) => {
@@ -426,19 +459,20 @@ export function createOcctKernel(replicad) {
426
459
  const revolve = (pts, { degrees = 360 } = {}) => {
427
460
  const key = h("revolve", pts && pts._shape2d ? pts._hash : pts, degrees);
428
461
  return cached(key, () => {
429
- const region = pts && pts._shape2d ? pts._drawing.clone() : contourDrawing(pts);
462
+ const region = pts && pts._shape2d ? drawingFor(pts) : contourDrawing(pts);
430
463
  return wrap(region.sketchOnPlane("XZ").revolve([0, 0, 1], { angle: degrees }), [], key);
431
464
  });
432
465
  };
433
466
 
434
467
  // extrude a polygon-with-holes region from z=0: cut each hole Drawing out of the outer
435
468
  // Drawing (winding-agnostic 2-D boolean), sketch it, then extrude (twist/taper via cfg).
436
- // A Shape2D `profile` (already a Drawing, possibly multi-region) extrudes directly off
437
- // its own `_drawing` (cloned replicad booleans/extrude consume their operand).
469
+ // A Shape2D `profile` (curve-native, possibly multi-region) materializes through
470
+ // drawingFor a fresh Drawing per call, so no .clone() is needed before the
471
+ // sketch consumes it (replicad booleans/extrude consume their operand).
438
472
  const extrude = (profile, hgt, { twist = 0, scaleTop = 1 } = {}) => {
439
473
  const key = h("extrude", profile && profile._shape2d ? profile._hash : profile, hgt, twist, scaleTop);
440
474
  return cached(key, () => {
441
- const region = profile && profile._shape2d ? profile._drawing.clone() : drawingFromProfile(profile);
475
+ const region = profile && profile._shape2d ? drawingFor(profile) : drawingFromProfile(profile);
442
476
  const sketch = region.sketchOnPlane("XY");
443
477
  if (twist === 0 && scaleTop === 1) return wrap(sketch.extrude(hgt), [], key);
444
478
  const cfg = {};
@@ -518,6 +552,9 @@ export function createOcctKernel(replicad) {
518
552
  });
519
553
  },
520
554
  shape2d,
555
+ // Backend-internal region adapter: the same function Shape2D.offset runs on
556
+ // (defined above). `_`-prefixed — not part of the public kernel surface.
557
+ _offsetRegions: offsetRegions,
521
558
  toSTEP: (named) => exportSTEP(named.map(({ name, solid }) => ({ name, shape: solid._mat()._s }))).arrayBuffer(),
522
559
  beginSubPart: (name) => cache.begin(name),
523
560
  endSubPart: () => cache.end(),