partforge 0.59.0 → 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.
@@ -8,10 +8,11 @@
8
8
  // The cubic subdivision approach is ported from glenzli/paperjs-offset
9
9
  // (https://github.com/glenzli/paperjs-offset, MIT License, Copyright (c) glenzli),
10
10
  // adapted from paper.js Segments to the partforge contour IR.
11
- import { arcCenterAndSweep, resolveSelfRegions, booleanRegions } from "./paper-bridge.js";
11
+ import { arcCenterAndSweep } from "./paper-bridge.js";
12
12
  import { cubicAt, splitCubic, jointTangents, SMOOTH_JOINT_DEG } from "./contour-ops.js";
13
- import { tessellateContour, closeContourGap, reverseContour } from "./profile.js";
13
+ import { tessellateContour, closeContourGap } from "./profile.js";
14
14
  import { ringArea, pointInRing } from "./shape2d-regions.js";
15
+ import { resolveOffsetWinding, CLUSTER_TOL, CHAIN_INCOMPLETE_MESSAGE } from "./contour-winding.js";
15
16
 
16
17
  export const OFFSET_TOL = 1e-3; // mm — max deviation of a cubic offset approximation
17
18
  const MAX_DEPTH = 12; // cubic subdivision recursion cap
@@ -87,6 +88,14 @@ export function _offsetSegment(from, seg, delta) {
87
88
 
88
89
  const MITER_LIMIT = 2;
89
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
+
90
99
  // Intersection of the line through P (direction u) with the line through Q (direction v).
91
100
  function lineIntersect(P, u, Q, v) {
92
101
  const d = cross(u, v);
@@ -106,7 +115,14 @@ function joinSegs(corner, aEnd, bStart, inTan, outTan, delta, corners) {
106
115
  // round: exact arc about the corner, via on the displacement bisector
107
116
  const d1 = sub(aEnd, corner), d2 = sub(bStart, corner);
108
117
  let m = add(d1, d2);
109
- if (len(m) < 1e-9) m = delta > 0 ? rightOf(norm(d1)) : scl(rightOf(norm(d1)), -1); // 180° turn
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;
110
126
  return [{ via: add(corner, scl(norm(m), Math.abs(delta))), to: bStart }];
111
127
  }
112
128
 
@@ -119,6 +135,13 @@ export function _offsetContour(contour, delta, corners) {
119
135
  const froms = [];
120
136
  { let p = contour.start; for (const s of contour.segments) { froms.push(p); p = s.to; } }
121
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 };
122
145
  // NB: feed jointTangents the KEPT chain's start — if the first segment was dropped
123
146
  // as zero-length, contour.start no longer heads the filtered ring.
124
147
  const joints = jointTangents({ start: fromsKept[0] ?? contour.start, segments: segs });
@@ -126,6 +149,30 @@ export function _offsetContour(contour, delta, corners) {
126
149
  let dirty = pieces.some((p) => p.dirty);
127
150
  const n = segs.length;
128
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
+ }));
129
176
 
130
177
  for (let i = 0; i < n; i++) {
131
178
  const prev = pieces[(i - 1 + n) % n], next = pieces[i];
@@ -134,12 +181,41 @@ export function _offsetContour(contour, delta, corners) {
134
181
  const turn = cross(inTan, outTan);
135
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;
136
183
  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
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.
139
211
  const aSeg = prev.segments.at(-1), bSeg = next.segments[0];
140
212
  if (!aSeg.via && !aSeg.c1 && !bSeg.via && !bSeg.c1) {
141
213
  const X = lineIntersect(aEnd, inTan, bStart, outTan);
142
- if (X) { aSeg.to = X; next.start = X; continue; } // exact trim, stays clean
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
+ }
143
219
  }
144
220
  joins[i] = [{ to: bStart }]; dirty = true;
145
221
  }
@@ -179,7 +255,7 @@ export function _offsetContour(contour, delta, corners) {
179
255
  // the partial-reflection residual: those pieces survive into the ring and validateRawOffset
180
256
  // can't see anything locally wrong with them. Delete them (not un-trim — un-trimming was
181
257
  // 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
258
+ // surviving neighbors with a direct chord, marking the ring dirty so resolveOffsetWinding
183
259
  // gets a chance to untangle whatever that chord leaves behind. Every non-line piece
184
260
  // always survives this pass; the whole-ring gate above already guarantees at least one
185
261
  // piece survives here too.
@@ -191,15 +267,16 @@ export function _offsetContour(contour, delta, corners) {
191
267
  if (keptIdx.length === 0) return { contour: null, dirty: true };
192
268
 
193
269
  // 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
270
+ // join, which resolveOffsetWinding can still untangle downstream, a deleted piece is gone for
195
271
  // good, so a bad deletion can turn perfectly good geometry into a false "offset collapses
196
272
  // the shape" throw (measured: 18 new throws per 3000 random polygons; repro: a 9-gon at
197
273
  // delta -2.79/chamfer with true eroded area 2.76). A raw *piece count* floor doesn't work as
198
274
  // the discriminator — an arc-dominated ring can be legitimately reduced to a single
199
275
  // surviving piece (this file's own storage convention stores a full circle as just two arcs;
200
276
  // 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
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
203
280
  // uninverted self-union is real material, an inverted one that flips positive is a
204
281
  // discarded artifact), but _offsetContour has no idea here whether it's assembling an outer
205
282
  // or a hole, and a perfectly valid CW/negative-area hole ring (like the keyed bore's) reads
@@ -212,7 +289,7 @@ export function _offsetContour(contour, delta, corners) {
212
289
  // when deletion isn't simple: cleanup gets a chance to untangle whatever the un-deleted
213
290
  // piece leaves behind, which is strictly more recoverable than deletion's dead end.
214
291
  const deleted = assembleRing(pieces, joins, keptIdx, n);
215
- const deletedRing = tessellateContour(deleted, VALIDATE_SEGS);
292
+ const deletedRing = sampleRing(deleted, VALIDATE_SEGS);
216
293
  const deletedArea = Math.abs(ringArea(deletedRing));
217
294
  if (deletedArea <= AREA_EPS || ringSelfIntersects(deletedRing))
218
295
  return { contour: assembleRing(pieces, joins, allIdx, n), dirty: true };
@@ -245,18 +322,35 @@ function assembleRing(pieces, joins, idx, n) {
245
322
  return { start, segments: out };
246
323
  }
247
324
 
248
- function segsCross(a1, a2, b1, b2) {
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) {
249
342
  const o = (p, q, r) => Math.sign((q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]));
250
343
  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
344
+ return o1 !== o2 && o3 !== o4;
252
345
  }
253
346
 
254
347
  // 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.
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.
260
354
  function segsOverlap(a1, a2, b1, b2) {
261
355
  const d = sub(a2, a1);
262
356
  const L = len(d);
@@ -294,6 +388,28 @@ const boxesApart = (a, b) =>
294
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;
295
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])];
296
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
+
297
413
  function ringSelfIntersects(ring) {
298
414
  const m = ring.length;
299
415
  const boxes = [];
@@ -302,19 +418,20 @@ function ringSelfIntersects(ring) {
302
418
  if (i === 0 && j === m - 1) continue; // adjacent via wraparound
303
419
  if (boxesApart(boxes[i], boxes[j])) continue;
304
420
  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;
421
+ if (segsIntersect(a1, a2, b1, b2) || segsOverlap(a1, a2, b1, b2)) return true;
306
422
  }
307
423
  return false;
308
424
  }
309
425
 
310
426
  // 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.
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.)
318
435
  function ringsCross(a, b, boxA = ringBox(a), boxB = ringBox(b)) {
319
436
  if (boxesApart(boxA, boxB)) return false;
320
437
  for (let i = 0; i < a.length; i++) {
@@ -324,7 +441,7 @@ function ringsCross(a, b, boxA = ringBox(a), boxB = ringBox(b)) {
324
441
  for (let j = 0; j < b.length; j++) {
325
442
  const b1 = b[j], b2 = b[(j + 1) % b.length];
326
443
  if (boxesApart(bx, segBox(b1, b2))) continue;
327
- if (segsCross(a1, a2, b1, b2) || segsOverlap(a1, a2, b1, b2)) return true;
444
+ if (segsIntersect(a1, a2, b1, b2) || segsOverlap(a1, a2, b1, b2)) return true;
328
445
  }
329
446
  }
330
447
  return false;
@@ -347,8 +464,8 @@ function ringInsideRing(inner, outer, outerBox) {
347
464
  // True when a raw offset result is already valid (fast path). Sampled at VALIDATE_SEGS.
348
465
  export function validateRawOffset(regions) {
349
466
  const sampled = regions.map((rg) => ({
350
- outer: tessellateContour(rg.outer, VALIDATE_SEGS),
351
- holes: rg.holes.map((h) => tessellateContour(h, VALIDATE_SEGS)),
467
+ outer: sampleRing(rg.outer, VALIDATE_SEGS),
468
+ holes: rg.holes.map((h) => sampleRing(h, VALIDATE_SEGS)),
352
469
  }));
353
470
  const allRings = [];
354
471
  for (const rg of sampled) {
@@ -367,112 +484,6 @@ export function validateRawOffset(regions) {
367
484
  return true;
368
485
  }
369
486
 
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
487
  // Part 2 of the partial-reflection fix (task 5B) — REMOVED (review round 2). A global
477
488
  // distance-from-source prune existed here through two review rounds, narrowing its scope each
478
489
  // time (round 1: per-hole rather than whole-region, to stop swallowing legitimate hole
@@ -490,53 +501,355 @@ function cleanupRegions(regions) {
490
501
  // distinguishes "prune this, it's really gone" from "don't prune this, cleanup will recover
491
502
  // it" using only the raw, pre-cleanup ring. The prune's collateral (silently deleting real
492
503
  // 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.
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.)
496
509
 
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));
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;
507
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) {
508
648
  // _offsetContour signals a whole-ring collapse with contour:null (see its comment) — a
509
649
  // 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.
650
+ // drops, raw ends up [] and offsetRegions' "no regions survive" throw fires naturally.
511
651
  let dirty = false;
512
652
  const raw = [];
513
653
  for (const rg of regions) {
514
654
  const o = _offsetContour(closeContourGap(rg.outer), delta, corners);
515
655
  dirty = dirty || o.dirty;
516
656
  if (!o.contour) continue;
517
- const hs = rg.holes.map((h) => _offsetContour(closeContourGap(h), delta, corners));
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
+ });
518
663
  // Only a SURVIVING hole's dirtiness can dirty the result. A hole that collapsed
519
664
  // (contour === null) always reports dirty — that is how _offsetContour signals the drop —
520
665
  // 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.
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.
526
669
  dirty = dirty || hs.some((h) => h.contour && h.dirty);
527
670
  raw.push({ outer: o.contour, holes: hs.filter((h) => h.contour).map((h) => h.contour) });
528
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));
529
839
 
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);
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
539
851
  }
852
+ out = sourceBackedPositiveRegions(regions, out, delta);
540
853
  if (out.length === 0) throw new Error("Shape2D.offset: offset collapses the shape (reduce |delta|)");
541
854
  return out;
542
855
  }