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.
@@ -0,0 +1,224 @@
1
+ // Lazy, private PaperScope: built on first use (not at module load), so parts that never
2
+ // call k.text2d don't pull paper-core's setup onto the geometry worker. Never paper's
3
+ // package-global project — another consumer in the same worker may import paper too.
4
+ import paper from "paper/dist/paper-core.js";
5
+ import { tessellateContour, reverseContour, closeContourGap } from "./profile.js";
6
+
7
+ const ORIENT_SEGS = 8; // points/segment for the local orientation sampler below
8
+
9
+ let _scope = null;
10
+ function paperScope() {
11
+ if (!_scope) { _scope = new paper.PaperScope(); _scope.setup(new _scope.Size(1, 1)); }
12
+ return _scope;
13
+ }
14
+
15
+ // Circumcircle center + signed sweep for the arc through (p0, via, to) — the sweep is the
16
+ // one passing through `via` (sign-free, winding-free), same recovery as profile.js's
17
+ // sampleArc. Returns null for a collinear (degenerate) triple. Shared by arcToCubicSegments
18
+ // below and contour-ops.js's jointTangents (arc tangents are ⊥ radius, oriented by dA's sign).
19
+ export function arcCenterAndSweep(p0, via, to) {
20
+ const [ax, ay] = p0, [bx, by] = via, [cx, cy] = to;
21
+ const d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));
22
+ if (Math.abs(d) < 1e-12) return null;
23
+ const sa = ax*ax + ay*ay, sb = bx*bx + by*by, sc = cx*cx + cy*cy;
24
+ const ux = (sa * (by - cy) + sb * (cy - ay) + sc * (ay - by)) / d;
25
+ const uy = (sa * (cx - bx) + sb * (ax - cx) + sc * (bx - ax)) / d;
26
+ const r = Math.hypot(ax - ux, ay - uy);
27
+ const a0 = Math.atan2(ay - uy, ax - ux);
28
+ const av = Math.atan2(by - uy, bx - ux);
29
+ const a1 = Math.atan2(cy - uy, cx - ux);
30
+ const twoPi = 2 * Math.PI;
31
+ const ccw = (x) => { let v = x % twoPi; if (v < 0) v += twoPi; return v; };
32
+ const dCCW = ccw(a1 - a0), vCCW = ccw(av - a0);
33
+ const dA = vCCW <= dCCW ? dCCW : dCCW - twoPi;
34
+ return { center: [ux, uy], r, dA };
35
+ }
36
+
37
+ // Circular arc through (p0, via, to) → cubic Bézier segments, ≤90° each, endpoints
38
+ // exact. Each piece uses the standard k = (4/3)·tan(θ/4) control-point offset.
39
+ // Collinear triple → straight segment.
40
+ export function arcToCubicSegments(p0, via, to) {
41
+ const cx = to[0], cy = to[1];
42
+ const c = arcCenterAndSweep(p0, via, to);
43
+ if (!c) return [{ to: [cx, cy] }];
44
+ const { center: [ux, uy], r, dA } = c;
45
+ const a0 = Math.atan2(p0[1] - uy, p0[0] - ux);
46
+ // Handle floating-point precision: angles very close to π/2 boundaries
47
+ const pieces = Math.max(1, Math.ceil((Math.abs(dA) - 1e-9) / (Math.PI / 2)));
48
+ const out = [];
49
+ for (let i = 0; i < pieces; i++) {
50
+ const t0 = a0 + dA * (i / pieces), t1 = a0 + dA * ((i + 1) / pieces);
51
+ const dt = t1 - t0, k = (4 / 3) * Math.tan(dt / 4);
52
+ const P = (t) => [ux + r * Math.cos(t), uy + r * Math.sin(t)];
53
+ const s = P(t0), e = P(t1);
54
+ out.push({
55
+ to: e,
56
+ c1: [s[0] - k * r * Math.sin(t0), s[1] + k * r * Math.cos(t0)],
57
+ c2: [e[0] + k * r * Math.sin(t1), e[1] - k * r * Math.cos(t1)],
58
+ });
59
+ }
60
+ out[out.length - 1].to = [cx, cy]; // pin the exact endpoint
61
+ return out;
62
+ }
63
+
64
+ export function toPaperPath(scope, contour, segMap = null, { open = false } = {}) {
65
+ const path = new scope.Path({ insert: false });
66
+ path.moveTo(new scope.Point(contour.start[0], contour.start[1]));
67
+ let prev = contour.start;
68
+ contour.segments.forEach((s, i) => {
69
+ if (s.via) {
70
+ // Expand {to,via} arc into cubic segments, all sharing one segMap entry
71
+ const cubics = arcToCubicSegments(prev, s.via, s.to);
72
+ for (const cubic of cubics) {
73
+ if (cubic.c1) {
74
+ path.cubicCurveTo(
75
+ new scope.Point(cubic.c1[0], cubic.c1[1]),
76
+ new scope.Point(cubic.c2[0], cubic.c2[1]),
77
+ new scope.Point(cubic.to[0], cubic.to[1]));
78
+ } else {
79
+ // Collinear triple: emit straight segment
80
+ path.lineTo(new scope.Point(cubic.to[0], cubic.to[1]));
81
+ }
82
+ if (segMap) segMap.push(i);
83
+ }
84
+ prev = s.to;
85
+ } else if (s.c1) {
86
+ path.cubicCurveTo(
87
+ new scope.Point(s.c1[0], s.c1[1]),
88
+ new scope.Point(s.c2[0], s.c2[1]),
89
+ new scope.Point(s.to[0], s.to[1]));
90
+ if (segMap) segMap.push(i);
91
+ prev = s.to;
92
+ } else {
93
+ path.lineTo(new scope.Point(s.to[0], s.to[1]));
94
+ if (segMap) segMap.push(i);
95
+ prev = s.to;
96
+ }
97
+ });
98
+ if (!open) {
99
+ path.closePath();
100
+ // A contour authored without an explicit closing segment (e.g. pathProfile(...).close())
101
+ // relies on closePath() to synthesize the closing curve — segMap never saw it. Give it
102
+ // the next index after the last authored segment so callers can recognize "the implicit
103
+ // close". When the contour DOES have an explicit closing segment, closePath() joins the
104
+ // coincident start/end segments and curve count already matches segMap — don't push then.
105
+ if (segMap && path.curves.length === segMap.length + 1) segMap.push(contour.segments.length);
106
+ }
107
+ return path;
108
+ }
109
+
110
+ function toContour(path) {
111
+ const segs = path.segments;
112
+ const start = [segs[0].point.x, segs[0].point.y];
113
+ const out = { start, segments: [] };
114
+ for (let i = 0; i < segs.length; i++) {
115
+ const a = segs[i], b = segs[(i + 1) % segs.length];
116
+ const straight = a.handleOut.isZero() && b.handleIn.isZero();
117
+ const closing = i === segs.length - 1;
118
+ if (closing && straight) continue; // implicit straight close
119
+ const to = [b.point.x, b.point.y];
120
+ if (straight) out.segments.push({ to });
121
+ 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] });
122
+ }
123
+ return out;
124
+ }
125
+
126
+ // Open-path counterpart to toContour: every segment is a real curve of the path (no
127
+ // wrap-around, no implicit-close skip) — for readback of paths built with {open: true}.
128
+ function toOpenContour(path) {
129
+ const segs = path.segments;
130
+ const start = [segs[0].point.x, segs[0].point.y];
131
+ const out = { start, segments: [] };
132
+ for (let i = 0; i < segs.length - 1; i++) {
133
+ const a = segs[i], b = segs[i + 1];
134
+ const straight = a.handleOut.isZero() && b.handleIn.isZero();
135
+ const to = [b.point.x, b.point.y];
136
+ if (straight) out.segments.push({ to });
137
+ 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] });
138
+ }
139
+ return out;
140
+ }
141
+
142
+ // Group while paths are still Paper geometry. Path.area includes cubic handles and
143
+ // interiorPoint is guaranteed to lie inside the curve; never reduce curves to endpoint rings.
144
+ function groupPaperPaths(paths) {
145
+ const largest = paths.reduce((a, b) => Math.abs(b.area) > Math.abs(a.area) ? b : a);
146
+ const outerClockwise = largest.clockwise;
147
+ const outers = paths.filter((p) => p.clockwise === outerClockwise)
148
+ .map((path) => ({ path, holes: [] }));
149
+ for (const hole of paths.filter((p) => p.clockwise !== outerClockwise)) {
150
+ const home = outers.filter((o) => o.path.contains(hole.interiorPoint))
151
+ .sort((a, b) => Math.abs(a.path.area) - Math.abs(b.path.area))[0];
152
+ if (!home) throw new Error("curve-fill: resolved hole has no containing outer");
153
+ home.holes.push(hole);
154
+ }
155
+ return outers.map(({ path, holes }) => ({
156
+ outer: toContour(path),
157
+ holes: holes.map(toContour),
158
+ }));
159
+ }
160
+
161
+ // Signed shoelace area of a tessellated ring (CCW positive) — a tiny local sampler so
162
+ // this module doesn't need shape2d-regions.js's ringArea for one call site.
163
+ function shoelaceArea(ring) {
164
+ let a = 0;
165
+ for (let i = 0; i < ring.length; i++) {
166
+ const [x1, y1] = ring[i], [x2, y2] = ring[(i + 1) % ring.length];
167
+ a += x1 * y2 - x2 * y1;
168
+ }
169
+ return a / 2;
170
+ }
171
+
172
+ // groupPaperPaths (containment-based outer/hole assignment), plus a winding-normalization
173
+ // pass so the emitted contours carry the storage invariant (outer CCW, holes CW in
174
+ // model y-up space). Paper's own `clockwise` flag is in paper's y-down coordinate frame,
175
+ // so it doesn't map onto that invariant directly — decide by area sign of the emitted
176
+ // contour instead: tessellate it and check the shoelace sign, reversing when wrong.
177
+ // closeContourGap re-closes the ring explicitly: toContour() (used by groupPaperPaths,
178
+ // via curve-fill.js too — see its own comment) drops a straight closing edge as
179
+ // "implicit," but every Shape2D region stored from here on must be explicitly closed —
180
+ // contour-ops.js's corner math reads `segments[n-1]` directly as the edge arriving back
181
+ // at `start` and has no other way to know the ring isn't fully spelled out.
182
+ function groupPaperPathsOriented(paths) {
183
+ const regions = groupPaperPaths(paths);
184
+ return regions.map(({ outer, holes }) => ({
185
+ outer: closeContourGap(shoelaceArea(tessellateContour(outer, ORIENT_SEGS)) >= 0 ? outer : reverseContour(outer)),
186
+ holes: holes.map((h) => closeContourGap(shoelaceArea(tessellateContour(h, ORIENT_SEGS)) < 0 ? h : reverseContour(h))),
187
+ }));
188
+ }
189
+
190
+ function cloneRegion(rg) {
191
+ return JSON.parse(JSON.stringify(rg));
192
+ }
193
+
194
+ function regionsToCompound(scope, regions) {
195
+ const children = [];
196
+ for (const rg of regions) {
197
+ children.push(toPaperPath(scope, rg.outer));
198
+ for (const h of rg.holes) children.push(toPaperPath(scope, h));
199
+ }
200
+ return new scope.CompoundPath({ children, fillRule: "evenodd" });
201
+ }
202
+
203
+ // Boolean op between two region lists (each in contour IR) via paper.js's planar boolean
204
+ // engine. op: "unite" | "subtract" | "intersect" — same semantics as bracket.js's Shape2D
205
+ // toolkit. Result regions come back in contour IR with the storage winding invariant
206
+ // (outer CCW, holes CW) restored by groupPaperPathsOriented. Empty result → [].
207
+ export function booleanRegions(aRegions, bRegions, op) {
208
+ if (!["unite", "subtract", "intersect"].includes(op)) throw new Error(`booleanRegions: unknown op "${op}"`);
209
+ if (aRegions.length === 0) return op === "unite" ? bRegions.map(cloneRegion) : [];
210
+ if (bRegions.length === 0) return op === "intersect" ? [] : aRegions.map(cloneRegion);
211
+ const scope = paperScope();
212
+ try {
213
+ const A = regionsToCompound(scope, aRegions), B = regionsToCompound(scope, bRegions);
214
+ const out = A[op](B, { insert: false });
215
+ const paths = (out.className === "CompoundPath" ? out.children : [out])
216
+ .filter((p) => p.segments && p.segments.length >= 2 && Math.abs(p.area) > 1e-9);
217
+ if (!paths.length) return [];
218
+ return groupPaperPathsOriented(paths);
219
+ } finally {
220
+ scope.project.clear();
221
+ }
222
+ }
223
+
224
+ export { paperScope, toContour, toOpenContour, groupPaperPaths };
@@ -430,3 +430,11 @@ export function offsetPolygon(profile, delta, opts = {}) {
430
430
  throw new Error("offsetPolygon: offset result self-intersects (reduce |delta| or simplify the profile)");
431
431
  return cleaned;
432
432
  }
433
+
434
+ // The 2-D editing surface (transforms, corner ops, queries, cleanup, validation) lives in
435
+ // contour-ops.js; re-exported here so `partforge/geometry` stays the single import surface.
436
+ export { translateProfile, rotateProfile, scaleProfile, mirrorProfile,
437
+ filletProfile, chamferProfile, profileCorners,
438
+ profileLength, profilePointAt, profileTangentAt, profileNearestPoint,
439
+ profileBounds, profileArea, profileContains,
440
+ simplifyProfile, validateProfile } from "./contour-ops.js";
@@ -19,13 +19,21 @@ import {
19
19
  } from "./kernel.js";
20
20
  import { KERNEL_OP_SPECS, SOLID_OP_SPECS, isPlainOptions } from "./op-options.js";
21
21
 
22
- // The probe returns ONE chainable handle for every non-query op, so it cannot tell a
23
- // Solid from a Shape2D k.box() and k.shape2d() yield the same object. The solid-scope
24
- // allowlist is therefore the union of all three surfaces: deliberately permissive, so
25
- // it never false-positives on an error-severity rule.
22
+ // The probe hands out TWO chainable handles: k.shape2d()/k.text2d() yield a Shape2D
23
+ // handle (whose extrude/revolve yield the Solid handle), everything else yields the
24
+ // Solid handle. The split exists for one reason backend routing: `Shape2D.fillet`
25
+ // is the shared pure-JS implementation and must NOT route a part to OCCT, while
26
+ // `Solid.fillet` must. Handle-level VALIDATION stays deliberately permissive (one
27
+ // union allowlist for both handle kinds), so the split can never false-positive on
28
+ // an error-severity rule.
26
29
  const KERNEL_ALLOWED = new Set([...KERNEL_OPS, ...KERNEL_OPTIONAL_OPS]);
27
30
  const SOLID_ALLOWED = new Set([...SOLID_OPS, ...SOLID_OPTIONAL_OPS, ...SHAPE2D_OPS]);
28
31
 
32
+ // Kernel ops whose result is a Shape2D, and Shape2D ops whose result is a Solid —
33
+ // the only two places the probe's handle kind changes.
34
+ const SHAPE2D_YIELDING_KERNEL_OPS = new Set(["shape2d", "text2d"]);
35
+ const SOLID_YIELDING_SHAPE2D_OPS = new Set(["extrude", "revolve"]);
36
+
29
37
  export const MAX_PROBE_OPS = 100000;
30
38
 
31
39
  // Thrown to unwind a runaway build. Never escapes runValidatingProbe.
@@ -64,37 +72,52 @@ function makeProbe(onCall) {
64
72
  // every query bypass the counter entirely, so a query-only loop (`for(;;)
65
73
  // s.volume()`) never tripped MAX_PROBE_OPS and hung forever. Wrap it the same
66
74
  // way as the chaining branch below: observe the call, then run the real query.
67
- const opProxy = (queries, scope) => new Proxy({}, {
75
+ // `routeFor(op)` picks which handle an op's result chains to — resolved at call
76
+ // time so the three proxies can reference each other despite declaration order.
77
+ const opProxy = (queries, scope, routeFor) => new Proxy({}, {
68
78
  get(_t, key) {
69
79
  if (ignore(key)) return undefined;
70
80
  if (key in queries) return (...args) => { onCall(scope, key, args); return queries[key](...args); };
71
- return (...args) => { onCall(scope, key, args); return proxy; };
81
+ return (...args) => { onCall(scope, key, args); return routeFor(key); };
72
82
  },
73
83
  });
74
84
 
75
- const proxy = opProxy(solidQueries, "solid"); // a solid handle: every op chains back to itself
76
- const kernel = opProxy(kernelQueries, "kernel"); // factory ops (cylinder/box/prism/…) return a solid
77
- return { kernel, proxy };
85
+ // Shape2D handles reuse solidQueries: boundingBox/volume/… dummies chain the same
86
+ // either way, and a 3-component bbox is as good a dummy as a 2-component one.
87
+ const proxy = opProxy(solidQueries, "solid", () => proxy); // a solid handle: every op chains back to itself
88
+ const shape2d = opProxy(solidQueries, "shape2d",
89
+ (key) => (SOLID_YIELDING_SHAPE2D_OPS.has(key) ? proxy : shape2d));
90
+ const kernel = opProxy(kernelQueries, "kernel", // factory ops (cylinder/box/prism/…) return a solid
91
+ (key) => (SHAPE2D_YIELDING_KERNEL_OPS.has(key) ? shape2d : proxy));
92
+ return { kernel, proxy, shape2d };
78
93
  }
79
94
 
80
95
  export function createProbeKernel() {
81
- const used = new Set();
82
- const { kernel } = makeProbe((_scope, key) => used.add(key));
83
- return { kernel, used };
96
+ const used = new Set(); // every op name, any handle kind
97
+ const solidUsed = new Set(); // ops recorded on kernel/Solid handles only — the
98
+ // routing set: Shape2D.fillet must not look like Solid.fillet
99
+ const { kernel } = makeProbe((scope, key) => {
100
+ used.add(key);
101
+ if (scope !== "shape2d") solidUsed.add(key);
102
+ });
103
+ return { kernel, used, solidUsed };
84
104
  }
85
105
 
86
106
  export function createValidatingProbe({ maxOps = MAX_PROBE_OPS } = {}) {
87
107
  const calls = [];
88
108
  const issues = [];
89
109
  const used = new Set();
110
+ const solidUsed = new Set(); // same split as createProbeKernel: non-Shape2D-handle ops
90
111
  let count = 0;
91
112
  let solidProxy = null;
113
+ let shape2dProxy = null;
92
114
 
93
115
  // Args are recorded as strings so two probe runs can be compared for determinism.
94
- // The chainable handle is a single shared object, so identity is enough to spot it —
116
+ // Each chainable handle is a single shared object, so identity is enough to spot it —
95
117
  // and checking identity FIRST matters, because JSON.stringify would trip its traps.
96
118
  const describe = (a) => {
97
119
  if (a === solidProxy) return "<solid>";
120
+ if (a === shape2dProxy) return "<shape2d>";
98
121
  if (typeof a === "function") return "<fn>";
99
122
  try { return JSON.stringify(a) ?? String(a); } catch { return "<unserializable>"; }
100
123
  };
@@ -102,6 +125,7 @@ export function createValidatingProbe({ maxOps = MAX_PROBE_OPS } = {}) {
102
125
  const onCall = (scope, op, args) => {
103
126
  if (++count > maxOps) throw new ProbeRunawayError(`build exceeded ${maxOps} kernel operations`);
104
127
  used.add(op);
128
+ if (scope !== "shape2d") solidUsed.add(op);
105
129
  const allowed = scope === "kernel" ? KERNEL_ALLOWED : SOLID_ALLOWED;
106
130
  if (!allowed.has(op)) issues.push({ kind: "unknown-op", scope, op });
107
131
  // Validate ONLY the options form — the normative rule (KERNEL-CONTRACT.md
@@ -118,9 +142,10 @@ export function createValidatingProbe({ maxOps = MAX_PROBE_OPS } = {}) {
118
142
  calls.push({ scope, op, args: args.map(describe) });
119
143
  };
120
144
 
121
- const { kernel, proxy } = makeProbe(onCall);
145
+ const { kernel, proxy, shape2d } = makeProbe(onCall);
122
146
  solidProxy = proxy;
123
- return { kernel, calls, issues, used };
147
+ shape2dProxy = shape2d;
148
+ return { kernel, calls, issues, used, solidUsed };
124
149
  }
125
150
 
126
151
  /**
@@ -140,5 +165,5 @@ export function runValidatingProbe(part, p, d, { maxOps = MAX_PROBE_OPS } = {})
140
165
  throws.push({ subpart: name, message: e?.message || String(e) });
141
166
  }
142
167
  }
143
- return { calls: probe.calls, issues: probe.issues, used: probe.used, throws, runaway };
168
+ return { calls: probe.calls, issues: probe.issues, used: probe.used, solidUsed: probe.solidUsed, throws, runaway };
144
169
  }
@@ -143,3 +143,54 @@ export function tessellateProfile(profile, segs) {
143
143
  const { outer, holes } = normalizeProfile(profile);
144
144
  return { outer: tessellateContour(outer, segs), holes: holes.map((hl) => tessellateContour(hl, segs)) };
145
145
  }
146
+
147
+ // Build a straight-edged path contour from a bare point list — the canonical lift for
148
+ // legacy [[x,y],…] inputs into the { start, segments } IR. Lives here (not contour-ops.js)
149
+ // so paper-bridge.js can reach it without importing contour-ops (contour-ops already
150
+ // imports paper-bridge; this module is a pure leaf both can share without a cycle).
151
+ export function pointsToContour(points) {
152
+ return { start: [points[0][0], points[0][1]],
153
+ segments: [...points.slice(1).map((p) => ({ to: [p[0], p[1]] })), { to: [points[0][0], points[0][1]] }] };
154
+ }
155
+
156
+ // Ensure a contour's ring is EXPLICITLY closed: the last segment's `to` must coincide
157
+ // with `start`. Several contour producers leave that closing edge only implicit —
158
+ // paper.js's own Path#closePath() (see toContour() in paper-bridge.js, which drops a
159
+ // straight closing edge as redundant) and any hand-authored `pathProfile(...).close()`
160
+ // that never revisits its own start — relying on a downstream consumer (tessellation,
161
+ // an SVG "Z") to re-synthesize the missing edge. `contourCorners`/`buildCornerOpRing`
162
+ // (contour-ops.js) don't re-synthesize anything: they read `contour.segments` directly
163
+ // and assume `segments[n-1]` really is the edge arriving back at `start` (corner 0's
164
+ // "previous segment", and the wraparound neighbor for every other corner's modular
165
+ // indexing). When that assumption is false, corner 0 gets paired with the wrong
166
+ // segment entirely — if that segment is curved, the fillet/chamfer tangency solve
167
+ // fails outright (`could not fit ... max ≈ 0`); if it's a line, the corner's position
168
+ // and radius are silently miscomputed instead. Call this wherever a contour is about
169
+ // to be stored (Shape2D regions) or handed to any contour-ops function; a no-op when
170
+ // the ring is already closed, so it's safe to call unconditionally.
171
+ export function closeContourGap(contour) {
172
+ const segs = contour.segments;
173
+ if (!segs || segs.length === 0) return contour;
174
+ const [sx, sy] = contour.start;
175
+ const [lx, ly] = segs[segs.length - 1].to;
176
+ if (Math.hypot(lx - sx, ly - sy) <= 1e-9) return contour;
177
+ return { start: contour.start, segments: [...segs, { to: [sx, sy] }] };
178
+ }
179
+
180
+ // Reverse a contour's traversal direction: walks segments back-to-front, swapping each
181
+ // cubic's control points (c1 ↔ c2) and keeping `via` (an arc's through-point is
182
+ // direction-independent). Lives here alongside pointsToContour for the same reason —
183
+ // paper-bridge.js's booleanRegions needs it to normalize emitted winding without
184
+ // importing contour-ops.js.
185
+ export function reverseContour(contour) {
186
+ const pts = [contour.start, ...contour.segments.map((s) => s.to)];
187
+ const segments = [];
188
+ for (let i = contour.segments.length - 1; i >= 0; i--) {
189
+ const s = contour.segments[i];
190
+ const m = { to: [pts[i][0], pts[i][1]] };
191
+ if (s.via) m.via = [s.via[0], s.via[1]];
192
+ if (s.c1) { m.c1 = [s.c2[0], s.c2[1]]; m.c2 = [s.c1[0], s.c1[1]]; }
193
+ segments.push(m);
194
+ }
195
+ return { start: [pts[pts.length - 1][0], pts[pts.length - 1][1]], segments };
196
+ }
@@ -97,6 +97,62 @@ function sampleSvgArc(from, rx, ry, rotDeg, largeArc, sweep, to, segs) {
97
97
  return out;
98
98
  }
99
99
 
100
+ // Split an SVG elliptical-arc segment (endpoint parameterization, same center-form
101
+ // math as sampleSvgArc above) into ≤90° cubic Bézier pieces via the standard
102
+ // k = (4/3)tan(dθ/4) control-point formula in the ellipse's own rotated/scaled
103
+ // frame, then mapped back through the rotation+translation into model space.
104
+ // Returns segments as { to, c1, c2 } (no `from` — the caller already holds it).
105
+ function svgArcToCubics(from, rx, ry, rotDeg, largeArc, sweep, to) {
106
+ const [x1, y1] = from, [x2, y2] = to;
107
+ if (rx === 0 || ry === 0) return [{ to: [x2, y2] }];
108
+ const phi = (rotDeg * Math.PI) / 180, cosP = Math.cos(phi), sinP = Math.sin(phi);
109
+ const dx = (x1 - x2) / 2, dy = (y1 - y2) / 2;
110
+ const x1p = cosP * dx + sinP * dy, y1p = -sinP * dx + cosP * dy;
111
+ let RX = Math.abs(rx), RY = Math.abs(ry);
112
+ const lambda = (x1p * x1p) / (RX * RX) + (y1p * y1p) / (RY * RY);
113
+ if (lambda > 1) { const s = Math.sqrt(lambda); RX *= s; RY *= s; }
114
+ const numr = RX * RX * RY * RY - RX * RX * y1p * y1p - RY * RY * x1p * x1p;
115
+ const den = RX * RX * y1p * y1p + RY * RY * x1p * x1p;
116
+ let coef = Math.sqrt(Math.max(0, numr / den));
117
+ if (Boolean(largeArc) === Boolean(sweep)) coef = -coef;
118
+ const cxp = (coef * RX * y1p) / RY, cyp = (-coef * RY * x1p) / RX;
119
+ const cx = cosP * cxp - sinP * cyp + (x1 + x2) / 2;
120
+ const cy = sinP * cxp + cosP * cyp + (y1 + y2) / 2;
121
+ const angle = (ux, uy, vx, vy) => {
122
+ const dot = ux * vx + uy * vy, len = Math.hypot(ux, uy) * Math.hypot(vx, vy) || 1e-12;
123
+ let a = Math.acos(Math.min(1, Math.max(-1, dot / len)));
124
+ if (ux * vy - uy * vx < 0) a = -a;
125
+ return a;
126
+ };
127
+ const theta1 = angle(1, 0, (x1p - cxp) / RX, (y1p - cyp) / RY);
128
+ let dTheta = angle((x1p - cxp) / RX, (y1p - cyp) / RY, (-x1p - cxp) / RX, (-y1p - cyp) / RY);
129
+ if (!sweep && dTheta > 0) dTheta -= 2 * Math.PI;
130
+ if (sweep && dTheta < 0) dTheta += 2 * Math.PI;
131
+ // point on the ellipse (model space) + its tangent direction, at parameter t
132
+ const pointAt = (t) => {
133
+ const ex = RX * Math.cos(t), ey = RY * Math.sin(t);
134
+ return [cx + cosP * ex - sinP * ey, cy + sinP * ex + cosP * ey];
135
+ };
136
+ const tangentAt = (t) => {
137
+ const ex = -RX * Math.sin(t), ey = RY * Math.cos(t);
138
+ return [cosP * ex - sinP * ey, sinP * ex + cosP * ey];
139
+ };
140
+ const pieces = Math.max(1, Math.ceil(Math.abs(dTheta) / (Math.PI / 2)));
141
+ const dSeg = dTheta / pieces;
142
+ const kFac = (4 / 3) * Math.tan(dSeg / 4);
143
+ const out = [];
144
+ for (let i = 0; i < pieces; i++) {
145
+ const tA = theta1 + dSeg * i, tB = theta1 + dSeg * (i + 1);
146
+ const pA = i === 0 ? [x1, y1] : pointAt(tA);
147
+ const pB = i === pieces - 1 ? [x2, y2] : pointAt(tB);
148
+ const tanA = tangentAt(tA), tanB = tangentAt(tB);
149
+ const c1 = [pA[0] + kFac * tanA[0], pA[1] + kFac * tanA[1]];
150
+ const c2 = [pB[0] - kFac * tanB[0], pB[1] - kFac * tanB[1]];
151
+ out.push({ to: pB, c1, c2 });
152
+ }
153
+ return out;
154
+ }
155
+
100
156
  // Minimal SVG-path tokenizer for the absolute commands replicad emits: M, L, C,
101
157
  // Q, A, Z. Coordinates are numbers separated by spaces or commas; a command may
102
158
  // be followed by several coordinate sets (implicit repeat). One subpath (M…Z) →
@@ -132,3 +188,47 @@ export function svgPathToRings(d, segs) {
132
188
  pushRing();
133
189
  return rings;
134
190
  }
191
+
192
+ // SVG-path tokenizer that emits contour IR ({ start, segments: [{to}|{to,c1,c2}] },
193
+ // the path-contour shape from profile.js) instead of tessellated point rings — the
194
+ // curve-preserving twin of svgPathToRings above, same command set (M L C Q A Z) and
195
+ // the same tokenizer/error-message conventions. C stays a cubic segment as-is; Q
196
+ // degree-elevates to a cubic with the identical control-point math svgPathToRings
197
+ // uses (just not sampled into points); A splits into ≤90° cubic pieces via
198
+ // svgArcToCubics. Z closes the subpath with a straight segment back to its start
199
+ // (mirroring pointsToContour's implicit closing edge) when not already there.
200
+ export function svgPathToContours(d) {
201
+ const toks = d.match(/[a-zA-Z]|-?\d*\.?\d+(?:e[-+]?\d+)?/g) ?? [];
202
+ const contours = [];
203
+ let start = null, segments = null, cur = [0, 0], cmd = null, i = 0;
204
+ const num = () => Number(toks[i++]);
205
+ const pt = () => [num(), num()];
206
+ const pushContour = () => { if (start && segments && segments.length >= 1) contours.push({ start, segments }); start = null; segments = null; };
207
+ while (i < toks.length) {
208
+ if (/^[a-zA-Z]$/.test(toks[i])) {
209
+ cmd = toks[i++];
210
+ if (!"MLCQAZ".includes(cmd)) throw new Error(`svgPathToContours: unsupported SVG command "${cmd}"`);
211
+ }
212
+ if (cmd === "M") { pushContour(); cur = pt(); start = cur.slice(); segments = []; cmd = "L"; }
213
+ else if (cmd === "L") { cur = pt(); segments.push({ to: cur.slice() }); }
214
+ else if (cmd === "C") { const c1 = pt(), c2 = pt(), end = pt(); segments.push({ to: end, c1, c2 }); cur = end; }
215
+ else if (cmd === "Q") {
216
+ const q = pt(), end = pt();
217
+ const c1 = [cur[0] + (2 / 3) * (q[0] - cur[0]), cur[1] + (2 / 3) * (q[1] - cur[1])];
218
+ const c2 = [end[0] + (2 / 3) * (q[0] - end[0]), end[1] + (2 / 3) * (q[1] - end[1])];
219
+ segments.push({ to: end, c1, c2 }); cur = end;
220
+ }
221
+ else if (cmd === "A") {
222
+ const rx = num(), ry = num(), rot = num(), large = num(), sweep = num(), end = pt();
223
+ for (const seg of svgArcToCubics(cur, rx, ry, rot, large, sweep, end)) segments.push(seg);
224
+ cur = end;
225
+ }
226
+ else if (cmd === "Z") {
227
+ if (cur[0] !== start[0] || cur[1] !== start[1]) segments.push({ to: start.slice() });
228
+ pushContour(); cmd = null;
229
+ }
230
+ else throw new Error("svgPathToContours: coordinate before or after a command");
231
+ }
232
+ pushContour();
233
+ return contours;
234
+ }
@@ -0,0 +1,91 @@
1
+ // Shared, backend-agnostic Shape2D factory. Storage is the curve-native contour IR
2
+ // (Tasks 4-10's regions: [{outer, holes}]) — booleans/transforms/queries all run against
3
+ // that IR directly (paper.js under the hood for booleans/area/bounds, no backend WASM
4
+ // involved). `toRegions()`/`simple()` are the only points where a shape gets tessellated
5
+ // down to point rings, for handoff to a kernel op (extrude/revolve) or export.
6
+ //
7
+ // `deps.offsetRegions`/`deps.extrude`/`deps.revolve` are the two backends' own hooks
8
+ // (Task 13 wires Manifold/OCCT versions); everything else here is pure curve math shared
9
+ // by both. Each op returns a NEW Shape2D — value semantics, no operand is ever mutated.
10
+ import { addShape2dSugar } from "./shape2d-sugar.js";
11
+ import { assembleRegions } from "./shape2d-regions.js";
12
+ import { tessellateContour } from "./profile.js";
13
+ import { booleanRegions } from "./paper-bridge.js";
14
+ import { h } from "./solid-hash.js";
15
+ import { closeContourGap } from "./profile.js";
16
+ import {
17
+ liftProfile, ensureRegionWinding, translateProfile, rotateProfile, scaleProfile,
18
+ mirrorProfile, filletProfile, chamferProfile, simplifyProfile, profileCorners,
19
+ profileArea, profileBounds, profileContains,
20
+ } from "./contour-ops.js";
21
+
22
+ const deepCopy = (regions) => JSON.parse(JSON.stringify(regions));
23
+
24
+ // Degenerate-input guard, carried over from the tessellation path the Manifold
25
+ // backend used to lift through: a 2-point "polygon" bounds no area, and silently
26
+ // accepting one yields an empty shape instead of an error. POINT LISTS only — a
27
+ // curve contour legitimately closes in one or two segments (a circle is two arcs),
28
+ // so no segment-count rule applies there. Every lift runs this, including the ones
29
+ // behind a boolean operand (`.cut([[0,0],[1,0]])`), so the message names the VALUE
30
+ // (Shape2D) rather than the `shape2d()` entry point.
31
+ const isPointList = (x) => Array.isArray(x) && Array.isArray(x[0]);
32
+ const checkPointRing = (c, role) => {
33
+ if (isPointList(c) && c.length < 3) throw new Error(`Shape2D: a point-list ${role} needs ≥3 points`);
34
+ };
35
+ const checkProfile = (x) => {
36
+ if (!x || x._shape2d) return;
37
+ if (isPointList(x)) { checkPointRing(x, "profile"); return; }
38
+ for (const rg of Array.isArray(x) ? x : [x]) {
39
+ if (!rg || !rg.outer) continue;
40
+ checkPointRing(rg.outer, "outer contour");
41
+ for (const hole of rg.holes ?? []) checkPointRing(hole, "hole");
42
+ }
43
+ };
44
+
45
+ export function makeShape2dFactory({ segs, offsetRegions, extrude, revolve }) {
46
+ // Lift any accepted profile form into stored regions: a live Shape2D is deep-copied out
47
+ // via its own toContours() (value semantics — never alias another shape's storage);
48
+ // anything else goes through liftProfile + per-ring winding normalization.
49
+ const liftRegions = (x) => {
50
+ if (x && x._shape2d) return deepCopy(x._regions);
51
+ checkProfile(x);
52
+ return liftProfile(x).regions.map(ensureRegionWinding);
53
+ };
54
+
55
+ const make = (regions) => {
56
+ const hash = h("shape2d", regions);
57
+ const viaOps = (fn) => make(fn(regions)); // regions-in → regions-out delegation
58
+ const s = {
59
+ _shape2d: true, _regions: regions, _hash: hash,
60
+ union: (o) => make(booleanRegions(regions, liftRegions(o), "unite")),
61
+ cut: (o) => make(booleanRegions(regions, liftRegions(o), "subtract")),
62
+ cutAll: (os) => make(os.reduce((acc, o) => booleanRegions(acc, liftRegions(o), "subtract"), regions)),
63
+ intersect: (o) => make(booleanRegions(regions, liftRegions(o), "intersect")),
64
+ // offsetRegions is the one backend hook feeding straight into make() — it doesn't route
65
+ // through liftRegions, so unlike every other op here nothing already guaranteed its
66
+ // rings are explicitly closed. Both backends' readbacks close explicitly today, so this
67
+ // is a no-op in practice; it's here so the storage invariant (every stored ring
68
+ // explicitly closed — see closeContourGap's own comment) holds unconditionally.
69
+ offset: (delta, opts = {}) => make(offsetRegions(regions, delta, opts)
70
+ .map((rg) => ({ outer: closeContourGap(rg.outer), holes: rg.holes.map(closeContourGap) }))),
71
+ area: () => profileArea(regions),
72
+ boundingBox: () => profileBounds(regions),
73
+ toRegions: () => assembleRegions(regions.flatMap((rg) =>
74
+ [tessellateContour(rg.outer, segs), ...rg.holes.map((hl) => tessellateContour(hl, segs))])),
75
+ toContours: () => deepCopy(regions),
76
+ clone: () => make(deepCopy(regions)),
77
+ translate: (v) => viaOps((r) => translateProfile(r, v)),
78
+ rotate: (deg, center) => viaOps((r) => rotateProfile(r, deg, center)),
79
+ scale: (f, center) => viaOps((r) => scaleProfile(r, f, center)),
80
+ mirror: (axis) => viaOps((r) => mirrorProfile(r, axis)),
81
+ fillet: (r, opts) => viaOps((rg) => filletProfile(rg, r, opts)),
82
+ chamfer: (d, opts) => viaOps((rg) => chamferProfile(rg, d, opts)),
83
+ simplify: (tol) => viaOps((r) => simplifyProfile(r, tol)),
84
+ corners: () => profileCorners(regions),
85
+ contains: (p) => profileContains(regions, p),
86
+ };
87
+ return addShape2dSugar(s, { shape2d, extrude, revolve });
88
+ };
89
+ const shape2d = (profile) => (profile && profile._shape2d ? profile : make(liftRegions(profile)));
90
+ return shape2d;
91
+ }
@@ -215,12 +215,25 @@ export async function handle(kernel, part, msg, post, opts = {}) {
215
215
  // The view is built HERE rather than inside measure, and handed down through
216
216
  // `opts.built`, because optional match scoring needs the same meshes: one build
217
217
  // feeds the measurement and the six silhouette rasterizations both.
218
- const built = buildView(kernel, part, msg.view, msg.params ?? {});
219
- const measured = measure(kernel, part, msg.view, msg.params ?? {}, { minWall: true, built });
218
+ //
219
+ // The default matters and must match measure()'s own: buildView has NO view
220
+ // default — viewSubParts(part, undefined) returns [] without erroring — so an
221
+ // inspect with no `view` (partforge-cloud's requestReport has always sent
222
+ // undefined, meaning "the current view") built an EMPTY view here, and every
223
+ // downstream consumer degraded silently: measure reported zero subparts and a
224
+ // [0,0,0] bbox, match scored nothing, and verify still passed. Pre-0.55,
225
+ // measure built internally and its own signature default hid this. Found by a
226
+ // live browser check, not by tests: this suite passes explicit views, and the
227
+ // cloud's unit tests fake the worker.
228
+ const view = msg.view ?? Object.keys(part.views)[0];
229
+ const built = buildView(kernel, part, view, msg.params ?? {});
230
+ const measured = measure(kernel, part, view, msg.params ?? {}, { minWall: true, built });
220
231
  const report = {
221
232
  measure: measured,
222
233
  verify: verify(kernel, part, {
223
- view: msg.view,
234
+ // The defaulted view, not msg.view: the seed below was measured on it, and
235
+ // verify's seed reuse is only sound when both name the same view.
236
+ view,
224
237
  seed: { params: msg.params ?? {}, result: measured },
225
238
  }),
226
239
  };