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