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.
@@ -124,7 +124,13 @@ export function mirrorProfile(input, axis) {
124
124
  export const SMOOTH_JOINT_DEG = 1;
125
125
 
126
126
  // Unit tangent of segment `s` (from `from`) at its start (dir=+1) or end (dir=-1 → arrival direction).
127
- function segTangent(from, s, atStart) {
127
+ // Exported for contour-winding.js's _chain: junction ordering at a curved pinch point needs
128
+ // the exact endpoint tangent, not an approximation (via/c1/c2 are NOT control points that
129
+ // happen to sit near the tangent — via in particular is a THROUGH point near mid-sweep, so
130
+ // from->via is systematically biased by about sweep/4). This recovers it exactly for arcs
131
+ // (perpendicular to the radius at the recovered center, oriented by the sweep's sign) and
132
+ // cubics (including the degenerate c1===from case, where the true tangent comes from c2).
133
+ export function segTangent(from, s, atStart) {
128
134
  const norm = ([x, y]) => { const L = Math.hypot(x, y) || 1; return [x / L, y / L]; };
129
135
  if (s.c1) {
130
136
  if (atStart) {
@@ -281,7 +287,7 @@ function curveEvaluator(from, seg) {
281
287
  // of its own parameterization, returning {from, seg} for the kept portion.
282
288
  // Cubic: two exact de Casteljau splits. Arc: angle interpolation, `via`
283
289
  // recomputed at the kept sweep's angular midpoint. Line: trivial endpoints.
284
- function trimSegment(from, seg, tStart, tEnd) {
290
+ export function trimSegment(from, seg, tStart, tEnd) {
285
291
  if (seg.c1) {
286
292
  let cur = { p0: from, c1: seg.c1, c2: seg.c2, p1: seg.to };
287
293
  if (tStart > 1e-12) { cur = splitCubic(cur.p0, cur.c1, cur.c2, cur.p1, tStart)[1]; }
@@ -0,0 +1,610 @@
1
+ // Winding resolution for offset outlines — the cleanup path of contour-offset.js.
2
+ //
3
+ // The correct result of an offset is the POSITIVE WINDING REGION (w >= 1) of the raw
4
+ // offset outline — the same fill rule Clipper2's ClipperOffset uses (FillRule::Positive),
5
+ // the oracle this feature is measured against. Self-overlap loops, collapsed holes,
6
+ // unmerged seams and pinched necks are all the same failure: approximating that rule with
7
+ // booleans instead of computing it. This module computes it: find crossings (paper's curve
8
+ // clipper), split each ring there, keep a piece iff its two sides straddle the fill
9
+ // boundary (one side filled, the other not), chain the survivors, and emit the ORIGINAL
10
+ // curves trimmed at the crossing parameters. `_classify` itself stays a general classifier
11
+ // parameterized by a fill rule (see its own comment); the positive rule is chosen by
12
+ // `resolveOffsetWinding`, this module's entry point. Spans that several rings run along at
13
+ // once — collinear edges, arcs sharing a circle — are handled by `_coincidence` below.
14
+ //
15
+ // Pure leaf in the worker graph: DOM-free, three-free, node:-free.
16
+ import { ringCrossings } from "./paper-bridge.js";
17
+ import { trimSegment, segTangent } from "./contour-ops.js";
18
+ import { tessellateContour, closeContourGap, reverseContour } from "./profile.js";
19
+ import { assembleRegions, ringArea } from "./shape2d-regions.js";
20
+
21
+ // Crossings closer than this are one vertex. Derived: it must exceed OFFSET_TOL (1e-3 mm,
22
+ // the cubic-offset approximation error) or two crossings that are genuinely the same point
23
+ // on an approximated curve stay split; and it must stay far below the thinnest feature the
24
+ // engine is expected to keep. 5x OFFSET_TOL sits an order below a 0.05 mm feature.
25
+ export const CLUSTER_TOL = 5e-3;
26
+
27
+ // Tessellation density for the winding-probe geometry: the raw offset outline sampled
28
+ // for _windingAt, and each piece sampled by pieceSamples to find interior locators.
29
+ // Declared here (not near _classify) because pieceSamples needs it earlier in this file.
30
+ export const WINDING_SEGS = 64;
31
+
32
+ const dist = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]);
33
+
34
+ // Assign every crossing a pool vertex, merging any within `tol`. Greedy against the pool:
35
+ // crossings are few (tens per ring) so the O(n·pool) scan is not worth indexing.
36
+ export function _mergeCrossings(crossings, tol = CLUSTER_TOL) {
37
+ const pool = [];
38
+ const members = [];
39
+ const out = crossings.map((x) => {
40
+ let v = pool.findIndex((p) => dist(p, x.point) <= tol);
41
+ if (v === -1) { v = pool.length; pool.push([x.point[0], x.point[1]]); members.push([]); }
42
+ members[v].push(x.point);
43
+ return { ...x, vertex: v };
44
+ });
45
+ // settle each pooled vertex on its cluster centroid so the shared position is unbiased.
46
+ // Membership above is assigned against a fixed anchor (the first member found within
47
+ // tol), not the eventual centroid, so a cluster's diameter is bounded at 2*tol — a
48
+ // member can end up slightly further than tol from the final centroid. Benign: `vertex`
49
+ // is used only as an identity for chaining, never as a distance check against pool[v].
50
+ for (let v = 0; v < pool.length; v++) {
51
+ const m = members[v];
52
+ pool[v] = [m.reduce((s, p) => s + p[0], 0) / m.length, m.reduce((s, p) => s + p[1], 0) / m.length];
53
+ }
54
+ return { crossings: out, pool };
55
+ }
56
+
57
+ // Point on a ring at (segment, t) — the ring's own parameterization.
58
+ const ringPoints = (contour) => [contour.start, ...contour.segments.map((s) => s.to)];
59
+
60
+ // Split each ring at its merged crossings into pieces. Crossings are sorted along the
61
+ // ring by (seg, t); consecutive pairs bound a piece, wrapping at the end. Each piece is
62
+ // materialized immediately as trimmed IR segments via trimSegment, so provenance never
63
+ // has to be carried further — a trimmed arc is still an arc.
64
+ export function _splitRings(rings, merged) {
65
+ const byRing = rings.map(() => []);
66
+ for (const x of merged.crossings) {
67
+ // A crossing with seg === contour.segments.length is on the closing curve toPaperPath's
68
+ // closePath() synthesizes for a ring that never explicitly returns to its own start (see
69
+ // its own comment, and irTime's matching seg===n branch in paper-bridge.js). Every ring
70
+ // this module receives from contour-offset.js is explicitly closed (assembleRing /
71
+ // closeContourGap both guarantee a real closing segment back to `start`), so that branch
72
+ // is unreachable in practice — but `resolveOffsetWinding` is reachable from a PUBLIC entry
73
+ // point (offsetRegions), so a future caller feeding an implicitly-closed ring must fail
74
+ // loudly here rather than silently wrapping `k % n` back onto segment 0 below (a wrong
75
+ // segment for that crossing, not merely an imprecise one).
76
+ if (x.seg >= rings[x.ring].segments.length) {
77
+ throw new Error("_splitRings: crossing lands on an implicit ring closure — every ring must be explicitly closed before resolveOffsetWinding");
78
+ }
79
+ byRing[x.ring].push(x);
80
+ }
81
+ const pieces = [];
82
+
83
+ rings.forEach((contour, r) => {
84
+ const pts = ringPoints(contour);
85
+ // Sorted along the ring, then collapsed where two records are the SAME POSITION on it.
86
+ // ringCrossings reports a crossing once per ring PAIR, so a point three or more rings
87
+ // pass through (four features offset until their corners meet — see the coincidence
88
+ // block below for the two-ring sibling of this) comes back twice or more on the same
89
+ // ring with identical (seg, t). Left in, `emit` reads the run between two such records
90
+ // as b.t <= a.t, i.e. "wrap all the way around", and emits the WHOLE RING as an extra
91
+ // piece — no error, just a grossly wrong duplicate boundary in the output. Position, not
92
+ // pooled vertex, is the right key: a ring that touches ITSELF visits one pooled vertex
93
+ // twice at genuinely different (seg, t), and both visits are needed to split the loop.
94
+ const xs = byRing[r].slice().sort((a, b) => (a.seg - b.seg) || (a.t - b.t))
95
+ .filter((x, i, all) => i === 0 || x.seg !== all[i - 1].seg || Math.abs(x.t - all[i - 1].t) > 1e-12);
96
+ if (xs.length === 0) {
97
+ pieces.push({ ring: r, from: [contour.start[0], contour.start[1]],
98
+ segs: contour.segments.map((s) => ({ ...s })), vStart: null, vEnd: null });
99
+ return;
100
+ }
101
+ // emit the run from crossing a to crossing b (b may wrap past the ring end)
102
+ const emit = (a, b) => {
103
+ const segs = [];
104
+ let from = merged.pool[a.vertex];
105
+ const spanEnd = b.seg + (b.seg < a.seg || (b.seg === a.seg && b.t <= a.t)
106
+ ? contour.segments.length : 0);
107
+ for (let k = a.seg; k <= spanEnd; k++) {
108
+ const i = k % contour.segments.length;
109
+ const seg = contour.segments[i];
110
+ const tS = k === a.seg ? a.t : 0;
111
+ const tE = k === spanEnd ? b.t : 1;
112
+ if (tE - tS <= 1e-12) continue;
113
+ segs.push(trimSegment(pts[i], seg, tS, tE).seg);
114
+ }
115
+ if (segs.length === 0) {
116
+ // The only legitimate reason a run trims to nothing is a crossing pair that
117
+ // already collapsed onto the same pooled vertex (a===b). Anything else means
118
+ // clustering merged two crossings that should have stayed distinct, silently
119
+ // dropping a run — fail here, at the source, not downstream as a broken chain.
120
+ if (a.vertex !== b.vertex) {
121
+ throw new Error(`_splitRings: degenerate run between distinct vertices ${a.vertex} and ${b.vertex} — clustering regression`);
122
+ }
123
+ return;
124
+ }
125
+ // The endpoint snap below is exact only for the bookkeeping: `from`/`to` are pool
126
+ // coordinates shared bit-for-bit by both pieces meeting at a vertex, which is what
127
+ // makes chaining reliable. It is NOT true of the curve shape near that joint — c1/c2
128
+ // (cubics) and via (arcs) are carried through trimSegment unchanged, not re-derived
129
+ // from the snapped endpoint, so a trimmed curve can deviate from the snapped position
130
+ // by up to 2*CLUSTER_TOL (the cluster-diameter bound documented above _mergeCrossings'
131
+ // loop). For an arc this deviation is not confined to the seam either: overwriting
132
+ // `to` while leaving `via`/`from` untouched defines a slightly different circle, so
133
+ // the error is distributed along the whole trimmed arc, not just at the joint. Fine
134
+ // for winding classification and re-chaining, just not bit-for-bit.
135
+ segs[segs.length - 1].to = [merged.pool[b.vertex][0], merged.pool[b.vertex][1]]; // snap to the shared vertex
136
+ pieces.push({ ring: r, from: [from[0], from[1]], segs, vStart: a.vertex, vEnd: b.vertex });
137
+ };
138
+ for (let i = 0; i < xs.length; i++) emit(xs[i], xs[(i + 1) % xs.length]);
139
+ });
140
+ return pieces;
141
+ }
142
+
143
+ // --- coincident (collinear-overlap) pieces ------------------------------------------
144
+ //
145
+ // Two rings can run along the SAME curve over a shared span — two features offset outward
146
+ // with `corners: "sharp"` until their straight flanks meet is the everyday case, and two
147
+ // arcs can equally share a span of one circle. paper's getIntersections DOES report those:
148
+ // Curve.getOverlaps returns both ends of the overlapped span as ordinary intersections on
149
+ // both curves, so the arrangement is complete and _splitRings already cuts there, leaving
150
+ // two (or more) pieces occupying the same span.
151
+ //
152
+ // What such input breaks is the wRight = wLeft - 1 derivation in _classify. That identity
153
+ // says "crossing a directed edge changes the winding by exactly 1", which is false where k
154
+ // directed edges lie on top of each other: the winding jumps by their NET count. Two
155
+ // same-direction copies (the sharp-corner case) give a doubled boundary — w goes 0 -> 2
156
+ // across it, so the true wRight is wLeft - 2 and the old derivation read wRight = 1, i.e.
157
+ // "material on both sides", dropping a piece the offset boundary needs. Opposite-direction
158
+ // copies CANCEL: net 0, wRight === wLeft, and the span really is interior to the fill
159
+ // (an eroded hole that grew onto its own outer's edge does this).
160
+ //
161
+ // So this reports, per piece, the net multiplicity in that piece's OWN direction, plus a
162
+ // `duplicate` flag for the copies that are not the group's representative. The winding
163
+ // PROBE is left alone: _windingAt ray-casts every ring, which already counts a doubled edge
164
+ // twice and so returns the true winding of the face it lands in. Only the arithmetic
165
+ // derivation of the far side was wrong — measurement stays, bookkeeping is fixed. Keeping
166
+ // exactly one representative (rather than excluding duplicates from the ray-cast set) is
167
+ // what the emitted boundary needs anyway: chaining must traverse the shared span once.
168
+
169
+ // Interior sample points shared by coincidence matching and adaptive winding probes. Taken
170
+ // at symmetric fractions of arc length, so a piece traversed the other way samples the same
171
+ // points in reverse order and coincidence can compare both orders. Probe callers additionally
172
+ // move a sample off an exact contour vertex, where the incident tangent would be ambiguous.
173
+ const INTERIOR_SAMPLES = 5;
174
+
175
+ function pieceSamples(piece, { avoidVertices = false } = {}) {
176
+ const poly = tessellateContour({ start: piece.from, segments: piece.segs }, WINDING_SEGS);
177
+ const cum = [0];
178
+ for (let i = 1; i < poly.length; i++)
179
+ cum.push(cum[i - 1] + Math.hypot(poly[i][0] - poly[i - 1][0], poly[i][1] - poly[i - 1][1]));
180
+ const total = cum[cum.length - 1];
181
+ const pts = [];
182
+ for (let k = 1; k <= INTERIOR_SAMPLES; k++) {
183
+ const target = (total * k) / (INTERIOR_SAMPLES + 1);
184
+ let i = 1;
185
+ while (i < cum.length - 1 && cum[i] < target) i++;
186
+ const span = cum[i] - cum[i - 1];
187
+ let f = span > 1e-15 ? (target - cum[i - 1]) / span : 0;
188
+ // A probe locator exactly on a contour vertex has two valid incident tangents. Which
189
+ // edge projectToRing wins is then an iteration-order accident, and offsetting along that
190
+ // edge's normal can leave the polygon immediately (a square's 50%-of-perimeter point is
191
+ // the simplest reproduction). Move only probe locators to the containing edge's interior.
192
+ // Coincidence matching keeps the exact symmetric fractions it historically used.
193
+ if (avoidVertices && (f <= 1e-9 || f >= 1 - 1e-9)) f = 0.5;
194
+ pts.push([poly[i - 1][0] + f * (poly[i][0] - poly[i - 1][0]),
195
+ poly[i - 1][1] + f * (poly[i][1] - poly[i - 1][1])]);
196
+ }
197
+ return { pts, len: total };
198
+ }
199
+
200
+ const maxDist = (a, b) => a.reduce((m, p, i) => Math.max(m, dist(p, b[i])), 0);
201
+
202
+ // +1 if b traces the same curve as a in the same direction, -1 if it traces it backwards,
203
+ // 0 if the two are different curves. Length first (cheap rejection), then the sampled points
204
+ // in both orders. When BOTH orders match the samples are palindromic (only possible for a
205
+ // piece that doubles back on itself); fall back to the pieces' shared pool vertices.
206
+ function coincidenceSign(A, B, a, b, tol) {
207
+ if (Math.abs(A.len - B.len) > tol) return 0;
208
+ const fOK = maxDist(A.pts, B.pts) <= tol;
209
+ const rOK = maxDist(A.pts, [...B.pts].reverse()) <= tol;
210
+ if (fOK && rOK) return (a.vStart === b.vStart && a.vEnd === b.vEnd) ? 1 : -1;
211
+ return fOK ? 1 : (rOK ? -1 : 0);
212
+ }
213
+
214
+ // Per piece: `mult` (net count of coincident directed pieces along that piece's span,
215
+ // measured in its own direction — 1 for the ordinary case of a piece nothing else lies on,
216
+ // 0 when the group cancels) and `duplicate` (a copy the group already has a representative
217
+ // for; dropped without probing).
218
+ //
219
+ // Only pieces sharing BOTH pool vertices can be coincident, which is what makes this cheap:
220
+ // the arrangement is split at the overlap's ends on every ring involved, so the copies come
221
+ // out with identical endpoints by construction. Coincidence is then confirmed GEOMETRICALLY
222
+ // (sampled points), not from the vertex pair alone — two different curves between the same
223
+ // pair of crossings (a lens) share endpoints without sharing a span. tol is CLUSTER_TOL, the
224
+ // module's "same point" scale; a lens thinner than that has no area worth keeping.
225
+ export function _coincidence(pieces, tol = CLUSTER_TOL) {
226
+ const mult = new Array(pieces.length).fill(1);
227
+ const duplicate = new Array(pieces.length).fill(false);
228
+ const buckets = new Map();
229
+ pieces.forEach((p, i) => {
230
+ if (p.vStart === null || p.vEnd === null) return; // uncrossed whole ring: nothing to pair with
231
+ const key = p.vStart <= p.vEnd ? `${p.vStart}:${p.vEnd}` : `${p.vEnd}:${p.vStart}`;
232
+ if (!buckets.has(key)) buckets.set(key, []);
233
+ buckets.get(key).push(i);
234
+ });
235
+ const cache = new Map();
236
+ const S = (i) => { if (!cache.has(i)) cache.set(i, pieceSamples(pieces[i])); return cache.get(i); };
237
+ for (const idxs of buckets.values()) {
238
+ if (idxs.length < 2) continue;
239
+ const taken = new Set();
240
+ for (const a of idxs) {
241
+ if (taken.has(a)) continue;
242
+ taken.add(a);
243
+ const group = [{ i: a, sign: 1 }];
244
+ for (const b of idxs) {
245
+ if (taken.has(b)) continue;
246
+ const sign = coincidenceSign(S(a), S(b), pieces[a], pieces[b], tol);
247
+ if (sign !== 0) { group.push({ i: b, sign }); taken.add(b); }
248
+ }
249
+ if (group.length < 2) continue;
250
+ const net = group.reduce((t, g) => t + g.sign, 0);
251
+ if (net === 0) {
252
+ // The group cancels: winding is identical on both sides, so no piece here bounds a
253
+ // face. Left for _classify's straddle test to drop (mult 0 → wRight === wLeft)
254
+ // rather than flagged as a duplicate — cancellation is a winding fact, not redundancy.
255
+ for (const g of group) mult[g.i] = 0;
256
+ continue;
257
+ }
258
+ const repSign = Math.sign(net);
259
+ const rep = group.find((g) => g.sign === repSign).i;
260
+ for (const g of group) { duplicate[g.i] = g.i !== rep; mult[g.i] = g.i === rep ? Math.abs(net) : 0; }
261
+ }
262
+ }
263
+ return { mult, duplicate };
264
+ }
265
+
266
+ // Probe offset for the winding query. PROBE_EPS has a CEILING and no floor. The ceiling is
267
+ // set by NEIGHBOURING geometry: too large a probe risks crossing into an unrelated nearby
268
+ // piece or feature. A smaller probe is never the risk: the probe origin is anchored on
269
+ // tessRings[piece.ring] itself (via projectToRing), the same UNSNAPPED polyline _windingAt
270
+ // queries below, so the endpoint snap in _splitRings cannot misplace it — `eps` legitimately
271
+ // shrinks to as little as 1e-9 for a short piece (see the length-proportional scaling in
272
+ // _classify) and still classifies correctly. CLUSTER_TOL*2 sits comfortably below any
273
+ // feature worth keeping.
274
+ export const PROBE_EPS = CLUSTER_TOL * 2;
275
+
276
+ // Below this length a piece carries no reliable direction to probe along — the endpoint
277
+ // snap in _splitRings can collapse a short trimmed run to (near-)zero length without
278
+ // tripping the `segs.length === 0` guard there (distinct vertices, near-identical pool
279
+ // positions). Probing it would place the origin ON the boundary itself, and the result
280
+ // would be whichever side the half-open ray rule happens to pick — not a measurement.
281
+ // Dropped rather than kept with an arbitrary, unverifiable orientation.
282
+ const MIN_PIECE_LEN = 1e-9;
283
+
284
+ const pointEdgeDistance = (p, a, b) => {
285
+ const ex = b[0] - a[0], ey = b[1] - a[1];
286
+ const L2 = ex * ex + ey * ey;
287
+ let t = L2 > 1e-18 ? ((p[0] - a[0]) * ex + (p[1] - a[1]) * ey) / L2 : 0;
288
+ if (t < 0) t = 0; else if (t > 1) t = 1;
289
+ return Math.hypot(p[0] - (a[0] + t * ex), p[1] - (a[1] + t * ey));
290
+ };
291
+
292
+ // One arrangement scan can answer the winding query at `p` and, when `near` is supplied,
293
+ // measure how much room the probe's boundary anchor has before it reaches any other edge.
294
+ // The projected source edge and its immediate neighbours are incident geometry, not an
295
+ // obstruction. Everything else participates, including another ring: glyph dilation brings
296
+ // formerly-disjoint contours together, so inter-ring crowding is one of the production cases
297
+ // this measurement exists to detect.
298
+ function scanArrangement(p, tessRings, near = null) {
299
+ let w = 0;
300
+ let clearance = Infinity;
301
+ for (let r = 0; r < tessRings.length; r++) {
302
+ const ring = tessRings[r];
303
+ for (let i = 0; i < ring.length; i++) {
304
+ const a = ring[i], b = ring[(i + 1) % ring.length];
305
+ const side = (b[0] - a[0]) * (p[1] - a[1]) - (p[0] - a[0]) * (b[1] - a[1]);
306
+ if (a[1] <= p[1]) { if (b[1] > p[1] && side > 0) w++; }
307
+ else if (b[1] <= p[1] && side < 0) w--;
308
+
309
+ if (near) {
310
+ const n = ring.length;
311
+ const delta = r === near.ring ? (i - near.edge + n) % n : -1;
312
+ const incident = r === near.ring && (delta === 0 || delta === 1 || delta === n - 1);
313
+ if (!incident) clearance = Math.min(clearance, pointEdgeDistance(near.point, a, b));
314
+ }
315
+ }
316
+ }
317
+ return { w, clearance };
318
+ }
319
+
320
+ // Signed crossing count of a +x ray from p against tessellated rings. Standard
321
+ // half-open rule (a[1] <= p[1] < b[1]) so a vertex is counted exactly once.
322
+ export function _windingAt(p, tessRings) { return scanArrangement(p, tessRings).w; }
323
+
324
+ // Project p onto the nearest edge of `ring` — a tessellated point ring — and return that
325
+ // point together with the edge's unit direction. This is what anchors a winding probe
326
+ // exactly on the polyline `_windingAt` queries, by construction, at any radius: the
327
+ // projected point IS a point on that polyline (up to floating-point rounding), so offsetting
328
+ // it by PROBE_EPS along the edge normal cannot straddle the wrong side of a tessellation gap
329
+ // the way offsetting an independently-sampled locator (pieceSamples') can. Also gives the
330
+ // left/right sense from the SAME edge the origin came from, so origin and normal agree.
331
+ // This is load-bearing on arcs: a short trimmed piece is tessellated more finely than the
332
+ // source ring segment it came from, and the ring chord's sagitta (~1.2e-3*r here) already
333
+ // exceeds PROBE_EPS above r≈8.3 mm.
334
+ //
335
+ // KNOWN, BOUNDED imprecision: this scans the WHOLE ring for the nearest edge, so on a thin
336
+ // crescent (two ring branches running close and antiparallel) it can legitimately latch onto
337
+ // the wrong branch — measured 176/840 pieces on a thin-crescent fixture. It causes zero
338
+ // misclassifications there because origin and normal are read off the SAME (wrong) edge:
339
+ // picking the antiparallel branch flips the edge direction, which flips `dir`, which flips
340
+ // which side of the probed point counts as "left" — the two flips cancel and `_classify`'s
341
+ // wLeft/wRight bookkeeping comes out the same as if the correct branch had been picked.
342
+ function projectToRing(p, ring) {
343
+ let best = null;
344
+ for (let i = 0; i < ring.length; i++) {
345
+ const a = ring[i], b = ring[(i + 1) % ring.length];
346
+ const ex = b[0] - a[0], ey = b[1] - a[1];
347
+ const L2 = ex * ex + ey * ey;
348
+ let t = L2 > 1e-18 ? ((p[0] - a[0]) * ex + (p[1] - a[1]) * ey) / L2 : 0;
349
+ if (t < 0) t = 0; else if (t > 1) t = 1;
350
+ const point = [a[0] + t * ex, a[1] + t * ey];
351
+ const dx = p[0] - point[0], dy = p[1] - point[1];
352
+ const d2 = dx * dx + dy * dy;
353
+ if (best === null || d2 < best.d2) {
354
+ const L = Math.sqrt(L2) || 1;
355
+ best = { point, dir: [ex / L, ey / L], edge: i, d2 };
356
+ }
357
+ }
358
+ return best;
359
+ }
360
+
361
+ // Keep a piece iff its two sides straddle the boundary of the caller's FILL RULE — the
362
+ // `inside` predicate, which maps a winding number to "is this face filled". The default is
363
+ // nonzero fill (w !== 0), under which this reports both the w=0/w=1 boundary (interior on
364
+ // the left, kept as-is) and the w=0/w=-1 boundary (interior on the right, kept REVERSED so
365
+ // the emitted piece is canonically oriented interior-on-left). `resolveOffsetWinding` passes
366
+ // the POSITIVE rule (w >= 1) instead — see its own comment for why offset output wants that
367
+ // one — and under it `reverse` can never be true, since the right side's winding is always
368
+ // the lower of the two.
369
+ //
370
+ // Crossing a directed edge changes the winding number by exactly ±1, so ONE probe
371
+ // suffices: with the interior of a CCW ring on its left, wLeft = wRight + 1 identically.
372
+ // A second probe is not merely redundant but harmful — two independent probes can
373
+ // disagree (both reading "inside") when either lands badly, and there is no way to tell
374
+ // which is wrong. Deriving the far side arithmetically makes the two consistent by
375
+ // construction. The one exception is a span several rings run along at once, where the jump
376
+ // is the NET number of coincident directed edges rather than 1 — `_coincidence` above
377
+ // measures that, and it is the `mult` subtracted below; every other piece has mult 1 and the
378
+ // classic identity back. (With the default nonzero rule and mult 1 the keep test is exactly
379
+ // the historical wLeft ∈ {0, 1}, and reverse exactly wLeft === 0.)
380
+ //
381
+ // Every candidate probe origin is a pieceSamples point PROJECTED onto
382
+ // tessRings[piece.ring] (projectToRing) — the same polyline _windingAt below queries — not
383
+ // the true curve the locator approximates; see projectToRing for why that is load-bearing.
384
+ //
385
+ // The ±mult invariant above (wLeft = wRight + mult) is a property of an actual probe: it
386
+ // holds for every record that reaches the probe below. The early-return branches never probe
387
+ // at all — the piece is dropped for having no reliable direction to probe along, or for being
388
+ // a redundant copy of a coincident piece already represented — so their debug record reports
389
+ // wLeft: null, wRight: null rather than fabricating a pair that would satisfy the arithmetic
390
+ // without a probe behind it. Callers that assert the invariant in debug mode (see
391
+ // test/contour-winding.test.js) must therefore do so only over records with a non-null wLeft,
392
+ // not over every record `_classify` returns.
393
+ export function _classify(pieces, tessRings, { debug = false, inside = (w) => w !== 0 } = {}) {
394
+ const { mult, duplicate } = _coincidence(pieces);
395
+ return pieces.map((piece, i) => {
396
+ if (duplicate[i]) {
397
+ // A coincident copy whose group representative carries the span (see _coincidence):
398
+ // emitting it too would trace the shared span more than once.
399
+ const rec = { piece, keep: false, reverse: false };
400
+ return debug ? { ...rec, wLeft: null, wRight: null } : rec;
401
+ }
402
+ if (piece.segs.length === 0) {
403
+ // Unreachable from _splitRings (every emitted piece has >=1 seg), but _classify is
404
+ // exported and a hand-built `segs: []` piece would otherwise throw inside pieceSamples
405
+ // (poly.length < 2) before MIN_PIECE_LEN gets a chance to reject it. Same outcome as
406
+ // the zero-length case below: drop it, don't guess an orientation.
407
+ const rec = { piece, keep: false, reverse: false };
408
+ return debug ? { ...rec, wLeft: null, wRight: null } : rec;
409
+ }
410
+ const { pts, len } = pieceSamples(piece, { avoidVertices: true });
411
+ if (len < MIN_PIECE_LEN) {
412
+ const rec = { piece, keep: false, reverse: false };
413
+ return debug ? { ...rec, wLeft: null, wRight: null } : rec;
414
+ }
415
+ // Start at the midpoint, preserving the old clean path. Its arrangement scan measures
416
+ // winding and local clearance together; if the full probe fits with a 4x safety margin,
417
+ // no other sample can improve the classification and the historical one-scan cost stays.
418
+ // At a contested midpoint, examine the other fixed arc-length samples and choose the one
419
+ // with greatest clearance. Selection is geometry-only — never based on keep, chainability,
420
+ // or an oracle answer — so a buried/interior edge cannot shop around for a winding it likes.
421
+ const maxEps = Math.min(PROBE_EPS, Math.max(len / 4, 1e-9));
422
+ const candidate = (point) => {
423
+ const projected = projectToRing(point, tessRings[piece.ring]);
424
+ const left = [projected.point[0] - projected.dir[1] * maxEps,
425
+ projected.point[1] + projected.dir[0] * maxEps];
426
+ const scan = scanArrangement(left, tessRings,
427
+ { point: projected.point, ring: piece.ring, edge: projected.edge });
428
+ return { ...projected, wLeft: scan.w, clearance: scan.clearance };
429
+ };
430
+ const mid = Math.floor(pts.length / 2);
431
+ let probe = candidate(pts[mid]);
432
+ if (probe.clearance < maxEps * 4) {
433
+ for (let j = 0; j < pts.length; j++) {
434
+ if (j === mid) continue;
435
+ const next = candidate(pts[j]);
436
+ if (next.clearance > probe.clearance) probe = next;
437
+ }
438
+ }
439
+ const eps = Math.min(maxEps, Math.max(probe.clearance / 4, 1e-9));
440
+ let wLeft = probe.wLeft;
441
+ if (eps !== maxEps) {
442
+ const left = [probe.point[0] - probe.dir[1] * eps, probe.point[1] + probe.dir[0] * eps];
443
+ wLeft = _windingAt(left, tessRings);
444
+ }
445
+ const wRight = wLeft - mult[i];
446
+ const inL = inside(wLeft), inR = inside(wRight);
447
+ const keep = inL !== inR;
448
+ const rec = { piece, keep, reverse: keep && !inL };
449
+ return debug ? { ...rec, wLeft, wRight } : rec;
450
+ });
451
+ }
452
+
453
+ const reversePieceSegs = (piece) => {
454
+ // reverse a piece's segment run, mirroring reverseContour's per-kind handling
455
+ const pts = [piece.from, ...piece.segs.map((s) => s.to)];
456
+ const segs = [];
457
+ for (let i = piece.segs.length - 1; i >= 0; i--) {
458
+ const s = piece.segs[i];
459
+ const m = { to: [pts[i][0], pts[i][1]] };
460
+ if (s.via) m.via = [s.via[0], s.via[1]];
461
+ if (s.c1) { m.c1 = [s.c2[0], s.c2[1]]; m.c2 = [s.c1[0], s.c1[1]]; }
462
+ segs.push(m);
463
+ }
464
+ return { from: pts[pts.length - 1], segs, vStart: piece.vEnd, vEnd: piece.vStart };
465
+ };
466
+
467
+ // Direction a piece LEAVES its start vertex: the EXACT tangent at that end, via
468
+ // contour-ops.js's segTangent — not a hand-rolled approximation. Round 1 used the raw
469
+ // chord; a from->c1/from->via shortcut is exact for cubics (modulo the c1===from
470
+ // degenerate case) but still biased for arcs — `via` is a THROUGH point near mid-sweep,
471
+ // not a control point, so from->via is systematically off by about sweep/4 (worse the
472
+ // larger the sweep, and this engine's round joins emit sweeps up to ~180deg at a spike;
473
+ // see contour-offset.js). segTangent recovers the arc's true center and returns the
474
+ // tangent perpendicular to the radius, exact at both ends, and also resolves the
475
+ // degenerate cubic correctly (c1===from → tangent comes from c2, not the chord).
476
+ const dirOut = (p) => { const [x, y] = segTangent(p.from, p.segs[0], true); return Math.atan2(y, x); };
477
+
478
+ // Direction a piece ARRIVES at its end vertex: the exact tangent on the LAST segment,
479
+ // computed from where that segment itself begins (segTangent needs a segment's own
480
+ // `from` to recover an arc's center — not some earlier point in the piece). Mirrors
481
+ // dirOut's reasoning at the other end.
482
+ const dirIn = (p) => {
483
+ const pts = [p.from, ...p.segs.map((s) => s.to)];
484
+ const lastFrom = pts[pts.length - 2];
485
+ const [x, y] = segTangent(lastFrom, p.segs[p.segs.length - 1], false);
486
+ return Math.atan2(y, x);
487
+ };
488
+
489
+ // The literal message every unclosable-arrangement site in _chain throws, and the string
490
+ // ERROR-PATTERNS.md § shape2d-offset-winding-chain-incomplete documents. Exported so
491
+ // contour-offset.js's fallback ladder can recognise THIS failure — the one it has a
492
+ // documented, measured degradation for — without pattern-matching on message text at a
493
+ // distance, and without swallowing an unrelated throw from the same call.
494
+ export const CHAIN_INCOMPLETE_MESSAGE =
495
+ "contour-winding: could not chain offset boundary (incomplete intersection set)";
496
+
497
+ // Join kept pieces end-to-end by SHARED POOL VERTEX identity — never coordinate
498
+ // comparison, which is what makes this exact. A junction with several outgoing pieces
499
+ // (a pinch point) takes the LEFTMOST turn: the smallest positive rotation from the
500
+ // reversed inbound direction, i.e. the most counter-clockwise turn relative to the
501
+ // direction of travel — the standard planar-arrangement rule for tracing an outer
502
+ // boundary consistently. A literal U-turn (straight back the way we arrived) rotates by
503
+ // exactly 0, the minimum possible, so it is the FIRST-preferred candidate, not a last
504
+ // resort — it is only actually taken when it's the sole outgoing option (a degree-1
505
+ // vertex), which is the standard DCEL `next = twin` behavior.
506
+ export function _chain(classified, pool) {
507
+ const kept = classified.filter((c) => c.keep)
508
+ .map((c) => (c.reverse ? reversePieceSegs(c.piece) : { from: c.piece.from, segs: c.piece.segs,
509
+ vStart: c.piece.vStart, vEnd: c.piece.vEnd }));
510
+ const closed = kept.filter((p) => p.vStart === null); // uncrossed whole rings
511
+ const open = kept.filter((p) => p.vStart !== null);
512
+ const out = closed.map((p) => ({ start: [p.from[0], p.from[1]], segs: p.segs }));
513
+
514
+ const outgoing = new Map();
515
+ open.forEach((p, i) => { if (!outgoing.has(p.vStart)) outgoing.set(p.vStart, []); outgoing.get(p.vStart).push(i); });
516
+ const used = new Array(open.length).fill(false);
517
+
518
+ for (let s = 0; s < open.length; s++) {
519
+ if (used[s]) continue;
520
+ const startV = open[s].vStart;
521
+ let cur = s, guard = 0;
522
+ const chainSegs = [];
523
+ // The pool vertex, not the piece's own `from` — identical by the _splitRings snap
524
+ // invariant, but this is the canonical identity, and it's what a hand-built fixture
525
+ // (as in the test suite) is on the hook to keep consistent, not this function.
526
+ const startPt = [pool[startV][0], pool[startV][1]];
527
+ for (;;) {
528
+ if (guard++ > open.length + 1) throw new Error(CHAIN_INCOMPLETE_MESSAGE);
529
+ used[cur] = true;
530
+ chainSegs.push(...open[cur].segs);
531
+ const at = open[cur].vEnd;
532
+ if (at === startV) break;
533
+ const cands = (outgoing.get(at) ?? []).filter((i) => !used[i]);
534
+ if (cands.length === 0) throw new Error(CHAIN_INCOMPLETE_MESSAGE);
535
+ const inDir = dirIn(open[cur]);
536
+ // leftmost turn: smallest positive rotation from the reversed inbound direction
537
+ cur = cands.reduce((best, i) => {
538
+ const turn = (x) => { let a = inDir + Math.PI - dirOut(open[x]); a %= 2 * Math.PI; return a < 0 ? a + 2 * Math.PI : a; };
539
+ return turn(i) < turn(best) ? i : best;
540
+ }, cands[0]);
541
+ }
542
+ out.push({ start: startPt, segs: chainSegs });
543
+ }
544
+
545
+ if (used.some((u) => !u)) throw new Error(CHAIN_INCOMPLETE_MESSAGE);
546
+ return out.map(({ start, segs }) => {
547
+ const last = segs[segs.length - 1].to;
548
+ if (Math.hypot(last[0] - start[0], last[1] - start[1]) <= 1e-9) {
549
+ // copy before mutating: `segs` here can be a kept piece's OWN segs array (the
550
+ // uncrossed-whole-ring path above assigns `segs: p.segs` with no copy), so writing
551
+ // into segs[last] in place would mutate the caller's input piece.
552
+ const closedSegs = segs.slice(0, -1).concat([{ ...segs[segs.length - 1], to: [start[0], start[1]] }]);
553
+ return { start, segments: closedSegs };
554
+ }
555
+ return { start, segments: [...segs, { to: [start[0], start[1]] }] };
556
+ });
557
+ }
558
+
559
+ // Resolve a raw offset region list into the POSITIVE winding region it denotes (w >= 1;
560
+ // see the module header). This is contour-offset.js's cleanup path.
561
+ //
562
+ // `clusterTol` is _mergeCrossings' "these crossings are one vertex" radius, CLUSTER_TOL by
563
+ // default. It is an option only so the caller's fallback ladder can RETRY a failed
564
+ // arrangement more coarsely (see contour-offset.js); nothing should raise it as a matter of
565
+ // course, because every crossing it merges moves the emitted boundary by up to that radius.
566
+ export function resolveOffsetWinding(rawRegions, { clusterTol = CLUSTER_TOL } = {}) {
567
+ const rings = [];
568
+ for (const rg of rawRegions) { rings.push(rg.outer); for (const h of rg.holes) rings.push(h); }
569
+ if (rings.length === 0) return [];
570
+
571
+ const merged = _mergeCrossings(ringCrossings(rings), clusterTol);
572
+ const pieces = _splitRings(rings, merged);
573
+ const tessRings = rings.map((r) => tessellateContour(r, WINDING_SEGS));
574
+ // _classify is a general classifier parameterized by a fill rule (see its own comment); the
575
+ // rule this module implements (module header) is POSITIVE winding, w >= 1 — Clipper2's
576
+ // FillRule::Positive. A face with winding <= 0 is never offset material: winding 0 is
577
+ // plainly outside, and a NEGATIVE face is always collapsed material (a lone hole with no
578
+ // covering outer, holes that grew into and past each other, or a raw offset that shrank past
579
+ // zero and inverted), never real material to reflect back into existence.
580
+ //
581
+ // Passing the rule down is not the same as filtering `_classify`'s nonzero answer afterwards
582
+ // (what this did before collinear overlaps were handled). The two agree wherever exactly one
583
+ // directed edge lies on the probed span — there `wRight = wLeft - 1`, so "straddles zero"
584
+ // and "straddles one" both reduce to wLeft === 1, and a nonzero-kept reverse piece is
585
+ // exactly a w=0/w=-1 boundary to drop. They part company on a span several rings share,
586
+ // where the winding jumps by more than 1: a doubled boundary between a w=1 and a w=-1 face
587
+ // is interior under nonzero (both sides filled) but a genuine edge of the positive region,
588
+ // and filtering afterwards would have dropped it — leaving the boundary unchainable.
589
+ const classified = _classify(pieces, tessRings, { inside: (w) => w >= 1 });
590
+ const contours = _chain(classified, merged.pool);
591
+
592
+ // drop numerically empty loops, then nest by containment and restore the storage
593
+ // winding invariant (outer CCW, holes CW) from each contour's own area sign. Under the
594
+ // positive-winding rule above, `assembleRegions` never actually needs its own safety net
595
+ // (a hole with no containing outer, silently dropped there) — every surviving contour here
596
+ // already bounds real w=1 material — but the net stays in place as ordinary defense in depth.
597
+ const live = contours.filter((c) => Math.abs(ringArea(tessellateContour(c, WINDING_SEGS))) > 1e-9);
598
+ if (live.length === 0) return [];
599
+ const tessOf = new Map(live.map((c) => [c, tessellateContour(c, WINDING_SEGS)]));
600
+ const regions = assembleRegions(live.map((c) => tessOf.get(c)));
601
+ const byRing = new Map(live.map((c) => [tessOf.get(c), c]));
602
+ return regions.map((rg) => {
603
+ const outer = byRing.get(rg.outer);
604
+ const orient = (c, wantCCW) => {
605
+ const isCCW = ringArea(tessOf.get(c)) >= 0;
606
+ return closeContourGap(isCCW === wantCCW ? c : reverseContour(c));
607
+ };
608
+ return { outer: orient(outer, true), holes: rg.holes.map((h) => orient(byRing.get(h), false)) };
609
+ });
610
+ }