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.
- package/docs/ERROR-PATTERNS.md +67 -43
- package/docs/KERNEL-CONTRACT.md +53 -40
- package/package.json +3 -2
- package/src/framework/geometry/contour-offset.js +477 -164
- package/src/framework/geometry/contour-ops.js +8 -2
- package/src/framework/geometry/contour-winding.js +610 -0
- package/src/framework/geometry/paper-bridge.js +97 -23
|
@@ -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
|
-
|
|
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
|
+
}
|