partforge 0.58.0 → 0.59.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,542 @@
1
+ // Native curve-aware contour offset — the engine behind Shape2D.offset. Pure leaf in
2
+ // the worker graph (DOM-free, three-free, node:-free). Offsets every ring by one signed
3
+ // rule: each point displaced `delta` along the normal to the RIGHT of the direction of
4
+ // travel — under the storage winding invariant (outer CCW, holes CW) that always points
5
+ // away from the filled interior, so positive delta grows outers and shrinks holes with
6
+ // no per-ring casing. Lines and arcs offset EXACTLY; cubics use adaptive Tiller–Hanson.
7
+ //
8
+ // The cubic subdivision approach is ported from glenzli/paperjs-offset
9
+ // (https://github.com/glenzli/paperjs-offset, MIT License, Copyright (c) glenzli),
10
+ // adapted from paper.js Segments to the partforge contour IR.
11
+ import { arcCenterAndSweep, resolveSelfRegions, booleanRegions } from "./paper-bridge.js";
12
+ import { cubicAt, splitCubic, jointTangents, SMOOTH_JOINT_DEG } from "./contour-ops.js";
13
+ import { tessellateContour, closeContourGap, reverseContour } from "./profile.js";
14
+ import { ringArea, pointInRing } from "./shape2d-regions.js";
15
+
16
+ export const OFFSET_TOL = 1e-3; // mm — max deviation of a cubic offset approximation
17
+ const MAX_DEPTH = 12; // cubic subdivision recursion cap
18
+ const JOIN_EPS = 1e-6; // endpoints closer than this are coincident
19
+
20
+ const VALIDATE_SEGS = 32;
21
+ const AREA_EPS = 1e-9;
22
+
23
+ const sub = (a, b) => [a[0] - b[0], a[1] - b[1]];
24
+ const add = (a, b) => [a[0] + b[0], a[1] + b[1]];
25
+ const scl = (v, s) => [v[0] * s, v[1] * s];
26
+ const cross = (a, b) => a[0] * b[1] - a[1] * b[0];
27
+ const dot = (a, b) => a[0] * b[0] + a[1] * b[1];
28
+ const len = (v) => Math.hypot(v[0], v[1]);
29
+ const norm = (v) => { const L = len(v) || 1; return [v[0] / L, v[1] / L]; };
30
+ const rightOf = ([tx, ty]) => [ty, -tx]; // unit right-of-travel normal
31
+ const dist = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]);
32
+
33
+ function offsetLine(from, to, delta) {
34
+ const n = scl(rightOf(norm(sub(to, from))), delta);
35
+ return { start: add(from, n), segments: [{ to: add(to, n) }], dirty: false };
36
+ }
37
+
38
+ function offsetArc(from, seg, delta) {
39
+ const c = arcCenterAndSweep(from, seg.via, seg.to);
40
+ if (!c) return offsetLine(from, seg.to, delta); // collinear via → straight
41
+ const { center, r, dA } = c;
42
+ // CCW sweep (dA>0): right-of-travel is the outward radial → r+delta; CW: inward → r-delta
43
+ const rNew = r + (dA >= 0 ? delta : -delta);
44
+ if (Math.abs(rNew) <= JOIN_EPS) {
45
+ // fully collapsed arc: bridge the offset endpoints with a line, let cleanup cope
46
+ const tanAt = (p) => { const rad = sub(p, center); return norm(dA >= 0 ? [-rad[1], rad[0]] : [rad[1], -rad[0]]); };
47
+ const q = (p) => add(p, scl(rightOf(tanAt(p)), delta));
48
+ return { start: q(from), segments: [{ to: q(seg.to) }], dirty: true };
49
+ }
50
+ // rNew < 0 lands every point on the opposite side of center — the inverted loop
51
+ // that stage-3 cleanup removes. Same projection formula either way.
52
+ const proj = (p) => add(center, scl(norm(sub(p, center)), rNew));
53
+ return { start: proj(from), segments: [{ via: proj(seg.via), to: proj(seg.to) }], dirty: rNew < 0 };
54
+ }
55
+
56
+ // Tiller–Hanson single-piece offset of cubic (p0,c1,c2,p1): displace endpoints along
57
+ // their endpoint normals and the handle line by the normal of the c1→c2 chord, then
58
+ // accept only if sampled deviation stays within OFFSET_TOL; otherwise split at t=0.5.
59
+ // (Ported from paperjs-offset's offsetSegment/adaptiveOffsetCurve.)
60
+ function offsetCubic(p0, c1, c2, p1, delta, depth) {
61
+ const nz = (v) => (len(v) > 1e-9 ? v : null);
62
+ const t0 = norm(nz(sub(c1, p0)) ?? nz(sub(c2, p0)) ?? sub(p1, p0));
63
+ const t1 = norm(nz(sub(p1, c2)) ?? nz(sub(p1, c1)) ?? sub(p1, p0));
64
+ const off0 = scl(rightOf(t0), delta), off1 = scl(rightOf(t1), delta);
65
+ const hChord = nz(sub(c2, c1)) ?? sub(p1, p0);
66
+ const hN = scl(rightOf(norm(hChord)), delta);
67
+ const q0 = add(p0, off0), q1 = add(p1, off1);
68
+ const qc1 = add(c1, scl(add(hN, off0), 0.5)), qc2 = add(c2, scl(add(hN, off1), 0.5));
69
+ let ok = true;
70
+ for (const t of [0.25, 0.5, 0.75]) {
71
+ const d = dist(cubicAt(q0, qc1, qc2, q1, t), cubicAt(p0, c1, c2, p1, t));
72
+ if (Math.abs(d - Math.abs(delta)) > OFFSET_TOL) { ok = false; break; }
73
+ }
74
+ if (ok || depth >= MAX_DEPTH) return { start: q0, segments: [{ to: q1, c1: qc1, c2: qc2 }], dirty: !ok };
75
+ const [L, R] = splitCubic(p0, c1, c2, p1, 0.5);
76
+ const a = offsetCubic(L.p0, L.c1, L.c2, L.p1, delta, depth + 1);
77
+ const b = offsetCubic(R.p0, R.c1, R.c2, R.p1, delta, depth + 1);
78
+ return { start: a.start, segments: [...a.segments, ...b.segments], dirty: a.dirty || b.dirty };
79
+ }
80
+
81
+ // One IR segment (running from `from` to seg.to) → its raw offset piece.
82
+ export function _offsetSegment(from, seg, delta) {
83
+ if (seg.c1) return offsetCubic(from, seg.c1, seg.c2, seg.to, delta, 0);
84
+ if (seg.via) return offsetArc(from, seg, delta);
85
+ return offsetLine(from, seg.to, delta);
86
+ }
87
+
88
+ const MITER_LIMIT = 2;
89
+
90
+ // Intersection of the line through P (direction u) with the line through Q (direction v).
91
+ function lineIntersect(P, u, Q, v) {
92
+ const d = cross(u, v);
93
+ if (Math.abs(d) < 1e-12) return null;
94
+ const w = sub(Q, P);
95
+ return add(P, scl(u, cross(w, v) / d));
96
+ }
97
+
98
+ // Segments bridging aEnd → bStart around `corner` on the gap side.
99
+ function joinSegs(corner, aEnd, bStart, inTan, outTan, delta, corners) {
100
+ if (corners === "chamfer") return [{ to: bStart }];
101
+ if (corners === "sharp") {
102
+ const X = lineIntersect(aEnd, inTan, bStart, outTan);
103
+ if (X && dist(X, corner) <= MITER_LIMIT * Math.abs(delta)) return [{ to: X }, { to: bStart }];
104
+ return [{ to: bStart }]; // miter-limit fallback = bevel
105
+ }
106
+ // round: exact arc about the corner, via on the displacement bisector
107
+ const d1 = sub(aEnd, corner), d2 = sub(bStart, corner);
108
+ let m = add(d1, d2);
109
+ if (len(m) < 1e-9) m = delta > 0 ? rightOf(norm(d1)) : scl(rightOf(norm(d1)), -1); // 180° turn
110
+ return [{ via: add(corner, scl(norm(m), Math.abs(delta))), to: bStart }];
111
+ }
112
+
113
+ // Offset one explicitly-closed ring. Returns { contour, dirty }.
114
+ export function _offsetContour(contour, delta, corners) {
115
+ const pts = [contour.start, ...contour.segments.map((s) => s.to)];
116
+ // drop zero-length line segments (they carry no direction)
117
+ const keep = contour.segments.map((s, i) => s.c1 || s.via || dist(pts[i], s.to) > 1e-9);
118
+ const segs = contour.segments.filter((_, i) => keep[i]);
119
+ const froms = [];
120
+ { let p = contour.start; for (const s of contour.segments) { froms.push(p); p = s.to; } }
121
+ const fromsKept = froms.filter((_, i) => keep[i]);
122
+ // NB: feed jointTangents the KEPT chain's start — if the first segment was dropped
123
+ // as zero-length, contour.start no longer heads the filtered ring.
124
+ const joints = jointTangents({ start: fromsKept[0] ?? contour.start, segments: segs });
125
+ const pieces = segs.map((s, i) => _offsetSegment(fromsKept[i], s, delta));
126
+ let dirty = pieces.some((p) => p.dirty);
127
+ const n = segs.length;
128
+ const joins = new Array(n).fill(null); // joins[i] bridges piece[i-1] → piece[i] at vertex i
129
+
130
+ for (let i = 0; i < n; i++) {
131
+ const prev = pieces[(i - 1 + n) % n], next = pieces[i];
132
+ const aEnd = prev.segments.at(-1).to, bStart = next.start;
133
+ const { point, inTan, outTan } = joints[i];
134
+ const turn = cross(inTan, outTan);
135
+ const turnDeg = (Math.atan2(Math.abs(turn), Math.max(-1, Math.min(1, inTan[0] * outTan[0] + inTan[1] * outTan[1]))) * 180) / Math.PI;
136
+ if (dist(aEnd, bStart) <= JOIN_EPS || turnDeg < SMOOTH_JOINT_DEG) continue; // smooth
137
+ if (turn * delta > 0) { joins[i] = joinSegs(point, aEnd, bStart, inTan, outTan, delta, corners); continue; }
138
+ // overlap side: trim when both neighbors are plain lines, else chord + dirty
139
+ const aSeg = prev.segments.at(-1), bSeg = next.segments[0];
140
+ if (!aSeg.via && !aSeg.c1 && !bSeg.via && !bSeg.c1) {
141
+ const X = lineIntersect(aEnd, inTan, bStart, outTan);
142
+ if (X) { aSeg.to = X; next.start = X; continue; } // exact trim, stays clean
143
+ }
144
+ joins[i] = [{ to: bStart }]; dirty = true;
145
+ }
146
+
147
+ // Whole-ring collapse check: when delta exceeds the ring's own inradius, EVERY plain-line
148
+ // piece's trimmed direction reverses relative to its pre-offset direction — reflection
149
+ // through the collapse point preserves winding, so the reflected ring passes every other
150
+ // validity check there is (paper.js included: it's a genuinely simple polygon, just the
151
+ // wrong one). That whole-ring signal is reliable; a PER-PIECE version of it is not — it
152
+ // also fires on ordinary trims (acute barbs, narrow slots, non-square holes, 45° chamfers)
153
+ // that never reflected, and "fixing" those by un-trimming produces over-inclusive geometry
154
+ // instead of the correct, already-exact Task 1-4 result. So: only act when ALL plain-line
155
+ // pieces agree; when some but not all do, this is a normal partial trim — leave it alone.
156
+ // Critically, "the whole ring" means EVERY piece of the ring, not just its line pieces: a
157
+ // ring where lines are a minority (a mostly-arc disc with a small tab, say) can have every
158
+ // one of its few line pieces reverse while the ring as a whole is nowhere near collapsed —
159
+ // requiring lineReversals.length === n makes this a genuine whole-ring predicate again. An
160
+ // all-arc ring that truly collapses is still caught downstream: offsetArc already marks
161
+ // rNew<0 / fully-collapsed arcs dirty, routing to cleanup instead of a false fast-path pass.
162
+ // pieceDot[i] is the reversal signal (dot of post-trim vs pre-offset direction) for
163
+ // plain-line piece i, or null for arc/cubic pieces — a dot of zero here also flags a
164
+ // piece trimmed down to zero length, since a zero vector's dot with anything is 0.
165
+ const pieceDot = pieces.map((p, i) => {
166
+ if (p.segments.length !== 1 || p.segments[0].via || p.segments[0].c1) return null;
167
+ const origDir = sub(segs[i].to, fromsKept[i]);
168
+ const newDir = sub(p.segments[0].to, p.start);
169
+ return dot(newDir, origDir);
170
+ });
171
+ const lineReversals = pieceDot.filter((d) => d !== null).map((d) => d <= 0);
172
+ if (lineReversals.length === n && n > 0 && lineReversals.every(Boolean)) return { contour: null, dirty: true };
173
+
174
+ // Part 1 of the partial-reflection fix (task 5B): the whole-ring gate above only fires
175
+ // when EVERY plain-line piece reverses — by design, since a per-piece version of that
176
+ // gate also fires on ordinary trims that never reflected (see the comment above). But a
177
+ // ring that does NOT collapse wholesale can still carry one or two individually-reversed
178
+ // pieces (an over-offset corner that trimmed past its own neighbor) — that's the seed of
179
+ // the partial-reflection residual: those pieces survive into the ring and validateRawOffset
180
+ // can't see anything locally wrong with them. Delete them (not un-trim — un-trimming was
181
+ // rejected in an earlier round for other over-inclusive regressions) and re-link the
182
+ // surviving neighbors with a direct chord, marking the ring dirty so resolveSelfRegions
183
+ // gets a chance to untangle whatever that chord leaves behind. Every non-line piece
184
+ // always survives this pass; the whole-ring gate above already guarantees at least one
185
+ // piece survives here too.
186
+ const allIdx = pieces.map((_, i) => i);
187
+ const dropped = pieceDot.map((d) => d !== null && d <= 0);
188
+ if (!dropped.some(Boolean)) return { contour: assembleRing(pieces, joins, allIdx, n), dirty };
189
+
190
+ const keptIdx = allIdx.filter((i) => !dropped[i]);
191
+ if (keptIdx.length === 0) return { contour: null, dirty: true };
192
+
193
+ // Guard (review round 1, Important 3): deletion is unrecoverable — unlike a chord/dirty
194
+ // join, which resolveSelfRegions can still untangle downstream, a deleted piece is gone for
195
+ // good, so a bad deletion can turn perfectly good geometry into a false "offset collapses
196
+ // the shape" throw (measured: 18 new throws per 3000 random polygons; repro: a 9-gon at
197
+ // delta -2.79/chamfer with true eroded area 2.76). A raw *piece count* floor doesn't work as
198
+ // the discriminator — an arc-dominated ring can be legitimately reduced to a single
199
+ // surviving piece (this file's own storage convention stores a full circle as just two arcs;
200
+ // the keyed-bore regression test below reduces to one surviving arc + closing chord and that
201
+ // IS the correct answer). Routing through resolveSelfRegions doesn't work either — it was
202
+ // tried first, and rejected: it assumes the CCW/positive-area "outer" convention (an
203
+ // uninverted self-union is real material, an inverted one that flips positive is a
204
+ // discarded artifact), but _offsetContour has no idea here whether it's assembling an outer
205
+ // or a hole, and a perfectly valid CW/negative-area hole ring (like the keyed bore's) reads
206
+ // as "inverted" under that assumption and gets wrongly discarded. What actually
207
+ // distinguishes "deletion destroyed real geometry" from "deletion correctly trimmed it
208
+ // down" is winding-agnostic: is the assembled result still a SIMPLE (non-self-intersecting)
209
+ // ring with nonzero area — exactly what validateRawOffset's own segment-crossing test
210
+ // (ringSelfIntersects, defined below) already checks without any orientation assumption.
211
+ // Fall back to the un-deleted ring (still marked dirty, since a piece DID look reversed)
212
+ // when deletion isn't simple: cleanup gets a chance to untangle whatever the un-deleted
213
+ // piece leaves behind, which is strictly more recoverable than deletion's dead end.
214
+ const deleted = assembleRing(pieces, joins, keptIdx, n);
215
+ const deletedRing = tessellateContour(deleted, VALIDATE_SEGS);
216
+ const deletedArea = Math.abs(ringArea(deletedRing));
217
+ if (deletedArea <= AREA_EPS || ringSelfIntersects(deletedRing))
218
+ return { contour: assembleRing(pieces, joins, allIdx, n), dirty: true };
219
+ return { contour: deleted, dirty: true };
220
+ }
221
+
222
+ // Assemble a closed ring from a subset of offset pieces (by index into `pieces`, in ring
223
+ // order), bridging consecutive survivors with their original join (when adjacent in the
224
+ // source ring) or a single chord (when one or more pieces were skipped in between — Part 1's
225
+ // deletion path). `idx = [0..n-1]` (every piece kept) reproduces the plain, no-deletion
226
+ // assembly exactly, since every piece is then "adjacent" to the next by construction.
227
+ function assembleRing(pieces, joins, idx, n) {
228
+ const out = [];
229
+ for (let k = 0; k < idx.length; k++) {
230
+ const i = idx[k];
231
+ out.push(...pieces[i].segments);
232
+ const nextI = idx[(k + 1) % idx.length];
233
+ if (nextI === (i + 1) % n) {
234
+ const j = joins[nextI];
235
+ if (j) out.push(...j);
236
+ } else {
237
+ // one or more pieces were skipped between i and nextI: bridge with a single chord
238
+ out.push({ to: [pieces[nextI].start[0], pieces[nextI].start[1]] });
239
+ }
240
+ }
241
+ const start = pieces[idx[0]].start;
242
+ const last = out.at(-1);
243
+ if (dist(last.to, start) <= JOIN_EPS) last.to = [start[0], start[1]]; // snap the closure exactly
244
+ else out.push({ to: [start[0], start[1]] });
245
+ return { start, segments: out };
246
+ }
247
+
248
+ function segsCross(a1, a2, b1, b2) {
249
+ const o = (p, q, r) => Math.sign((q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]));
250
+ const o1 = o(a1, a2, b1), o2 = o(a1, a2, b2), o3 = o(b1, b2, a1), o4 = o(b1, b2, a2);
251
+ return o1 !== o2 && o3 !== o4 && o1 !== 0 && o2 !== 0 && o3 !== 0 && o4 !== 0; // strict crossings only
252
+ }
253
+
254
+ // True when two (non-adjacent) segments are collinear and overlap along more than a point.
255
+ // segsCross's strict-crossing test deliberately ignores collinear touches, but an offset
256
+ // ring can retrace the same line twice (a neck/hole pinched shut by delta past its own
257
+ // width flips the offset pieces from either side onto each other) — that produces exact
258
+ // duplicate or overlapping collinear edges with no transversal crossing anywhere, which
259
+ // segsCross alone can't see.
260
+ function segsOverlap(a1, a2, b1, b2) {
261
+ const d = sub(a2, a1);
262
+ const L = len(d);
263
+ if (L < 1e-9) return false;
264
+ const u = norm(d);
265
+ const perp = (p) => Math.abs(cross(u, sub(p, a1))); // distance off the a1→a2 line
266
+ if (perp(b1) > 1e-9 || perp(b2) > 1e-9) return false; // not collinear with a
267
+ const t = (p) => dot(sub(p, a1), u); // param along a1→a2
268
+ const [ta1, ta2] = [0, L];
269
+ const [tb1, tb2] = [t(b1), t(b2)].sort((x, y) => x - y);
270
+ return Math.min(ta2, tb2) - Math.max(ta1, tb1) > 1e-9; // overlap longer than a touch
271
+ }
272
+
273
+ // Axis-aligned bounding box of a ring / of one segment, and a disjointness test between
274
+ // two boxes. These exist purely as a rejection filter in front of the pairwise segment
275
+ // tests below: validateRawOffset runs on the geometry worker on EVERY offset (so on every
276
+ // parameter change), and its pairwise loops are O(R²·m²) in ring count and ring resolution
277
+ // with nothing to stop them. Measured on offsetRegions end to end, +0.5 round over N disjoint
278
+ // squares: 40 → 11.6 ms before this filter and 1.0 ms after, 100 → 64.0 / 1.3 ms, 200 → 326.2
279
+ // / 2.0 ms — clean quadratic before, effectively flat after. (Many-region TEXT is a smaller
280
+ // win — a 24-glyph string went 93 → 85 ms — because a glyph's raw offset ring self-intersects,
281
+ // so validation short-circuits early and paper.js cleanup dominates that case regardless.)
282
+ // Boxes never change the ANSWER — two segments whose boxes are disjoint cannot cross or
283
+ // overlap — so this is a pure short-circuit, not an approximation.
284
+ const BOX_EPS = 1e-9;
285
+ function ringBox(ring) {
286
+ let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
287
+ for (const [x, y] of ring) {
288
+ if (x < x0) x0 = x; if (x > x1) x1 = x;
289
+ if (y < y0) y0 = y; if (y > y1) y1 = y;
290
+ }
291
+ return [x0, y0, x1, y1];
292
+ }
293
+ const boxesApart = (a, b) =>
294
+ a[2] < b[0] - BOX_EPS || b[2] < a[0] - BOX_EPS || a[3] < b[1] - BOX_EPS || b[3] < a[1] - BOX_EPS;
295
+ const segBox = (p, q) => [Math.min(p[0], q[0]), Math.min(p[1], q[1]), Math.max(p[0], q[0]), Math.max(p[1], q[1])];
296
+
297
+ function ringSelfIntersects(ring) {
298
+ const m = ring.length;
299
+ const boxes = [];
300
+ for (let i = 0; i < m; i++) boxes.push(segBox(ring[i], ring[(i + 1) % m]));
301
+ for (let i = 0; i < m; i++) for (let j = i + 2; j < m; j++) {
302
+ if (i === 0 && j === m - 1) continue; // adjacent via wraparound
303
+ if (boxesApart(boxes[i], boxes[j])) continue;
304
+ const a1 = ring[i], a2 = ring[(i + 1) % m], b1 = ring[j], b2 = ring[(j + 1) % m];
305
+ if (segsCross(a1, a2, b1, b2) || segsOverlap(a1, a2, b1, b2)) return true;
306
+ }
307
+ return false;
308
+ }
309
+
310
+ // Two DIFFERENT rings interfering. Like ringSelfIntersects this checks segsOverlap as well
311
+ // as segsCross: two rings can interfere without any transversal crossing at all when their
312
+ // boundaries run along the same line — exactly what two eroding holes that grew into each
313
+ // other produce under a sharp join, where every actual crossing lands on a shared vertex
314
+ // (o === 0, which segsCross deliberately ignores) and the only usable signal is the pair of
315
+ // collinear, partially-overlapping edges. Missing that let an invalid double-ring result
316
+ // pass the fast path and reach extrude, where even-odd fill turned the doubly-covered lens
317
+ // back into SOLID material inside the merged pocket.
318
+ function ringsCross(a, b, boxA = ringBox(a), boxB = ringBox(b)) {
319
+ if (boxesApart(boxA, boxB)) return false;
320
+ for (let i = 0; i < a.length; i++) {
321
+ const a1 = a[i], a2 = a[(i + 1) % a.length];
322
+ const bx = segBox(a1, a2);
323
+ if (boxesApart(bx, boxB)) continue;
324
+ for (let j = 0; j < b.length; j++) {
325
+ const b1 = b[j], b2 = b[(j + 1) % b.length];
326
+ if (boxesApart(bx, segBox(b1, b2))) continue;
327
+ if (segsCross(a1, a2, b1, b2) || segsOverlap(a1, a2, b1, b2)) return true;
328
+ }
329
+ }
330
+ return false;
331
+ }
332
+
333
+ // Every point of `inner` strictly inside `outer`. Sampling ONE point (which this used to do)
334
+ // is satisfied by a hole that has grown most of the way out through its own outer boundary
335
+ // as long as its first vertex happens to still be inside — and a hole ring poking outside
336
+ // its outer is not merely inaccurate, it becomes real material: fed through toRegions() and
337
+ // CrossSection.ofPolygons(…,"EvenOdd"), the escaped part of the ring extrudes to a solid tab
338
+ // hanging off the plate below its own boundary.
339
+ function ringInsideRing(inner, outer, outerBox) {
340
+ for (const p of inner) {
341
+ if (p[0] < outerBox[0] || p[0] > outerBox[2] || p[1] < outerBox[1] || p[1] > outerBox[3]) return false;
342
+ if (!pointInRing(p, outer)) return false;
343
+ }
344
+ return true;
345
+ }
346
+
347
+ // True when a raw offset result is already valid (fast path). Sampled at VALIDATE_SEGS.
348
+ export function validateRawOffset(regions) {
349
+ const sampled = regions.map((rg) => ({
350
+ outer: tessellateContour(rg.outer, VALIDATE_SEGS),
351
+ holes: rg.holes.map((h) => tessellateContour(h, VALIDATE_SEGS)),
352
+ }));
353
+ const allRings = [];
354
+ for (const rg of sampled) {
355
+ if (ringArea(rg.outer) <= AREA_EPS) return false; // flipped or collapsed outer
356
+ const outerBox = ringBox(rg.outer);
357
+ for (const h of rg.holes) {
358
+ if (ringArea(h) >= -AREA_EPS) return false; // flipped or collapsed hole
359
+ if (!ringInsideRing(h, rg.outer, outerBox)) return false; // hole escaped its outer
360
+ }
361
+ allRings.push(rg.outer, ...rg.holes);
362
+ }
363
+ for (const r of allRings) if (ringSelfIntersects(r)) return false;
364
+ const boxes = allRings.map(ringBox);
365
+ for (let i = 0; i < allRings.length; i++) for (let j = i + 1; j < allRings.length; j++)
366
+ if (ringsCross(allRings[i], allRings[j], boxes[i], boxes[j])) return false;
367
+ return true;
368
+ }
369
+
370
+ // A raw all-line outer ring can retrace the very same edge twice, in the same direction,
371
+ // when a narrow neck (or a hole) offsets past its own width: the two sides of the pinch
372
+ // land exactly on top of each other (see the whole-ring collapse check in _offsetContour
373
+ // for the convex-corner sibling of this — same underlying reflection, reached through a
374
+ // reflex-corner join instead of a trim, so it isn't a single collapsed ring to drop but a
375
+ // self-touching one to cut). That's a *valid, simple* polygon as far as paper.js is
376
+ // concerned — a zero-width slit, not a crossing — so resolveSelfRegions has nothing to
377
+ // untangle and leaves the two halves connected. Cut the ring at each duplicate edge
378
+ // instead: it always severs the ring into two closed sub-loops (repeat until none remain).
379
+ // Winding tells real material from the leftover artifact: the artifact comes back CW
380
+ // (negative area — the neck's own boundary retraced backwards) and is discarded; the two
381
+ // severed pieces come back CCW, matching the storage invariant for outers.
382
+ function splitAtDuplicateEdges(contour) {
383
+ if (contour.segments.some((s) => s.via || s.c1)) return null; // lines only
384
+ const pts = [contour.start, ...contour.segments.map((s) => s.to)];
385
+ const ring = pts.slice(0, -1); // drop the closing repeat of start
386
+ const eq = (a, b) => dist(a, b) <= JOIN_EPS;
387
+ const loops = [ring];
388
+ const pieces = [];
389
+ let splitAny = false;
390
+ while (loops.length) {
391
+ const r = loops.pop();
392
+ const m = r.length;
393
+ let found = null;
394
+ for (let i = 0; i < m && !found; i++) for (let j = i + 1; j < m; j++) {
395
+ if (eq(r[i], r[j]) && eq(r[(i + 1) % m], r[(j + 1) % m])) { found = [i, j]; break; }
396
+ }
397
+ if (!found) { pieces.push(r); continue; }
398
+ splitAny = true;
399
+ const [i, j] = found;
400
+ const a = r.slice(i + 1, j + 1), b = [...r.slice(j + 1), ...r.slice(0, i + 1)];
401
+ if (a.length >= 3) loops.push(a);
402
+ if (b.length >= 3) loops.push(b);
403
+ }
404
+ if (!splitAny) return null;
405
+ return pieces
406
+ .filter((r) => ringArea(r) > AREA_EPS) // discard the CW artifact
407
+ .map((r) => ({ start: r[0], segments: [...r.slice(1).map((p) => ({ to: p })), { to: [r[0][0], r[0][1]] }] }));
408
+ }
409
+
410
+ // Apply splitAtDuplicateEdges to EVERY region's outer ring, re-nesting that region's holes
411
+ // into whichever split piece contains them. Returns null when no outer split (nothing to
412
+ // recover); otherwise the re-nested region list. Severing a pinched neck is the only
413
+ // mechanism this engine has for cutting a ring that an inward offset closed shut, and it
414
+ // applies to holed and multi-region shapes exactly as much as to a single bare ring — a
415
+ // plate with a waist AND bolt holes, inset for print clearance, is the everyday case.
416
+ //
417
+ // A hole whose first point lands inside none of the split pieces (the neck it lived beside
418
+ // was severed out from under it, or it grew past its own outer) is NOT dropped: it is
419
+ // re-attached to the largest piece so validateRawOffset sees it and rejects the candidate,
420
+ // routing the whole thing to cleanupRegions — which subtracts hole rings from the united
421
+ // outers and so clips it correctly. Dropping it here would silently delete real material
422
+ // removal (measured: a dumbbell with a 1×1 hole at delta −2 came back 72 instead of 56).
423
+ function splitPinchedRegions(raw) {
424
+ let splitAny = false;
425
+ const out = [];
426
+ for (const rg of raw) {
427
+ const split = splitAtDuplicateEdges(rg.outer);
428
+ if (!split) { out.push(rg); continue; }
429
+ splitAny = true;
430
+ if (split.length === 0) continue; // every piece came back CW: pure artifact
431
+ const pieces = split.map((outer) => ({ outer, holes: [], ring: tessellateContour(outer, VALIDATE_SEGS) }));
432
+ for (const h of rg.holes) {
433
+ const p = tessellateContour(h, VALIDATE_SEGS)[0];
434
+ const inside = pieces.filter((q) => pointInRing(p, q.ring));
435
+ const home = inside.length
436
+ ? inside.reduce((a, b) => (Math.abs(ringArea(a.ring)) <= Math.abs(ringArea(b.ring)) ? a : b))
437
+ : pieces.reduce((a, b) => (Math.abs(ringArea(a.ring)) >= Math.abs(ringArea(b.ring)) ? a : b));
438
+ home.holes.push(h);
439
+ }
440
+ for (const q of pieces) out.push({ outer: q.outer, holes: q.holes });
441
+ }
442
+ return splitAny ? out : null;
443
+ }
444
+
445
+ // Cleanup stage: resolve a raw offset result that the fast path rejected.
446
+ //
447
+ // This is a SUBTRACTION, not a self-union. Feeding outers and holes into one even-odd
448
+ // compound and self-uniting it (what this used to do) is only equivalent while every hole
449
+ // still sits cleanly inside its own outer and no two holes touch — precisely the conditions
450
+ // an over-offset breaks. Two eroding holes that grew into each other, or a hole that grew
451
+ // through its outer's boundary, cancel under even-odd instead of merging: the doubly-covered
452
+ // lens comes back SOLID. Extruded, that is a solid island inside a merged pocket (40×20
453
+ // plate, two 6×8 holes 3 mm apart, delta −2: 360 mm² instead of 348) or a tab of material
454
+ // hanging off the plate below its own boundary (10×10 hole 2 mm from the edge, delta −2:
455
+ // 436 mm² instead of 408). Uniting the outers and then subtracting the united hole rings
456
+ // gives the right answer in both cases because subtraction has no doubly-covered state.
457
+ //
458
+ // The holes are united into one region list first rather than subtracted one at a time —
459
+ // same result (subtraction distributes over union), one paper.js boolean instead of N, which
460
+ // matters on the worker hot path for many-counter text.
461
+ function cleanupRegions(regions) {
462
+ const outers = resolveSelfRegions(regions.map((rg) => ({ outer: rg.outer, holes: [] })));
463
+ const holes = regions.flatMap((rg) => rg.holes);
464
+ if (outers.length === 0 || holes.length === 0) return outers;
465
+ // Hole rings are stored CW; reverse each to the CCW "outer" winding resolveSelfRegions
466
+ // assumes before uniting them, or its inverted-region guard reads a perfectly good hole as
467
+ // a collapsed artifact and cancels it (measured: every hole silently vanished).
468
+ const holeUnion = resolveSelfRegions(holes.map((h) => ({
469
+ outer: ringArea(tessellateContour(h, VALIDATE_SEGS)) < 0 ? reverseContour(h) : h,
470
+ holes: [],
471
+ })));
472
+ if (holeUnion.length === 0) return outers;
473
+ return booleanRegions(outers, holeUnion, "subtract");
474
+ }
475
+
476
+ // Part 2 of the partial-reflection fix (task 5B) — REMOVED (review round 2). A global
477
+ // distance-from-source prune existed here through two review rounds, narrowing its scope each
478
+ // time (round 1: per-hole rather than whole-region, to stop swallowing legitimate hole
479
+ // merges/breakthroughs; round 2: exact closed-form line/arc distance and an adaptively-
480
+ // flattened cubic distance instead of a chord-length-bounded tolerance, to stop losing curved
481
+ // holes to discretization noise). Round 2 confirmed the prune's own justification was sound —
482
+ // the wide-arm-L-pocket case it exists to fix is a genuine defect (max inscribed circle in a
483
+ // 5-wide-arm L has radius 2.5 < delta 3, so the hole DOES fully vanish; a truth value is
484
+ // derivable and the pre-fix engine got it wrong) — but even with exact-geometry distances, a
485
+ // sweep of real glyph counters (uppercase/lowercase/digit counters on 10mm text at delta
486
+ // 0.1–0.5mm) still lost 36 of 76 entirely, all silent total-hole-loss, none
487
+ // recoverable by further tolerance tuning: the wide-L-pocket's own raw hole ring is ALREADY
488
+ // `dirty` from Part 1 before any distance check runs, and so are the failing glyph counters —
489
+ // there is no scoping condition (dirty vs not, tolerance size, source curve type) that
490
+ // distinguishes "prune this, it's really gone" from "don't prune this, cleanup will recover
491
+ // it" using only the raw, pre-cleanup ring. The prune's collateral (silently deleting real
492
+ // text counters at sub-millimetre offsets) is strictly worse than the single defect it fixes,
493
+ // so it's gone rather than shipped delicately tuned. The wide-L-pocket case is parked as a
494
+ // test.todo pending a proper oracle (Clipper2 or the OCCT backend) rather than this engine's
495
+ // own per-ring heuristics. See task-5B-report.md's round-2 section for the full sweep.
496
+
497
+ // Region-in / region-out offset: the engine behind Shape2D.offset on BOTH backends.
498
+ // Fast path: raw per-ring offsets that validate cleanly are returned as-is (lines/arcs
499
+ // exact). Cleanup path: anything dirty or invalid is self-united through paper.js, with
500
+ // one recovery attempted first (see splitAtDuplicateEdges) for the specific degenerate
501
+ // shape paper.js's boolean engine can't see as invalid.
502
+ export function offsetRegions(regions, delta, { corners = "round" } = {}) {
503
+ if (!["round", "chamfer", "sharp"].includes(corners))
504
+ throw new Error('Shape2D.offset: corners must be "round" | "chamfer" | "sharp"');
505
+ if (!Number.isFinite(delta)) throw new Error("Shape2D.offset: delta must be a finite number");
506
+ if (delta === 0) return JSON.parse(JSON.stringify(regions));
507
+
508
+ // _offsetContour signals a whole-ring collapse with contour:null (see its comment) — a
509
+ // dropped outer removes its whole region, a dropped hole is simply omitted. If everything
510
+ // drops, raw ends up [] and the "no regions survive" throw below fires naturally.
511
+ let dirty = false;
512
+ const raw = [];
513
+ for (const rg of regions) {
514
+ const o = _offsetContour(closeContourGap(rg.outer), delta, corners);
515
+ dirty = dirty || o.dirty;
516
+ if (!o.contour) continue;
517
+ const hs = rg.holes.map((h) => _offsetContour(closeContourGap(h), delta, corners));
518
+ // Only a SURVIVING hole's dirtiness can dirty the result. A hole that collapsed
519
+ // (contour === null) always reports dirty — that is how _offsetContour signals the drop —
520
+ // but the drop itself is a clean operation: the hole is simply gone and nothing else about
521
+ // the region moved. Folding that signal into `dirty` sent an otherwise-exact outer through
522
+ // paper.js for no reason, and paper.js has no arc primitive: a 10×10 square at +2 round
523
+ // came back line,cubic,line,cubic… with a collapsing 2×2 hole present and line,arc,line,arc
524
+ // without it. On OCCT that is the difference between B_SPLINE and CIRCLE in the exported
525
+ // STEP — exact curve fidelity lost to a hole that isn't even in the output.
526
+ dirty = dirty || hs.some((h) => h.contour && h.dirty);
527
+ raw.push({ outer: o.contour, holes: hs.filter((h) => h.contour).map((h) => h.contour) });
528
+ }
529
+
530
+ let out = (!dirty && validateRawOffset(raw)) ? raw : null;
531
+ if (!out) {
532
+ const split = splitPinchedRegions(raw);
533
+ // `split` can be [] (every piece came back CW, i.e. a spurious artifact rather than real
534
+ // material) — validateRawOffset([]) is vacuously true, so an explicit length check is
535
+ // required or a fully-collapsed split silently short-circuits past cleanup and this
536
+ // throws "collapses the shape" even when cleanup would find real area.
537
+ const cand = split && split.length > 0 ? split : raw;
538
+ out = cand !== raw && validateRawOffset(cand) ? cand : cleanupRegions(cand);
539
+ }
540
+ if (out.length === 0) throw new Error("Shape2D.offset: offset collapses the shape (reduce |delta|)");
541
+ return out;
542
+ }
@@ -239,7 +239,7 @@ const normalize2 = ([x, y]) => { const L = Math.hypot(x, y) || 1; return [x / L,
239
239
  const rot90 = ([x, y]) => [-y, x];
240
240
  const addScaled = (p, v, s) => [p[0] + v[0] * s, p[1] + v[1] * s];
241
241
 
242
- function cubicAt(p0, c1, c2, p1, t) {
242
+ export function cubicAt(p0, c1, c2, p1, t) {
243
243
  const u = 1 - t;
244
244
  return [0, 1].map((k) => u * u * u * p0[k] + 3 * u * u * t * c1[k] + 3 * u * t * t * c2[k] + t * t * t * p1[k]);
245
245
  }
@@ -248,7 +248,7 @@ function cubicDeriv(p0, c1, c2, p1, t) {
248
248
  return [0, 1].map((k) => 3 * u * u * (c1[k] - p0[k]) + 6 * u * t * (c2[k] - c1[k]) + 3 * t * t * (p1[k] - c2[k]));
249
249
  }
250
250
  // Exact de Casteljau split of cubic (p0,c1,c2,p1) at t → two exact cubic pieces.
251
- function splitCubic(p0, c1, c2, p1, t) {
251
+ export function splitCubic(p0, c1, c2, p1, t) {
252
252
  const lerp = (a, b) => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
253
253
  const p01 = lerp(p0, c1), p12 = lerp(c1, c2), p23 = lerp(c2, p1);
254
254
  const p012 = lerp(p01, p12), p123 = lerp(p12, p23);