partforge 0.58.1 → 0.60.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,855 @@
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 } from "./paper-bridge.js";
12
+ import { cubicAt, splitCubic, jointTangents, SMOOTH_JOINT_DEG } from "./contour-ops.js";
13
+ import { tessellateContour, closeContourGap } from "./profile.js";
14
+ import { ringArea, pointInRing } from "./shape2d-regions.js";
15
+ import { resolveOffsetWinding, CLUSTER_TOL, CHAIN_INCOMPLETE_MESSAGE } from "./contour-winding.js";
16
+
17
+ export const OFFSET_TOL = 1e-3; // mm — max deviation of a cubic offset approximation
18
+ const MAX_DEPTH = 12; // cubic subdivision recursion cap
19
+ const JOIN_EPS = 1e-6; // endpoints closer than this are coincident
20
+
21
+ const VALIDATE_SEGS = 32;
22
+ const AREA_EPS = 1e-9;
23
+
24
+ const sub = (a, b) => [a[0] - b[0], a[1] - b[1]];
25
+ const add = (a, b) => [a[0] + b[0], a[1] + b[1]];
26
+ const scl = (v, s) => [v[0] * s, v[1] * s];
27
+ const cross = (a, b) => a[0] * b[1] - a[1] * b[0];
28
+ const dot = (a, b) => a[0] * b[0] + a[1] * b[1];
29
+ const len = (v) => Math.hypot(v[0], v[1]);
30
+ const norm = (v) => { const L = len(v) || 1; return [v[0] / L, v[1] / L]; };
31
+ const rightOf = ([tx, ty]) => [ty, -tx]; // unit right-of-travel normal
32
+ const dist = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]);
33
+
34
+ function offsetLine(from, to, delta) {
35
+ const n = scl(rightOf(norm(sub(to, from))), delta);
36
+ return { start: add(from, n), segments: [{ to: add(to, n) }], dirty: false };
37
+ }
38
+
39
+ function offsetArc(from, seg, delta) {
40
+ const c = arcCenterAndSweep(from, seg.via, seg.to);
41
+ if (!c) return offsetLine(from, seg.to, delta); // collinear via → straight
42
+ const { center, r, dA } = c;
43
+ // CCW sweep (dA>0): right-of-travel is the outward radial → r+delta; CW: inward → r-delta
44
+ const rNew = r + (dA >= 0 ? delta : -delta);
45
+ if (Math.abs(rNew) <= JOIN_EPS) {
46
+ // fully collapsed arc: bridge the offset endpoints with a line, let cleanup cope
47
+ const tanAt = (p) => { const rad = sub(p, center); return norm(dA >= 0 ? [-rad[1], rad[0]] : [rad[1], -rad[0]]); };
48
+ const q = (p) => add(p, scl(rightOf(tanAt(p)), delta));
49
+ return { start: q(from), segments: [{ to: q(seg.to) }], dirty: true };
50
+ }
51
+ // rNew < 0 lands every point on the opposite side of center — the inverted loop
52
+ // that stage-3 cleanup removes. Same projection formula either way.
53
+ const proj = (p) => add(center, scl(norm(sub(p, center)), rNew));
54
+ return { start: proj(from), segments: [{ via: proj(seg.via), to: proj(seg.to) }], dirty: rNew < 0 };
55
+ }
56
+
57
+ // Tiller–Hanson single-piece offset of cubic (p0,c1,c2,p1): displace endpoints along
58
+ // their endpoint normals and the handle line by the normal of the c1→c2 chord, then
59
+ // accept only if sampled deviation stays within OFFSET_TOL; otherwise split at t=0.5.
60
+ // (Ported from paperjs-offset's offsetSegment/adaptiveOffsetCurve.)
61
+ function offsetCubic(p0, c1, c2, p1, delta, depth) {
62
+ const nz = (v) => (len(v) > 1e-9 ? v : null);
63
+ const t0 = norm(nz(sub(c1, p0)) ?? nz(sub(c2, p0)) ?? sub(p1, p0));
64
+ const t1 = norm(nz(sub(p1, c2)) ?? nz(sub(p1, c1)) ?? sub(p1, p0));
65
+ const off0 = scl(rightOf(t0), delta), off1 = scl(rightOf(t1), delta);
66
+ const hChord = nz(sub(c2, c1)) ?? sub(p1, p0);
67
+ const hN = scl(rightOf(norm(hChord)), delta);
68
+ const q0 = add(p0, off0), q1 = add(p1, off1);
69
+ const qc1 = add(c1, scl(add(hN, off0), 0.5)), qc2 = add(c2, scl(add(hN, off1), 0.5));
70
+ let ok = true;
71
+ for (const t of [0.25, 0.5, 0.75]) {
72
+ const d = dist(cubicAt(q0, qc1, qc2, q1, t), cubicAt(p0, c1, c2, p1, t));
73
+ if (Math.abs(d - Math.abs(delta)) > OFFSET_TOL) { ok = false; break; }
74
+ }
75
+ if (ok || depth >= MAX_DEPTH) return { start: q0, segments: [{ to: q1, c1: qc1, c2: qc2 }], dirty: !ok };
76
+ const [L, R] = splitCubic(p0, c1, c2, p1, 0.5);
77
+ const a = offsetCubic(L.p0, L.c1, L.c2, L.p1, delta, depth + 1);
78
+ const b = offsetCubic(R.p0, R.c1, R.c2, R.p1, delta, depth + 1);
79
+ return { start: a.start, segments: [...a.segments, ...b.segments], dirty: a.dirty || b.dirty };
80
+ }
81
+
82
+ // One IR segment (running from `from` to seg.to) → its raw offset piece.
83
+ export function _offsetSegment(from, seg, delta) {
84
+ if (seg.c1) return offsetCubic(from, seg.c1, seg.c2, seg.to, delta, 0);
85
+ if (seg.via) return offsetArc(from, seg, delta);
86
+ return offsetLine(from, seg.to, delta);
87
+ }
88
+
89
+ const MITER_LIMIT = 2;
90
+
91
+ // Where X falls along the directed segment P0→P1, as a fraction of its length (null for a
92
+ // degenerate segment). This is the overlap-side trim's validity test — see the trim itself.
93
+ function paramOn(P0, P1, X) {
94
+ const d = sub(P1, P0), L2 = dot(d, d);
95
+ if (L2 < 1e-18) return null;
96
+ return dot(sub(X, P0), d) / L2;
97
+ }
98
+
99
+ // Intersection of the line through P (direction u) with the line through Q (direction v).
100
+ function lineIntersect(P, u, Q, v) {
101
+ const d = cross(u, v);
102
+ if (Math.abs(d) < 1e-12) return null;
103
+ const w = sub(Q, P);
104
+ return add(P, scl(u, cross(w, v) / d));
105
+ }
106
+
107
+ // Segments bridging aEnd → bStart around `corner` on the gap side.
108
+ function joinSegs(corner, aEnd, bStart, inTan, outTan, delta, corners) {
109
+ if (corners === "chamfer") return [{ to: bStart }];
110
+ if (corners === "sharp") {
111
+ const X = lineIntersect(aEnd, inTan, bStart, outTan);
112
+ if (X && dist(X, corner) <= MITER_LIMIT * Math.abs(delta)) return [{ to: X }, { to: bStart }];
113
+ return [{ to: bStart }]; // miter-limit fallback = bevel
114
+ }
115
+ // round: exact arc about the corner, via on the displacement bisector
116
+ const d1 = sub(aEnd, corner), d2 = sub(bStart, corner);
117
+ let m = add(d1, d2);
118
+ // 180° turn: the two displacement normals cancel exactly, but their sum's LIMIT as the
119
+ // turn approaches 180° is the incoming tangent direction (for either delta sign) — the
120
+ // cap bulges forward past the tip. The old rightOf(d1) fallback put `via` on the
121
+ // diametrically wrong side, sweeping the cap arc back through the interior where the
122
+ // winding rule cancelled it and the whole tip cap silently vanished (review finding,
123
+ // execution-confirmed: a zero-width spike dilated +1 round lost its end caps — area
124
+ // 20.000 where the stadium truth is 20+π, maxX 10 where the cap reaches 11).
125
+ if (len(m) < 1e-9) m = inTan;
126
+ return [{ via: add(corner, scl(norm(m), Math.abs(delta))), to: bStart }];
127
+ }
128
+
129
+ // Offset one explicitly-closed ring. Returns { contour, dirty }.
130
+ export function _offsetContour(contour, delta, corners) {
131
+ const pts = [contour.start, ...contour.segments.map((s) => s.to)];
132
+ // drop zero-length line segments (they carry no direction)
133
+ const keep = contour.segments.map((s, i) => s.c1 || s.via || dist(pts[i], s.to) > 1e-9);
134
+ const segs = contour.segments.filter((_, i) => keep[i]);
135
+ const froms = [];
136
+ { let p = contour.start; for (const s of contour.segments) { froms.push(p); p = s.to; } }
137
+ const fromsKept = froms.filter((_, i) => keep[i]);
138
+ // A ring whose every segment dropped as zero-length has no direction to offset along —
139
+ // treat it as collapsed (contour:null) rather than letting assembleRing dereference an
140
+ // empty piece list (review finding, execution-confirmed: a sub-1e-9-extent sliver ring
141
+ // from an upstream boolean reaches here through the public surface — checkPointRing
142
+ // demands three points, not nonzero extent — and crashed the whole offset with a raw
143
+ // TypeError instead of the pinned collapse message).
144
+ if (segs.length === 0) return { contour: null, dirty: true };
145
+ // NB: feed jointTangents the KEPT chain's start — if the first segment was dropped
146
+ // as zero-length, contour.start no longer heads the filtered ring.
147
+ const joints = jointTangents({ start: fromsKept[0] ?? contour.start, segments: segs });
148
+ const pieces = segs.map((s, i) => _offsetSegment(fromsKept[i], s, delta));
149
+ let dirty = pieces.some((p) => p.dirty);
150
+ const n = segs.length;
151
+ const joins = new Array(n).fill(null); // joins[i] bridges piece[i-1] → piece[i] at vertex i
152
+ // Each piece's ORIGINAL (pre-trim) first- and last-segment extents, captured before the
153
+ // join loop starts mutating them. The trim gate below is a question about the raw offset
154
+ // geometry, so it must be asked of the raw extents: reading the live, partly-trimmed ones
155
+ // makes the answer depend on which corner the loop happens to have reached, and a ring
156
+ // whose corners are then trimmed inconsistently (some yes, some no) is worse than either
157
+ // all-trimmed or none-trimmed — measured: a 6.99×7.78 rectangular hole at delta +3.5 (it
158
+ // over-collapses in x by 0.008 mm and should vanish) leaves a 12.26 mm² spurious face
159
+ // under a live-extent gate and 0.007 mm² under this one.
160
+ //
161
+ // Built eagerly for every piece, deliberately. Deferring it to the first overlap-side corner
162
+ // looks like free savings — four .slice() allocations per piece, read only by the trim gate —
163
+ // and it is not: an INWARD offset (the case the trim exists for, and the one benchmarked
164
+ // below) puts every convex corner on the overlap side, so every piece's extents get demanded
165
+ // anyway and laziness buys one closure call per corner. Measured, 300 runs, 100 disjoint
166
+ // squares: eager 0.328-0.334 ms against lazy 0.336-0.344 at delta -1, and eager 0.842 against
167
+ // lazy 0.827 at +1 — a wash in both directions. Not worth the ordering invariant it would add
168
+ // (corner i mutates piece i-1's last `.to` and piece i's `.start`, so a lazy read would have
169
+ // to be proven to happen before those, forever).
170
+ const ext = pieces.map((p) => ({
171
+ aFrom: (p.segments.length > 1 ? p.segments.at(-2).to : p.start).slice(),
172
+ aEnd: p.segments.at(-1).to.slice(),
173
+ bStart: p.start.slice(),
174
+ bEnd: p.segments[0].to.slice(),
175
+ }));
176
+
177
+ for (let i = 0; i < n; i++) {
178
+ const prev = pieces[(i - 1 + n) % n], next = pieces[i];
179
+ const aEnd = prev.segments.at(-1).to, bStart = next.start;
180
+ const { point, inTan, outTan } = joints[i];
181
+ const turn = cross(inTan, outTan);
182
+ const turnDeg = (Math.atan2(Math.abs(turn), Math.max(-1, Math.min(1, inTan[0] * outTan[0] + inTan[1] * outTan[1]))) * 180) / Math.PI;
183
+ if (dist(aEnd, bStart) <= JOIN_EPS || turnDeg < SMOOTH_JOINT_DEG) continue; // smooth
184
+ // Gap side gets a join. An EXACT 180° reversal (turn === 0 with a large turnDeg — the
185
+ // tip of a zero-width spike) is ambiguous under the sign test alone and used to fall
186
+ // through to the overlap branch, flat-capping a requested round end; treat it as gap
187
+ // side so the cap is honored (an inward spike's join makes an inverted loop the
188
+ // winding rule cancels, so the choice is safe for either delta sign).
189
+ if (turn * delta > 0 || turn === 0) { joins[i] = joinSegs(point, aEnd, bStart, inTan, outTan, delta, corners); continue; }
190
+ // Overlap side: the two offset pieces run into each other instead of leaving a gap.
191
+ // When both neighbors are plain lines and the two offset LINES cross WITHIN both
192
+ // segments' own extents, that crossing is the true corner of the offset outline: trim
193
+ // both to it and the ring stays simple, so validateRawOffset's exact fast path still
194
+ // applies (this is the everyday polygon inset, and keeping it on the fast path matters —
195
+ // 100 disjoint squares at delta −1 measure 0.33 ms against 9.1 ms with the trim ablated,
196
+ // both as shipped, i.e. with the in-extent gate below in force. An earlier revision of
197
+ // this comment quoted 0.22 ms, which was the UNGATED trim; the gate costs the difference
198
+ // and the number to plan against is the one the code actually runs).
199
+ //
200
+ // The in-extent test is the whole point and is NOT a formality. Past a corner's own
201
+ // feature size the two offset lines still cross, but OUTSIDE both segments — the "trim"
202
+ // then EXTENDS the segments to a point neither of them reaches, fabricating material the
203
+ // raw offset never covered, and because the result is still a simple, correctly-wound
204
+ // ring nothing downstream can tell. That was the whole residual gap after the winding
205
+ // resolver landed: on the clustered-reflex 9-gon at delta −2.79 it produced 4.621926
206
+ // against a true 3.553831 (Clipper2 and an independent Minkowski-union construction
207
+ // agree on that value), ~30 % too much surviving area. Untrimmed, the crossing offset
208
+ // segments are simply emitted and the positive-winding rule cancels the reversed loop,
209
+ // which is what every standard offset algorithm (Clipper2 included) does — so decline
210
+ // the trim, bevel across, and let resolveOffsetWinding do it properly.
211
+ const aSeg = prev.segments.at(-1), bSeg = next.segments[0];
212
+ if (!aSeg.via && !aSeg.c1 && !bSeg.via && !bSeg.c1) {
213
+ const X = lineIntersect(aEnd, inTan, bStart, outTan);
214
+ const eA = ext[(i - 1 + n) % n], eB = ext[i];
215
+ const ta = X && paramOn(eA.aFrom, eA.aEnd, X), tb = X && paramOn(eB.bStart, eB.bEnd, X);
216
+ if (X && ta !== null && tb !== null && ta > 0 && ta <= 1 && tb >= 0 && tb < 1) {
217
+ aSeg.to = X; next.start = X; continue; // exact trim, stays clean
218
+ }
219
+ }
220
+ joins[i] = [{ to: bStart }]; dirty = true;
221
+ }
222
+
223
+ // Whole-ring collapse check: when delta exceeds the ring's own inradius, EVERY plain-line
224
+ // piece's trimmed direction reverses relative to its pre-offset direction — reflection
225
+ // through the collapse point preserves winding, so the reflected ring passes every other
226
+ // validity check there is (paper.js included: it's a genuinely simple polygon, just the
227
+ // wrong one). That whole-ring signal is reliable; a PER-PIECE version of it is not — it
228
+ // also fires on ordinary trims (acute barbs, narrow slots, non-square holes, 45° chamfers)
229
+ // that never reflected, and "fixing" those by un-trimming produces over-inclusive geometry
230
+ // instead of the correct, already-exact Task 1-4 result. So: only act when ALL plain-line
231
+ // pieces agree; when some but not all do, this is a normal partial trim — leave it alone.
232
+ // Critically, "the whole ring" means EVERY piece of the ring, not just its line pieces: a
233
+ // ring where lines are a minority (a mostly-arc disc with a small tab, say) can have every
234
+ // one of its few line pieces reverse while the ring as a whole is nowhere near collapsed —
235
+ // requiring lineReversals.length === n makes this a genuine whole-ring predicate again. An
236
+ // all-arc ring that truly collapses is still caught downstream: offsetArc already marks
237
+ // rNew<0 / fully-collapsed arcs dirty, routing to cleanup instead of a false fast-path pass.
238
+ // pieceDot[i] is the reversal signal (dot of post-trim vs pre-offset direction) for
239
+ // plain-line piece i, or null for arc/cubic pieces — a dot of zero here also flags a
240
+ // piece trimmed down to zero length, since a zero vector's dot with anything is 0.
241
+ const pieceDot = pieces.map((p, i) => {
242
+ if (p.segments.length !== 1 || p.segments[0].via || p.segments[0].c1) return null;
243
+ const origDir = sub(segs[i].to, fromsKept[i]);
244
+ const newDir = sub(p.segments[0].to, p.start);
245
+ return dot(newDir, origDir);
246
+ });
247
+ const lineReversals = pieceDot.filter((d) => d !== null).map((d) => d <= 0);
248
+ if (lineReversals.length === n && n > 0 && lineReversals.every(Boolean)) return { contour: null, dirty: true };
249
+
250
+ // Part 1 of the partial-reflection fix (task 5B): the whole-ring gate above only fires
251
+ // when EVERY plain-line piece reverses — by design, since a per-piece version of that
252
+ // gate also fires on ordinary trims that never reflected (see the comment above). But a
253
+ // ring that does NOT collapse wholesale can still carry one or two individually-reversed
254
+ // pieces (an over-offset corner that trimmed past its own neighbor) — that's the seed of
255
+ // the partial-reflection residual: those pieces survive into the ring and validateRawOffset
256
+ // can't see anything locally wrong with them. Delete them (not un-trim — un-trimming was
257
+ // rejected in an earlier round for other over-inclusive regressions) and re-link the
258
+ // surviving neighbors with a direct chord, marking the ring dirty so resolveOffsetWinding
259
+ // gets a chance to untangle whatever that chord leaves behind. Every non-line piece
260
+ // always survives this pass; the whole-ring gate above already guarantees at least one
261
+ // piece survives here too.
262
+ const allIdx = pieces.map((_, i) => i);
263
+ const dropped = pieceDot.map((d) => d !== null && d <= 0);
264
+ if (!dropped.some(Boolean)) return { contour: assembleRing(pieces, joins, allIdx, n), dirty };
265
+
266
+ const keptIdx = allIdx.filter((i) => !dropped[i]);
267
+ if (keptIdx.length === 0) return { contour: null, dirty: true };
268
+
269
+ // Guard (review round 1, Important 3): deletion is unrecoverable — unlike a chord/dirty
270
+ // join, which resolveOffsetWinding can still untangle downstream, a deleted piece is gone for
271
+ // good, so a bad deletion can turn perfectly good geometry into a false "offset collapses
272
+ // the shape" throw (measured: 18 new throws per 3000 random polygons; repro: a 9-gon at
273
+ // delta -2.79/chamfer with true eroded area 2.76). A raw *piece count* floor doesn't work as
274
+ // the discriminator — an arc-dominated ring can be legitimately reduced to a single
275
+ // surviving piece (this file's own storage convention stores a full circle as just two arcs;
276
+ // the keyed-bore regression test below reduces to one surviving arc + closing chord and that
277
+ // IS the correct answer). Routing through the former Paper.js self-union cleanup did not
278
+ // work either — it was tried first and rejected because it assumed the CCW/positive-area
279
+ // "outer" convention (an
280
+ // uninverted self-union is real material, an inverted one that flips positive is a
281
+ // discarded artifact), but _offsetContour has no idea here whether it's assembling an outer
282
+ // or a hole, and a perfectly valid CW/negative-area hole ring (like the keyed bore's) reads
283
+ // as "inverted" under that assumption and gets wrongly discarded. What actually
284
+ // distinguishes "deletion destroyed real geometry" from "deletion correctly trimmed it
285
+ // down" is winding-agnostic: is the assembled result still a SIMPLE (non-self-intersecting)
286
+ // ring with nonzero area — exactly what validateRawOffset's own segment-crossing test
287
+ // (ringSelfIntersects, defined below) already checks without any orientation assumption.
288
+ // Fall back to the un-deleted ring (still marked dirty, since a piece DID look reversed)
289
+ // when deletion isn't simple: cleanup gets a chance to untangle whatever the un-deleted
290
+ // piece leaves behind, which is strictly more recoverable than deletion's dead end.
291
+ const deleted = assembleRing(pieces, joins, keptIdx, n);
292
+ const deletedRing = sampleRing(deleted, VALIDATE_SEGS);
293
+ const deletedArea = Math.abs(ringArea(deletedRing));
294
+ if (deletedArea <= AREA_EPS || ringSelfIntersects(deletedRing))
295
+ return { contour: assembleRing(pieces, joins, allIdx, n), dirty: true };
296
+ return { contour: deleted, dirty: true };
297
+ }
298
+
299
+ // Assemble a closed ring from a subset of offset pieces (by index into `pieces`, in ring
300
+ // order), bridging consecutive survivors with their original join (when adjacent in the
301
+ // source ring) or a single chord (when one or more pieces were skipped in between — Part 1's
302
+ // deletion path). `idx = [0..n-1]` (every piece kept) reproduces the plain, no-deletion
303
+ // assembly exactly, since every piece is then "adjacent" to the next by construction.
304
+ function assembleRing(pieces, joins, idx, n) {
305
+ const out = [];
306
+ for (let k = 0; k < idx.length; k++) {
307
+ const i = idx[k];
308
+ out.push(...pieces[i].segments);
309
+ const nextI = idx[(k + 1) % idx.length];
310
+ if (nextI === (i + 1) % n) {
311
+ const j = joins[nextI];
312
+ if (j) out.push(...j);
313
+ } else {
314
+ // one or more pieces were skipped between i and nextI: bridge with a single chord
315
+ out.push({ to: [pieces[nextI].start[0], pieces[nextI].start[1]] });
316
+ }
317
+ }
318
+ const start = pieces[idx[0]].start;
319
+ const last = out.at(-1);
320
+ if (dist(last.to, start) <= JOIN_EPS) last.to = [start[0], start[1]]; // snap the closure exactly
321
+ else out.push({ to: [start[0], start[1]] });
322
+ return { start, segments: out };
323
+ }
324
+
325
+ // Do two segments meet anywhere — including AT an endpoint of either one? The orientation
326
+ // test `o1 !== o2 && o3 !== o4` already answers that (a vertex-incident crossing shows up as
327
+ // one orientation being 0 and the other two straddling), so the endpoint-incident case costs
328
+ // nothing extra; what it costs is the right to be sloppy about adjacency, which is why every
329
+ // caller must hand this a ring with no duplicate and no zero-length vertices (see dedupeRing).
330
+ //
331
+ // This used to demand all four orientations be nonzero — "strict crossings only" — and that
332
+ // discarded exactly the case an inward offset produces most often: a ring that runs into
333
+ // ITSELF at one of its own vertices. A 20×10 block with an 8-deep slot, inset by 2, offsets
334
+ // the slot floor DOWN past the block's own eroded bottom edge, so the ring dips below y=2 and
335
+ // re-crosses it AT the vertices where the slot walls land — every crossing endpoint-incident,
336
+ // every orientation product zero, `validateRawOffset` satisfied, and the tangled ring taken
337
+ // straight down the exact fast path. It silently kept 32 mm² of a true 48 mm² erosion (the
338
+ // walls' offsets cancel under positive winding, which is what resolveOffsetWinding would have
339
+ // done had the ring ever reached it). Endpoint-incidence is not a degenerate curiosity here;
340
+ // it is the generic case, because offset pieces are BUILT by translating shared vertices.
341
+ function segsIntersect(a1, a2, b1, b2) {
342
+ const o = (p, q, r) => Math.sign((q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]));
343
+ const o1 = o(a1, a2, b1), o2 = o(a1, a2, b2), o3 = o(b1, b2, a1), o4 = o(b1, b2, a2);
344
+ return o1 !== o2 && o3 !== o4;
345
+ }
346
+
347
+ // True when two (non-adjacent) segments are collinear and overlap along more than a point.
348
+ // segsIntersect is an orientation test, and a fully collinear pair makes all four
349
+ // orientations 0, so `o1 !== o2` is false and it reports nothing — yet an offset ring can
350
+ // retrace the same line twice (a neck/hole pinched shut by delta past its own width flips
351
+ // the offset pieces from either side onto each other), producing exact duplicate or
352
+ // overlapping collinear edges with no transversal crossing anywhere. That is this test's
353
+ // job and it stays: widening segsIntersect to endpoint-incidence does not subsume it.
354
+ function segsOverlap(a1, a2, b1, b2) {
355
+ const d = sub(a2, a1);
356
+ const L = len(d);
357
+ if (L < 1e-9) return false;
358
+ const u = norm(d);
359
+ const perp = (p) => Math.abs(cross(u, sub(p, a1))); // distance off the a1→a2 line
360
+ if (perp(b1) > 1e-9 || perp(b2) > 1e-9) return false; // not collinear with a
361
+ const t = (p) => dot(sub(p, a1), u); // param along a1→a2
362
+ const [ta1, ta2] = [0, L];
363
+ const [tb1, tb2] = [t(b1), t(b2)].sort((x, y) => x - y);
364
+ return Math.min(ta2, tb2) - Math.max(ta1, tb1) > 1e-9; // overlap longer than a touch
365
+ }
366
+
367
+ // Axis-aligned bounding box of a ring / of one segment, and a disjointness test between
368
+ // two boxes. These exist purely as a rejection filter in front of the pairwise segment
369
+ // tests below: validateRawOffset runs on the geometry worker on EVERY offset (so on every
370
+ // parameter change), and its pairwise loops are O(R²·m²) in ring count and ring resolution
371
+ // with nothing to stop them. Measured on offsetRegions end to end, +0.5 round over N disjoint
372
+ // squares: 40 → 11.6 ms before this filter and 1.0 ms after, 100 → 64.0 / 1.3 ms, 200 → 326.2
373
+ // / 2.0 ms — clean quadratic before, effectively flat after. (Many-region TEXT is a smaller
374
+ // win — a 24-glyph string went 93 → 85 ms — because a glyph's raw offset ring self-intersects,
375
+ // so validation short-circuits early and paper.js cleanup dominates that case regardless.)
376
+ // Boxes never change the ANSWER — two segments whose boxes are disjoint cannot cross or
377
+ // overlap — so this is a pure short-circuit, not an approximation.
378
+ const BOX_EPS = 1e-9;
379
+ function ringBox(ring) {
380
+ let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
381
+ for (const [x, y] of ring) {
382
+ if (x < x0) x0 = x; if (x > x1) x1 = x;
383
+ if (y < y0) y0 = y; if (y > y1) y1 = y;
384
+ }
385
+ return [x0, y0, x1, y1];
386
+ }
387
+ const boxesApart = (a, b) =>
388
+ a[2] < b[0] - BOX_EPS || b[2] < a[0] - BOX_EPS || a[3] < b[1] - BOX_EPS || b[3] < a[1] - BOX_EPS;
389
+ 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])];
390
+
391
+ // Every ring below is read as IMPLICITLY closed — segment i runs ring[i] → ring[(i+1)%m] —
392
+ // and the pairwise loops decide "these two may legitimately touch" purely from index
393
+ // adjacency. `tessellateContour` does not produce such a ring: it emits the closing point,
394
+ // so ring[m-1] === ring[0]. That duplicate is not harmless bookkeeping once segsIntersect
395
+ // counts endpoint contact. It creates a zero-length segment m-1 sitting on ring[0], and it
396
+ // pushes the real closing edge back to index m-2 — which then shares the vertex ring[0] with
397
+ // segment 0 while being two apart in the index, i.e. NON-adjacent by the loop's reckoning.
398
+ // Result: every ring in the repo would report a self-touch at its own start point, and every
399
+ // offset would take the resolver. Coincident interior vertices (a join that lands exactly on
400
+ // its neighbour, a piece trimmed to zero length) do the same thing one index further in.
401
+ // So: normalize first, and let "non-adjacent" mean what it says.
402
+ const RING_EPS = 1e-9;
403
+ function dedupeRing(ring) {
404
+ const out = [];
405
+ for (const p of ring) if (!out.length || dist(out.at(-1), p) > RING_EPS) out.push(p);
406
+ while (out.length > 1 && dist(out[0], out.at(-1)) <= RING_EPS) out.pop();
407
+ return out;
408
+ }
409
+ // Tessellate a contour into the deduplicated, implicitly-closed ring the tests below expect.
410
+ // (ringArea / ringBox / pointInRing all wrap modularly too, so they read it unchanged.)
411
+ const sampleRing = (contour, segs) => dedupeRing(tessellateContour(contour, segs));
412
+
413
+ function ringSelfIntersects(ring) {
414
+ const m = ring.length;
415
+ const boxes = [];
416
+ for (let i = 0; i < m; i++) boxes.push(segBox(ring[i], ring[(i + 1) % m]));
417
+ for (let i = 0; i < m; i++) for (let j = i + 2; j < m; j++) {
418
+ if (i === 0 && j === m - 1) continue; // adjacent via wraparound
419
+ if (boxesApart(boxes[i], boxes[j])) continue;
420
+ const a1 = ring[i], a2 = ring[(i + 1) % m], b1 = ring[j], b2 = ring[(j + 1) % m];
421
+ if (segsIntersect(a1, a2, b1, b2) || segsOverlap(a1, a2, b1, b2)) return true;
422
+ }
423
+ return false;
424
+ }
425
+
426
+ // Two DIFFERENT rings interfering. Like ringSelfIntersects this checks segsOverlap as well
427
+ // as segsIntersect: two rings can interfere without any transversal crossing at all when
428
+ // their boundaries run along the same line — exactly what two eroding holes that grew into
429
+ // each other produce under a sharp join, where the crossings land on shared vertices and the
430
+ // rest of the signal is a pair of collinear, partially-overlapping edges. Missing that let an
431
+ // invalid double-ring result pass the fast path and reach extrude, where even-odd fill turned
432
+ // the doubly-covered lens back into SOLID material inside the merged pocket. (The shared-
433
+ // vertex half of that is now caught directly — segsIntersect counts endpoint contact — but
434
+ // the collinear half still is not, so both tests stay.)
435
+ function ringsCross(a, b, boxA = ringBox(a), boxB = ringBox(b)) {
436
+ if (boxesApart(boxA, boxB)) return false;
437
+ for (let i = 0; i < a.length; i++) {
438
+ const a1 = a[i], a2 = a[(i + 1) % a.length];
439
+ const bx = segBox(a1, a2);
440
+ if (boxesApart(bx, boxB)) continue;
441
+ for (let j = 0; j < b.length; j++) {
442
+ const b1 = b[j], b2 = b[(j + 1) % b.length];
443
+ if (boxesApart(bx, segBox(b1, b2))) continue;
444
+ if (segsIntersect(a1, a2, b1, b2) || segsOverlap(a1, a2, b1, b2)) return true;
445
+ }
446
+ }
447
+ return false;
448
+ }
449
+
450
+ // Every point of `inner` strictly inside `outer`. Sampling ONE point (which this used to do)
451
+ // is satisfied by a hole that has grown most of the way out through its own outer boundary
452
+ // as long as its first vertex happens to still be inside — and a hole ring poking outside
453
+ // its outer is not merely inaccurate, it becomes real material: fed through toRegions() and
454
+ // CrossSection.ofPolygons(…,"EvenOdd"), the escaped part of the ring extrudes to a solid tab
455
+ // hanging off the plate below its own boundary.
456
+ function ringInsideRing(inner, outer, outerBox) {
457
+ for (const p of inner) {
458
+ if (p[0] < outerBox[0] || p[0] > outerBox[2] || p[1] < outerBox[1] || p[1] > outerBox[3]) return false;
459
+ if (!pointInRing(p, outer)) return false;
460
+ }
461
+ return true;
462
+ }
463
+
464
+ // True when a raw offset result is already valid (fast path). Sampled at VALIDATE_SEGS.
465
+ export function validateRawOffset(regions) {
466
+ const sampled = regions.map((rg) => ({
467
+ outer: sampleRing(rg.outer, VALIDATE_SEGS),
468
+ holes: rg.holes.map((h) => sampleRing(h, VALIDATE_SEGS)),
469
+ }));
470
+ const allRings = [];
471
+ for (const rg of sampled) {
472
+ if (ringArea(rg.outer) <= AREA_EPS) return false; // flipped or collapsed outer
473
+ const outerBox = ringBox(rg.outer);
474
+ for (const h of rg.holes) {
475
+ if (ringArea(h) >= -AREA_EPS) return false; // flipped or collapsed hole
476
+ if (!ringInsideRing(h, rg.outer, outerBox)) return false; // hole escaped its outer
477
+ }
478
+ allRings.push(rg.outer, ...rg.holes);
479
+ }
480
+ for (const r of allRings) if (ringSelfIntersects(r)) return false;
481
+ const boxes = allRings.map(ringBox);
482
+ for (let i = 0; i < allRings.length; i++) for (let j = i + 1; j < allRings.length; j++)
483
+ if (ringsCross(allRings[i], allRings[j], boxes[i], boxes[j])) return false;
484
+ return true;
485
+ }
486
+
487
+ // Part 2 of the partial-reflection fix (task 5B) — REMOVED (review round 2). A global
488
+ // distance-from-source prune existed here through two review rounds, narrowing its scope each
489
+ // time (round 1: per-hole rather than whole-region, to stop swallowing legitimate hole
490
+ // merges/breakthroughs; round 2: exact closed-form line/arc distance and an adaptively-
491
+ // flattened cubic distance instead of a chord-length-bounded tolerance, to stop losing curved
492
+ // holes to discretization noise). Round 2 confirmed the prune's own justification was sound —
493
+ // the wide-arm-L-pocket case it exists to fix is a genuine defect (max inscribed circle in a
494
+ // 5-wide-arm L has radius 2.5 < delta 3, so the hole DOES fully vanish; a truth value is
495
+ // derivable and the pre-fix engine got it wrong) — but even with exact-geometry distances, a
496
+ // sweep of real glyph counters (uppercase/lowercase/digit counters on 10mm text at delta
497
+ // 0.1–0.5mm) still lost 36 of 76 entirely, all silent total-hole-loss, none
498
+ // recoverable by further tolerance tuning: the wide-L-pocket's own raw hole ring is ALREADY
499
+ // `dirty` from Part 1 before any distance check runs, and so are the failing glyph counters —
500
+ // there is no scoping condition (dirty vs not, tolerance size, source curve type) that
501
+ // distinguishes "prune this, it's really gone" from "don't prune this, cleanup will recover
502
+ // it" using only the raw, pre-cleanup ring. The prune's collateral (silently deleting real
503
+ // text counters at sub-millimetre offsets) is strictly worse than the single defect it fixes,
504
+ // so it's gone rather than shipped delicately tuned. See task-5B-report.md's round-2 section
505
+ // for the full sweep. (The wide-L-pocket case itself is no longer an open gap: task 7's
506
+ // resolveOffsetWinding — see below — resolves it correctly with no per-ring heuristic at all,
507
+ // since a fully-eroded hole ring is simply negative-winding everywhere and drops out on its
508
+ // own; see test/contour-offset.test.js's "wide L-shaped hole (5-unit arms) at +3" test.)
509
+
510
+ // A positive round offset erodes each source hole by a Euclidean disk. The hole survives
511
+ // exactly when its source domain contains a disk of radius `delta`; asking that question
512
+ // before constructing the raw offset avoids the inverted pockets that fully-eroded curved
513
+ // counters can otherwise leave behind. This deliberately examines the SOURCE contour, not
514
+ // the self-tangled raw output — see the removed-prune history above.
515
+ const SOURCE_DISK_TOL = 5 * OFFSET_TOL;
516
+ const SOURCE_DISK_FLAT_TOL = OFFSET_TOL / 2;
517
+ const SOURCE_DISK_POINT_CAP = 16384;
518
+
519
+ function pointSegmentDistance(p, a, b) {
520
+ const ab = sub(b, a);
521
+ const d2 = dot(ab, ab);
522
+ if (d2 <= 1e-18) return dist(p, a);
523
+ const t = Math.max(0, Math.min(1, dot(sub(p, a), ab) / d2));
524
+ return dist(p, add(a, scl(ab, t)));
525
+ }
526
+
527
+ // Flatten specifically for the disk proof with a distance-bound, rather than the display
528
+ // sampler's angle-bound. Arc sagitta and the cubic control hull both give conservative
529
+ // Hausdorff bounds. If a pathological curve exceeds the work cap, return null so its hole is
530
+ // kept — resource exhaustion must not turn into silent topology loss.
531
+ function sourceDiskRing(contour) {
532
+ if (Array.isArray(contour)) return dedupeRing(contour);
533
+ const ring = [[...contour.start]];
534
+ let capped = false;
535
+ const push = (p) => {
536
+ if (ring.length >= SOURCE_DISK_POINT_CAP) { capped = true; return; }
537
+ ring.push([p[0], p[1]]);
538
+ };
539
+ const cubic = (p0, c1, c2, p1, depth = 0) => {
540
+ if (capped) return;
541
+ const flatness = Math.max(pointSegmentDistance(c1, p0, p1), pointSegmentDistance(c2, p0, p1));
542
+ if (flatness <= SOURCE_DISK_FLAT_TOL) { push(p1); return; }
543
+ if (depth >= 18) { capped = true; return; }
544
+ const [left, right] = splitCubic(p0, c1, c2, p1, 0.5);
545
+ cubic(left.p0, left.c1, left.c2, left.p1, depth + 1);
546
+ cubic(right.p0, right.c1, right.c2, right.p1, depth + 1);
547
+ };
548
+
549
+ let from = contour.start;
550
+ for (const seg of contour.segments) {
551
+ if (seg.c1) cubic(from, seg.c1, seg.c2, seg.to);
552
+ else if (seg.via) {
553
+ const arc = arcCenterAndSweep(from, seg.via, seg.to);
554
+ if (!arc) push(seg.to);
555
+ else {
556
+ const maxStep = 2 * Math.acos(Math.max(-1, 1 - SOURCE_DISK_FLAT_TOL / arc.r));
557
+ const steps = Math.max(2, Math.ceil(Math.abs(arc.dA) / maxStep));
558
+ const a0 = Math.atan2(from[1] - arc.center[1], from[0] - arc.center[0]);
559
+ if (!Number.isFinite(steps) || ring.length + steps > SOURCE_DISK_POINT_CAP) capped = true;
560
+ else for (let i = 1; i <= steps; i++) {
561
+ const a = a0 + arc.dA * (i / steps);
562
+ push([arc.center[0] + arc.r * Math.cos(a), arc.center[1] + arc.r * Math.sin(a)]);
563
+ }
564
+ }
565
+ } else push(seg.to);
566
+ if (capped) return null;
567
+ from = seg.to;
568
+ }
569
+ return dedupeRing(ring);
570
+ }
571
+
572
+ function signedRingDistance(p, ring) {
573
+ let d = Infinity;
574
+ for (let i = 0; i < ring.length; i++)
575
+ d = Math.min(d, pointSegmentDistance(p, ring[i], ring[(i + 1) % ring.length]));
576
+ return pointInRing(p, ring) ? d : -d;
577
+ }
578
+
579
+ const diskCell = (x, y, hx, hy, ring) => {
580
+ const d = signedRingDistance([x, y], ring);
581
+ return { x, y, hx, hy, d, max: d + Math.hypot(hx, hy) };
582
+ };
583
+
584
+ function heapPush(heap, cell) {
585
+ let i = heap.length;
586
+ heap.push(cell);
587
+ while (i > 0) {
588
+ const parent = (i - 1) >> 1;
589
+ if (heap[parent].max >= cell.max) break;
590
+ heap[i] = heap[parent];
591
+ i = parent;
592
+ }
593
+ heap[i] = cell;
594
+ }
595
+
596
+ function heapPop(heap) {
597
+ const top = heap[0];
598
+ const last = heap.pop();
599
+ if (!heap.length) return top;
600
+ let i = 0;
601
+ while (true) {
602
+ const left = i * 2 + 1;
603
+ if (left >= heap.length) break;
604
+ const right = left + 1;
605
+ const child = right < heap.length && heap[right].max > heap[left].max ? right : left;
606
+ if (heap[child].max <= last.max) break;
607
+ heap[i] = heap[child];
608
+ i = child;
609
+ }
610
+ heap[i] = last;
611
+ return top;
612
+ }
613
+
614
+ export function _sourceHoleContainsDisk(contour, radius, tolerance = SOURCE_DISK_TOL) {
615
+ if (radius <= 0) return true;
616
+ const ring = sourceDiskRing(closeContourGap(contour));
617
+ if (!ring) return true;
618
+ if (ring.length < 3) return false;
619
+ const [x0, y0, x1, y1] = ringBox(ring);
620
+ const width = x1 - x0, height = y1 - y0;
621
+ if (width <= 0 || height <= 0) return false;
622
+
623
+ // Keep a near-threshold hole rather than silently erase real material because of curve
624
+ // tessellation or floating-point error. A false positive merely leaves cleanup its former
625
+ // input; a false negative permanently deletes the counter.
626
+ const threshold = Math.max(0, radius - tolerance);
627
+ if (Math.min(width, height) / 2 < threshold) return false;
628
+
629
+ const heap = [];
630
+ heapPush(heap, diskCell((x0 + x1) / 2, (y0 + y1) / 2, width / 2, height / 2, ring));
631
+ while (heap.length) {
632
+ const cell = heapPop(heap); // greatest remaining upper bound
633
+ if (cell.d >= threshold) return true;
634
+ if (cell.max < threshold) return false;
635
+ if (Math.hypot(cell.hx, cell.hy) <= tolerance) return true;
636
+
637
+ const hx = cell.hx / 2, hy = cell.hy / 2;
638
+ for (const dx of [-hx, hx]) for (const dy of [-hy, hy])
639
+ heapPush(heap, diskCell(cell.x + dx, cell.y + dy, hx, hy, ring));
640
+ }
641
+ return false;
642
+ }
643
+
644
+ // Raw per-ring offset of a whole region list, plus whether any surviving ring came back
645
+ // approximated. Split out of offsetRegions so the fallback ladder below can re-run it at a
646
+ // perturbed delta without recursing through the public entry point.
647
+ function rawOffset(regions, delta, corners) {
648
+ // _offsetContour signals a whole-ring collapse with contour:null (see its comment) — a
649
+ // dropped outer removes its whole region, a dropped hole is simply omitted. If everything
650
+ // drops, raw ends up [] and offsetRegions' "no regions survive" throw fires naturally.
651
+ let dirty = false;
652
+ const raw = [];
653
+ for (const rg of regions) {
654
+ const o = _offsetContour(closeContourGap(rg.outer), delta, corners);
655
+ dirty = dirty || o.dirty;
656
+ if (!o.contour) continue;
657
+ const hs = rg.holes.map((h) => {
658
+ const hole = closeContourGap(h);
659
+ if (delta > 0 && corners === "round" && !_sourceHoleContainsDisk(hole, delta))
660
+ return { contour: null, dirty: false };
661
+ return _offsetContour(hole, delta, corners);
662
+ });
663
+ // Only a SURVIVING hole's dirtiness can dirty the result. A hole that collapsed
664
+ // (contour === null) always reports dirty — that is how _offsetContour signals the drop —
665
+ // but the drop itself is a clean operation: the hole is simply gone and nothing else about
666
+ // the region moved. Folding that signal into `dirty` would send an otherwise-exact outer
667
+ // through resolveOffsetWinding for no reason — unnecessary crossing search over a ring that
668
+ // was already exact — for a hole that isn't even in the output.
669
+ dirty = dirty || hs.some((h) => h.contour && h.dirty);
670
+ raw.push({ outer: o.contour, holes: hs.filter((h) => h.contour).map((h) => h.contour) });
671
+ }
672
+ return { raw, dirty };
673
+ }
674
+
675
+ const resolveOrRaw = ({ raw, dirty }) =>
676
+ (!dirty && validateRawOffset(raw)) ? raw : resolveOffsetWinding(raw);
677
+
678
+ // Three underscore hooks, none of them part of the public surface, all three existing for
679
+ // ONE reason: `scripts/offset-rates.mjs` is the committed instrument behind every offset rate
680
+ // quoted in docs/ERROR-PATTERNS.md and docs/KERNEL-CONTRACT.md, and it has to measure the
681
+ // SHIPPED ladder rather than a copy of it. Those rates previously came from scratch scripts
682
+ // that were never committed, and several of them turned out to be wrong.
683
+ // _rawOffset — the raw per-ring offset, the ladder's input.
684
+ // _offsetNoFallback — the offset as it behaved BEFORE the ladder existed (the "before"
685
+ // column of the rate table).
686
+ // _ladderRungs — the ladder itself, as named lazy thunks.
687
+ export const _rawOffset = rawOffset;
688
+ export const _offsetNoFallback = (regions, delta, corners) =>
689
+ resolveOrRaw(rawOffset(regions, delta, corners));
690
+
691
+ // A ring rebuilt as straight chords at `segs` facets per full turn — arcs and cubics
692
+ // replaced by their own tessellation, lines unchanged (tessellateContour emits a line's
693
+ // endpoints and nothing between). Used only by the fallback ladder's last rungs.
694
+ const flattenRing = (contour, segs) => {
695
+ const pts = tessellateContour(contour, segs);
696
+ // closeContourGap, not a bare rebuild: resolveOffsetWinding requires every ring to carry a
697
+ // real closing segment back to `start` (_splitRings throws on an implicitly-closed one), and
698
+ // whether tessellateContour's last point lands exactly on the first is a property of the
699
+ // input, not something to assume.
700
+ return closeContourGap({ start: [pts[0][0], pts[0][1]],
701
+ segments: pts.slice(1).map((p) => ({ to: [p[0], p[1]] })) });
702
+ };
703
+
704
+ // The fallback ladder for a raw offset whose arrangement the winding resolver cannot close
705
+ // (contour-winding.js's CHAIN_INCOMPLETE_MESSAGE). Each rung re-runs the SAME offset with one
706
+ // numerical nuisance perturbed, and the first rung that produces a non-empty region set wins.
707
+ //
708
+ // Why a ladder exists at all: the chain failure is a defect in resolving a degenerate
709
+ // arrangement, not a statement about the shape — the material is really there. Left as a
710
+ // throw it reads, in a parametric app that re-offsets on every slider move, as "the part
711
+ // builds at 2.4 mm and dies at 2.5 mm", which is a harder failure than any other defect class
712
+ // in this engine (all of which degrade to bounded inaccuracy).
713
+ //
714
+ // RATES, and where they come from. `node scripts/offset-rates.mjs` sweeps the committed
715
+ // corpus (600 seeded shapes + 6 glyphs, 20 deltas, 3 styles = 36 090 offsets). After the
716
+ // adaptive pinch classifier, failures before the ladder / after it are:
717
+ // round 1 -> 0 chamfer 2 -> 0 sharp 4 -> 0
718
+ // All seven rescues are oracle-checked: median area error 0.0972 %, worst 1.663 %, with zero
719
+ // region-count losses and zero complete arc losses. The ladder stays because those seven raw
720
+ // arrangements remain numerically unclosable, not because the formerly parked comb/text
721
+ // failures still exist.
722
+ //
723
+ // Rung ORDER is by fidelity of what survives, not by hit rate:
724
+ // 1. delta perturbed by ±1e-9 relative. Escapes an exactly-degenerate arrangement (two
725
+ // offset walls landing on the same coordinate) with the requested corner style and the
726
+ // exact curve IR both intact.
727
+ // 2. a coarser crossing-merge radius (4x and 20x CLUSTER_TOL). Keeps corner style AND the
728
+ // exact IR — a trimmed arc is still an arc — at the cost of collapsing crossings up to
729
+ // that radius apart onto one vertex. This can merge a genuine severing pinch, so the
730
+ // 20x rung is not widened further even though the current seven rescues preserve the
731
+ // oracle's region count.
732
+ // 3. the raw outline re-run as polylines (64/256/1024 facets per turn). Geometrically
733
+ // faithful to the chord error of that tessellation, but it DEGRADES THE IR: round joins
734
+ // come back as chords, so A STEP EXPORT OF A POLYLINE-RUNG RESULT LOSES ITS TRUE CIRCLES.
735
+ // No current corpus rescue loses every arc, but these rungs remain last because that
736
+ // fidelity cost is structural.
737
+ // Coverage is not monotonic in any rung's parameter (64 facets resolves cases 256 does not) —
738
+ // these are escapes from degeneracy, not refinements, so every rung earns its place by cases
739
+ // no other rung covers.
740
+ //
741
+ // Two things this deliberately does NOT do. (The numbers in this paragraph are task 7D's
742
+ // design measurements, taken on a 59-case round-only sweep that predates the committed corpus
743
+ // and is NOT reproducible from this repo — they are recorded as the reasoning behind two
744
+ // rejected rungs, not as current rates. Everything above is from scripts/offset-rates.mjs.)
745
+ // It never retries under a different JOIN: swapping
746
+ // to chamfer resolves 58 of the 59, but at a median 5 % from the round truth (worst 4 800 %),
747
+ // and it would hand back geometry with visibly different corners than the caller asked for —
748
+ // silently wrong beats loudly failed only when the wrongness is bounded, and that one is not.
749
+ // And it never perturbs delta by more than 1e-9: the failures are NOT the knife-edge
750
+ // degeneracies they look like (the four-notch comb throws across a whole 0.02 mm band of delta,
751
+ // not at one value), so escaping by delta alone takes ~1e-2 relative, which resolves 100 % at a
752
+ // median 0.15 % and a worst 9 % error — well outside the band above. A bounded middle ground
753
+ // (±1e-4 and ±1e-3 mm absolute rungs) was measured too: it bought ONE extra case out of 62 and
754
+ // more than doubled the worst absolute error, 0.048 → 0.112 mm², so it is not here either.
755
+ //
756
+ // The ladder as named, LAZY rungs — one list, walked by chainFallback below and by
757
+ // scripts/offset-rates.mjs, so a measurement of "what each rung costs" can never drift from
758
+ // the ladder that actually ships. Every rung's whole body (including tessellating the outline
759
+ // for the polyline rungs) runs inside its own thunk. The expected chain-incomplete signal
760
+ // advances to the next rung; any other setup or resolver exception propagates as a bug report,
761
+ // matching offsetRegions' policy instead of being silently hidden by the fallback ladder.
762
+ export function _ladderRungs(regions, raw, delta, corners) {
763
+ return [
764
+ ...[-1, 1].map((sign) => ({ name: `delta*(1${sign < 0 ? "-" : "+"}1e-9)`,
765
+ run: () => resolveOrRaw(rawOffset(regions, delta * (1 + sign * 1e-9), corners)) })),
766
+ ...[4, 20].map((mult) => ({ name: `clusterTol*${mult}`,
767
+ run: () => resolveOffsetWinding(raw, { clusterTol: CLUSTER_TOL * mult }) })),
768
+ ...[64, 256, 1024].map((segs) => ({ name: `polyline@${segs}`,
769
+ run: () => resolveOffsetWinding(raw.map((rg) => ({ outer: flattenRing(rg.outer, segs),
770
+ holes: rg.holes.map((h) => flattenRing(h, segs)) }))) })),
771
+ ];
772
+ }
773
+
774
+ // Returns null when every rung fails, which is the caller's signal to rethrow the original.
775
+ function chainFallback(regions, raw, delta, corners) {
776
+ for (const rung of _ladderRungs(regions, raw, delta, corners)) {
777
+ let out;
778
+ try {
779
+ out = rung.run();
780
+ } catch (err) {
781
+ // A rung may fail with the SAME unresolvable-arrangement signal the ladder exists
782
+ // for — move to the next rung. Anything else (the _splitRings clustering tripwire,
783
+ // a TypeError) is a bug report, exactly as offsetRegions' own policy says below;
784
+ // swallowing it here would defeat those tripwires' stated fail-at-the-source purpose
785
+ // (review finding).
786
+ if (err?.message === CHAIN_INCOMPLETE_MESSAGE) continue;
787
+ throw err;
788
+ }
789
+ if (out.length > 0) return out;
790
+ }
791
+ return null;
792
+ }
793
+
794
+ // Dilation is extensive: S ⊆ S+B. A resolver artifact that contains no point from any
795
+ // source outer therefore cannot be a real output component. Unlike an area cutoff this keeps
796
+ // arbitrarily small source components, and it is valid for every positive join style. Source
797
+ // boundary samples lie strictly inside their dilation once delta clears the numerical band.
798
+ function sourceBackedPositiveRegions(source, out, delta) {
799
+ if (delta <= SOURCE_DISK_TOL) return out;
800
+ const witnesses = source.flatMap((rg) => sampleRing(closeContourGap(rg.outer), VALIDATE_SEGS));
801
+ const entries = out.map((rg) => {
802
+ const outer = sampleRing(rg.outer, VALIDATE_SEGS);
803
+ const box = ringBox(outer);
804
+ const holeRings = rg.holes.map((h) => sampleRing(h, VALIDATE_SEGS));
805
+ const backed = witnesses.some((p) => p[0] >= box[0] && p[0] <= box[2]
806
+ && p[1] >= box[1] && p[1] <= box[3] && pointInRing(p, outer)
807
+ && !holeRings.some((h) => pointInRing(p, h)));
808
+ return { rg, outer, box, backed };
809
+ });
810
+ const kept = entries.filter((e) => e.backed);
811
+ // If numerical sampling cannot witness even one component, preserve the resolver result.
812
+ // The filter is allowed to remove proved source-less artifacts, never all caller geometry.
813
+ if (!kept.length || kept.length === entries.length) return out;
814
+
815
+ const result = kept.map((e) => ({ ...e.rg, holes: [...e.rg.holes] }));
816
+ for (const entry of entries.filter((e) => !e.backed)) for (const hole of entry.rg.holes) {
817
+ const p = hole.start;
818
+ const homes = kept.map((e, i) => ({ e, i })).filter(({ e }) =>
819
+ p[0] >= e.box[0] && p[0] <= e.box[2] && p[1] >= e.box[1] && p[1] <= e.box[3]
820
+ && pointInRing(p, e.outer));
821
+ homes.sort((a, b) => Math.abs(ringArea(a.e.outer)) - Math.abs(ringArea(b.e.outer)));
822
+ if (homes.length) result[homes[0].i].holes.push(hole);
823
+ }
824
+ return result;
825
+ }
826
+
827
+ // Region-in / region-out offset: the engine behind Shape2D.offset on BOTH backends.
828
+ // Fast path: raw per-ring offsets that validate cleanly are returned as-is (lines/arcs
829
+ // exact). Cleanup path: anything dirty or invalid goes through resolveOffsetWinding
830
+ // (contour-winding.js), which computes the positive-winding region of the raw offset
831
+ // outline directly — no boolean engine, no degenerate-shape recovery heuristics. When THAT
832
+ // cannot close its arrangement, chainFallback above retries it a few ways before the failure
833
+ // is allowed to reach the caller.
834
+ export function offsetRegions(regions, delta, { corners = "round" } = {}) {
835
+ if (!["round", "chamfer", "sharp"].includes(corners))
836
+ throw new Error('Shape2D.offset: corners must be "round" | "chamfer" | "sharp"');
837
+ if (!Number.isFinite(delta)) throw new Error("Shape2D.offset: delta must be a finite number");
838
+ if (delta === 0) return JSON.parse(JSON.stringify(regions));
839
+
840
+ const first = rawOffset(regions, delta, corners);
841
+ let out;
842
+ try {
843
+ out = resolveOrRaw(first);
844
+ } catch (err) {
845
+ // Only the unclosable-arrangement failure has a measured degradation behind it. Anything
846
+ // else out of the resolver (a clustering regression, an implicitly-closed ring) is a bug
847
+ // report, not a case to paper over, and goes straight up.
848
+ if (err?.message !== CHAIN_INCOMPLETE_MESSAGE) throw err;
849
+ out = chainFallback(regions, first.raw, delta, corners);
850
+ if (out === null) throw err; // pinned message, unchanged, when nothing works
851
+ }
852
+ out = sourceBackedPositiveRegions(regions, out, delta);
853
+ if (out.length === 0) throw new Error("Shape2D.offset: offset collapses the shape (reduce |delta|)");
854
+ return out;
855
+ }