partforge 0.59.0 → 0.60.1
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 +507 -164
- package/src/framework/geometry/contour-ops.js +8 -2
- package/src/framework/geometry/contour-winding.js +610 -0
- package/src/framework/geometry/creased-normals.js +7 -3
- package/src/framework/geometry/paper-bridge.js +97 -23
|
@@ -8,10 +8,11 @@
|
|
|
8
8
|
// The cubic subdivision approach is ported from glenzli/paperjs-offset
|
|
9
9
|
// (https://github.com/glenzli/paperjs-offset, MIT License, Copyright (c) glenzli),
|
|
10
10
|
// adapted from paper.js Segments to the partforge contour IR.
|
|
11
|
-
import { arcCenterAndSweep
|
|
11
|
+
import { arcCenterAndSweep } from "./paper-bridge.js";
|
|
12
12
|
import { cubicAt, splitCubic, jointTangents, SMOOTH_JOINT_DEG } from "./contour-ops.js";
|
|
13
|
-
import { tessellateContour, closeContourGap
|
|
13
|
+
import { tessellateContour, closeContourGap } from "./profile.js";
|
|
14
14
|
import { ringArea, pointInRing } from "./shape2d-regions.js";
|
|
15
|
+
import { resolveOffsetWinding, CLUSTER_TOL, CHAIN_INCOMPLETE_MESSAGE } from "./contour-winding.js";
|
|
15
16
|
|
|
16
17
|
export const OFFSET_TOL = 1e-3; // mm — max deviation of a cubic offset approximation
|
|
17
18
|
const MAX_DEPTH = 12; // cubic subdivision recursion cap
|
|
@@ -87,6 +88,14 @@ export function _offsetSegment(from, seg, delta) {
|
|
|
87
88
|
|
|
88
89
|
const MITER_LIMIT = 2;
|
|
89
90
|
|
|
91
|
+
// Where X falls along the directed segment P0→P1, as a fraction of its length (null for a
|
|
92
|
+
// degenerate segment). This is the overlap-side trim's validity test — see the trim itself.
|
|
93
|
+
function paramOn(P0, P1, X) {
|
|
94
|
+
const d = sub(P1, P0), L2 = dot(d, d);
|
|
95
|
+
if (L2 < 1e-18) return null;
|
|
96
|
+
return dot(sub(X, P0), d) / L2;
|
|
97
|
+
}
|
|
98
|
+
|
|
90
99
|
// Intersection of the line through P (direction u) with the line through Q (direction v).
|
|
91
100
|
function lineIntersect(P, u, Q, v) {
|
|
92
101
|
const d = cross(u, v);
|
|
@@ -106,7 +115,14 @@ function joinSegs(corner, aEnd, bStart, inTan, outTan, delta, corners) {
|
|
|
106
115
|
// round: exact arc about the corner, via on the displacement bisector
|
|
107
116
|
const d1 = sub(aEnd, corner), d2 = sub(bStart, corner);
|
|
108
117
|
let m = add(d1, d2);
|
|
109
|
-
|
|
118
|
+
// 180° turn: the two displacement normals cancel exactly, but their sum's LIMIT as the
|
|
119
|
+
// turn approaches 180° is the incoming tangent direction (for either delta sign) — the
|
|
120
|
+
// cap bulges forward past the tip. The old rightOf(d1) fallback put `via` on the
|
|
121
|
+
// diametrically wrong side, sweeping the cap arc back through the interior where the
|
|
122
|
+
// winding rule cancelled it and the whole tip cap silently vanished (review finding,
|
|
123
|
+
// execution-confirmed: a zero-width spike dilated +1 round lost its end caps — area
|
|
124
|
+
// 20.000 where the stadium truth is 20+π, maxX 10 where the cap reaches 11).
|
|
125
|
+
if (len(m) < 1e-9) m = inTan;
|
|
110
126
|
return [{ via: add(corner, scl(norm(m), Math.abs(delta))), to: bStart }];
|
|
111
127
|
}
|
|
112
128
|
|
|
@@ -119,6 +135,13 @@ export function _offsetContour(contour, delta, corners) {
|
|
|
119
135
|
const froms = [];
|
|
120
136
|
{ let p = contour.start; for (const s of contour.segments) { froms.push(p); p = s.to; } }
|
|
121
137
|
const fromsKept = froms.filter((_, i) => keep[i]);
|
|
138
|
+
// A ring whose every segment dropped as zero-length has no direction to offset along —
|
|
139
|
+
// treat it as collapsed (contour:null) rather than letting assembleRing dereference an
|
|
140
|
+
// empty piece list (review finding, execution-confirmed: a sub-1e-9-extent sliver ring
|
|
141
|
+
// from an upstream boolean reaches here through the public surface — checkPointRing
|
|
142
|
+
// demands three points, not nonzero extent — and crashed the whole offset with a raw
|
|
143
|
+
// TypeError instead of the pinned collapse message).
|
|
144
|
+
if (segs.length === 0) return { contour: null, dirty: true };
|
|
122
145
|
// NB: feed jointTangents the KEPT chain's start — if the first segment was dropped
|
|
123
146
|
// as zero-length, contour.start no longer heads the filtered ring.
|
|
124
147
|
const joints = jointTangents({ start: fromsKept[0] ?? contour.start, segments: segs });
|
|
@@ -126,6 +149,30 @@ export function _offsetContour(contour, delta, corners) {
|
|
|
126
149
|
let dirty = pieces.some((p) => p.dirty);
|
|
127
150
|
const n = segs.length;
|
|
128
151
|
const joins = new Array(n).fill(null); // joins[i] bridges piece[i-1] → piece[i] at vertex i
|
|
152
|
+
// Each piece's ORIGINAL (pre-trim) first- and last-segment extents, captured before the
|
|
153
|
+
// join loop starts mutating them. The trim gate below is a question about the raw offset
|
|
154
|
+
// geometry, so it must be asked of the raw extents: reading the live, partly-trimmed ones
|
|
155
|
+
// makes the answer depend on which corner the loop happens to have reached, and a ring
|
|
156
|
+
// whose corners are then trimmed inconsistently (some yes, some no) is worse than either
|
|
157
|
+
// all-trimmed or none-trimmed — measured: a 6.99×7.78 rectangular hole at delta +3.5 (it
|
|
158
|
+
// over-collapses in x by 0.008 mm and should vanish) leaves a 12.26 mm² spurious face
|
|
159
|
+
// under a live-extent gate and 0.007 mm² under this one.
|
|
160
|
+
//
|
|
161
|
+
// Built eagerly for every piece, deliberately. Deferring it to the first overlap-side corner
|
|
162
|
+
// looks like free savings — four .slice() allocations per piece, read only by the trim gate —
|
|
163
|
+
// and it is not: an INWARD offset (the case the trim exists for, and the one benchmarked
|
|
164
|
+
// below) puts every convex corner on the overlap side, so every piece's extents get demanded
|
|
165
|
+
// anyway and laziness buys one closure call per corner. Measured, 300 runs, 100 disjoint
|
|
166
|
+
// squares: eager 0.328-0.334 ms against lazy 0.336-0.344 at delta -1, and eager 0.842 against
|
|
167
|
+
// lazy 0.827 at +1 — a wash in both directions. Not worth the ordering invariant it would add
|
|
168
|
+
// (corner i mutates piece i-1's last `.to` and piece i's `.start`, so a lazy read would have
|
|
169
|
+
// to be proven to happen before those, forever).
|
|
170
|
+
const ext = pieces.map((p) => ({
|
|
171
|
+
aFrom: (p.segments.length > 1 ? p.segments.at(-2).to : p.start).slice(),
|
|
172
|
+
aEnd: p.segments.at(-1).to.slice(),
|
|
173
|
+
bStart: p.start.slice(),
|
|
174
|
+
bEnd: p.segments[0].to.slice(),
|
|
175
|
+
}));
|
|
129
176
|
|
|
130
177
|
for (let i = 0; i < n; i++) {
|
|
131
178
|
const prev = pieces[(i - 1 + n) % n], next = pieces[i];
|
|
@@ -134,12 +181,41 @@ export function _offsetContour(contour, delta, corners) {
|
|
|
134
181
|
const turn = cross(inTan, outTan);
|
|
135
182
|
const turnDeg = (Math.atan2(Math.abs(turn), Math.max(-1, Math.min(1, inTan[0] * outTan[0] + inTan[1] * outTan[1]))) * 180) / Math.PI;
|
|
136
183
|
if (dist(aEnd, bStart) <= JOIN_EPS || turnDeg < SMOOTH_JOINT_DEG) continue; // smooth
|
|
137
|
-
|
|
138
|
-
//
|
|
184
|
+
// Gap side gets a join. An EXACT 180° reversal (turn === 0 with a large turnDeg — the
|
|
185
|
+
// tip of a zero-width spike) is ambiguous under the sign test alone and used to fall
|
|
186
|
+
// through to the overlap branch, flat-capping a requested round end; treat it as gap
|
|
187
|
+
// side so the cap is honored (an inward spike's join makes an inverted loop the
|
|
188
|
+
// winding rule cancels, so the choice is safe for either delta sign).
|
|
189
|
+
if (turn * delta > 0 || turn === 0) { joins[i] = joinSegs(point, aEnd, bStart, inTan, outTan, delta, corners); continue; }
|
|
190
|
+
// Overlap side: the two offset pieces run into each other instead of leaving a gap.
|
|
191
|
+
// When both neighbors are plain lines and the two offset LINES cross WITHIN both
|
|
192
|
+
// segments' own extents, that crossing is the true corner of the offset outline: trim
|
|
193
|
+
// both to it and the ring stays simple, so validateRawOffset's exact fast path still
|
|
194
|
+
// applies (this is the everyday polygon inset, and keeping it on the fast path matters —
|
|
195
|
+
// 100 disjoint squares at delta −1 measure 0.33 ms against 9.1 ms with the trim ablated,
|
|
196
|
+
// both as shipped, i.e. with the in-extent gate below in force. An earlier revision of
|
|
197
|
+
// this comment quoted 0.22 ms, which was the UNGATED trim; the gate costs the difference
|
|
198
|
+
// and the number to plan against is the one the code actually runs).
|
|
199
|
+
//
|
|
200
|
+
// The in-extent test is the whole point and is NOT a formality. Past a corner's own
|
|
201
|
+
// feature size the two offset lines still cross, but OUTSIDE both segments — the "trim"
|
|
202
|
+
// then EXTENDS the segments to a point neither of them reaches, fabricating material the
|
|
203
|
+
// raw offset never covered, and because the result is still a simple, correctly-wound
|
|
204
|
+
// ring nothing downstream can tell. That was the whole residual gap after the winding
|
|
205
|
+
// resolver landed: on the clustered-reflex 9-gon at delta −2.79 it produced 4.621926
|
|
206
|
+
// against a true 3.553831 (Clipper2 and an independent Minkowski-union construction
|
|
207
|
+
// agree on that value), ~30 % too much surviving area. Untrimmed, the crossing offset
|
|
208
|
+
// segments are simply emitted and the positive-winding rule cancels the reversed loop,
|
|
209
|
+
// which is what every standard offset algorithm (Clipper2 included) does — so decline
|
|
210
|
+
// the trim, bevel across, and let resolveOffsetWinding do it properly.
|
|
139
211
|
const aSeg = prev.segments.at(-1), bSeg = next.segments[0];
|
|
140
212
|
if (!aSeg.via && !aSeg.c1 && !bSeg.via && !bSeg.c1) {
|
|
141
213
|
const X = lineIntersect(aEnd, inTan, bStart, outTan);
|
|
142
|
-
|
|
214
|
+
const eA = ext[(i - 1 + n) % n], eB = ext[i];
|
|
215
|
+
const ta = X && paramOn(eA.aFrom, eA.aEnd, X), tb = X && paramOn(eB.bStart, eB.bEnd, X);
|
|
216
|
+
if (X && ta !== null && tb !== null && ta > 0 && ta <= 1 && tb >= 0 && tb < 1) {
|
|
217
|
+
aSeg.to = X; next.start = X; continue; // exact trim, stays clean
|
|
218
|
+
}
|
|
143
219
|
}
|
|
144
220
|
joins[i] = [{ to: bStart }]; dirty = true;
|
|
145
221
|
}
|
|
@@ -179,7 +255,7 @@ export function _offsetContour(contour, delta, corners) {
|
|
|
179
255
|
// the partial-reflection residual: those pieces survive into the ring and validateRawOffset
|
|
180
256
|
// can't see anything locally wrong with them. Delete them (not un-trim — un-trimming was
|
|
181
257
|
// rejected in an earlier round for other over-inclusive regressions) and re-link the
|
|
182
|
-
// surviving neighbors with a direct chord, marking the ring dirty so
|
|
258
|
+
// surviving neighbors with a direct chord, marking the ring dirty so resolveOffsetWinding
|
|
183
259
|
// gets a chance to untangle whatever that chord leaves behind. Every non-line piece
|
|
184
260
|
// always survives this pass; the whole-ring gate above already guarantees at least one
|
|
185
261
|
// piece survives here too.
|
|
@@ -191,15 +267,16 @@ export function _offsetContour(contour, delta, corners) {
|
|
|
191
267
|
if (keptIdx.length === 0) return { contour: null, dirty: true };
|
|
192
268
|
|
|
193
269
|
// Guard (review round 1, Important 3): deletion is unrecoverable — unlike a chord/dirty
|
|
194
|
-
// join, which
|
|
270
|
+
// join, which resolveOffsetWinding can still untangle downstream, a deleted piece is gone for
|
|
195
271
|
// good, so a bad deletion can turn perfectly good geometry into a false "offset collapses
|
|
196
272
|
// the shape" throw (measured: 18 new throws per 3000 random polygons; repro: a 9-gon at
|
|
197
273
|
// delta -2.79/chamfer with true eroded area 2.76). A raw *piece count* floor doesn't work as
|
|
198
274
|
// the discriminator — an arc-dominated ring can be legitimately reduced to a single
|
|
199
275
|
// surviving piece (this file's own storage convention stores a full circle as just two arcs;
|
|
200
276
|
// the keyed-bore regression test below reduces to one surviving arc + closing chord and that
|
|
201
|
-
// IS the correct answer). Routing through
|
|
202
|
-
// tried first
|
|
277
|
+
// IS the correct answer). Routing through the former Paper.js self-union cleanup did not
|
|
278
|
+
// work either — it was tried first and rejected because it assumed the CCW/positive-area
|
|
279
|
+
// "outer" convention (an
|
|
203
280
|
// uninverted self-union is real material, an inverted one that flips positive is a
|
|
204
281
|
// discarded artifact), but _offsetContour has no idea here whether it's assembling an outer
|
|
205
282
|
// or a hole, and a perfectly valid CW/negative-area hole ring (like the keyed bore's) reads
|
|
@@ -212,7 +289,7 @@ export function _offsetContour(contour, delta, corners) {
|
|
|
212
289
|
// when deletion isn't simple: cleanup gets a chance to untangle whatever the un-deleted
|
|
213
290
|
// piece leaves behind, which is strictly more recoverable than deletion's dead end.
|
|
214
291
|
const deleted = assembleRing(pieces, joins, keptIdx, n);
|
|
215
|
-
const deletedRing =
|
|
292
|
+
const deletedRing = sampleRing(deleted, VALIDATE_SEGS);
|
|
216
293
|
const deletedArea = Math.abs(ringArea(deletedRing));
|
|
217
294
|
if (deletedArea <= AREA_EPS || ringSelfIntersects(deletedRing))
|
|
218
295
|
return { contour: assembleRing(pieces, joins, allIdx, n), dirty: true };
|
|
@@ -245,18 +322,35 @@ function assembleRing(pieces, joins, idx, n) {
|
|
|
245
322
|
return { start, segments: out };
|
|
246
323
|
}
|
|
247
324
|
|
|
248
|
-
|
|
325
|
+
// Do two segments meet anywhere — including AT an endpoint of either one? The orientation
|
|
326
|
+
// test `o1 !== o2 && o3 !== o4` already answers that (a vertex-incident crossing shows up as
|
|
327
|
+
// one orientation being 0 and the other two straddling), so the endpoint-incident case costs
|
|
328
|
+
// nothing extra; what it costs is the right to be sloppy about adjacency, which is why every
|
|
329
|
+
// caller must hand this a ring with no duplicate and no zero-length vertices (see dedupeRing).
|
|
330
|
+
//
|
|
331
|
+
// This used to demand all four orientations be nonzero — "strict crossings only" — and that
|
|
332
|
+
// discarded exactly the case an inward offset produces most often: a ring that runs into
|
|
333
|
+
// ITSELF at one of its own vertices. A 20×10 block with an 8-deep slot, inset by 2, offsets
|
|
334
|
+
// the slot floor DOWN past the block's own eroded bottom edge, so the ring dips below y=2 and
|
|
335
|
+
// re-crosses it AT the vertices where the slot walls land — every crossing endpoint-incident,
|
|
336
|
+
// every orientation product zero, `validateRawOffset` satisfied, and the tangled ring taken
|
|
337
|
+
// straight down the exact fast path. It silently kept 32 mm² of a true 48 mm² erosion (the
|
|
338
|
+
// walls' offsets cancel under positive winding, which is what resolveOffsetWinding would have
|
|
339
|
+
// done had the ring ever reached it). Endpoint-incidence is not a degenerate curiosity here;
|
|
340
|
+
// it is the generic case, because offset pieces are BUILT by translating shared vertices.
|
|
341
|
+
function segsIntersect(a1, a2, b1, b2) {
|
|
249
342
|
const o = (p, q, r) => Math.sign((q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]));
|
|
250
343
|
const o1 = o(a1, a2, b1), o2 = o(a1, a2, b2), o3 = o(b1, b2, a1), o4 = o(b1, b2, a2);
|
|
251
|
-
return o1 !== o2 && o3 !== o4
|
|
344
|
+
return o1 !== o2 && o3 !== o4;
|
|
252
345
|
}
|
|
253
346
|
|
|
254
347
|
// True when two (non-adjacent) segments are collinear and overlap along more than a point.
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
348
|
+
// segsIntersect is an orientation test, and a fully collinear pair makes all four
|
|
349
|
+
// orientations 0, so `o1 !== o2` is false and it reports nothing — yet an offset ring can
|
|
350
|
+
// retrace the same line twice (a neck/hole pinched shut by delta past its own width flips
|
|
351
|
+
// the offset pieces from either side onto each other), producing exact duplicate or
|
|
352
|
+
// overlapping collinear edges with no transversal crossing anywhere. That is this test's
|
|
353
|
+
// job and it stays: widening segsIntersect to endpoint-incidence does not subsume it.
|
|
260
354
|
function segsOverlap(a1, a2, b1, b2) {
|
|
261
355
|
const d = sub(a2, a1);
|
|
262
356
|
const L = len(d);
|
|
@@ -294,6 +388,28 @@ const boxesApart = (a, b) =>
|
|
|
294
388
|
a[2] < b[0] - BOX_EPS || b[2] < a[0] - BOX_EPS || a[3] < b[1] - BOX_EPS || b[3] < a[1] - BOX_EPS;
|
|
295
389
|
const segBox = (p, q) => [Math.min(p[0], q[0]), Math.min(p[1], q[1]), Math.max(p[0], q[0]), Math.max(p[1], q[1])];
|
|
296
390
|
|
|
391
|
+
// Every ring below is read as IMPLICITLY closed — segment i runs ring[i] → ring[(i+1)%m] —
|
|
392
|
+
// and the pairwise loops decide "these two may legitimately touch" purely from index
|
|
393
|
+
// adjacency. `tessellateContour` does not produce such a ring: it emits the closing point,
|
|
394
|
+
// so ring[m-1] === ring[0]. That duplicate is not harmless bookkeeping once segsIntersect
|
|
395
|
+
// counts endpoint contact. It creates a zero-length segment m-1 sitting on ring[0], and it
|
|
396
|
+
// pushes the real closing edge back to index m-2 — which then shares the vertex ring[0] with
|
|
397
|
+
// segment 0 while being two apart in the index, i.e. NON-adjacent by the loop's reckoning.
|
|
398
|
+
// Result: every ring in the repo would report a self-touch at its own start point, and every
|
|
399
|
+
// offset would take the resolver. Coincident interior vertices (a join that lands exactly on
|
|
400
|
+
// its neighbour, a piece trimmed to zero length) do the same thing one index further in.
|
|
401
|
+
// So: normalize first, and let "non-adjacent" mean what it says.
|
|
402
|
+
const RING_EPS = 1e-9;
|
|
403
|
+
function dedupeRing(ring) {
|
|
404
|
+
const out = [];
|
|
405
|
+
for (const p of ring) if (!out.length || dist(out.at(-1), p) > RING_EPS) out.push(p);
|
|
406
|
+
while (out.length > 1 && dist(out[0], out.at(-1)) <= RING_EPS) out.pop();
|
|
407
|
+
return out;
|
|
408
|
+
}
|
|
409
|
+
// Tessellate a contour into the deduplicated, implicitly-closed ring the tests below expect.
|
|
410
|
+
// (ringArea / ringBox / pointInRing all wrap modularly too, so they read it unchanged.)
|
|
411
|
+
const sampleRing = (contour, segs) => dedupeRing(tessellateContour(contour, segs));
|
|
412
|
+
|
|
297
413
|
function ringSelfIntersects(ring) {
|
|
298
414
|
const m = ring.length;
|
|
299
415
|
const boxes = [];
|
|
@@ -302,19 +418,20 @@ function ringSelfIntersects(ring) {
|
|
|
302
418
|
if (i === 0 && j === m - 1) continue; // adjacent via wraparound
|
|
303
419
|
if (boxesApart(boxes[i], boxes[j])) continue;
|
|
304
420
|
const a1 = ring[i], a2 = ring[(i + 1) % m], b1 = ring[j], b2 = ring[(j + 1) % m];
|
|
305
|
-
if (
|
|
421
|
+
if (segsIntersect(a1, a2, b1, b2) || segsOverlap(a1, a2, b1, b2)) return true;
|
|
306
422
|
}
|
|
307
423
|
return false;
|
|
308
424
|
}
|
|
309
425
|
|
|
310
426
|
// Two DIFFERENT rings interfering. Like ringSelfIntersects this checks segsOverlap as well
|
|
311
|
-
// as
|
|
312
|
-
// boundaries run along the same line — exactly what two eroding holes that grew into
|
|
313
|
-
// other produce under a sharp join, where
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
//
|
|
427
|
+
// as segsIntersect: two rings can interfere without any transversal crossing at all when
|
|
428
|
+
// their boundaries run along the same line — exactly what two eroding holes that grew into
|
|
429
|
+
// each other produce under a sharp join, where the crossings land on shared vertices and the
|
|
430
|
+
// rest of the signal is a pair of collinear, partially-overlapping edges. Missing that let an
|
|
431
|
+
// invalid double-ring result pass the fast path and reach extrude, where even-odd fill turned
|
|
432
|
+
// the doubly-covered lens back into SOLID material inside the merged pocket. (The shared-
|
|
433
|
+
// vertex half of that is now caught directly — segsIntersect counts endpoint contact — but
|
|
434
|
+
// the collinear half still is not, so both tests stay.)
|
|
318
435
|
function ringsCross(a, b, boxA = ringBox(a), boxB = ringBox(b)) {
|
|
319
436
|
if (boxesApart(boxA, boxB)) return false;
|
|
320
437
|
for (let i = 0; i < a.length; i++) {
|
|
@@ -324,7 +441,7 @@ function ringsCross(a, b, boxA = ringBox(a), boxB = ringBox(b)) {
|
|
|
324
441
|
for (let j = 0; j < b.length; j++) {
|
|
325
442
|
const b1 = b[j], b2 = b[(j + 1) % b.length];
|
|
326
443
|
if (boxesApart(bx, segBox(b1, b2))) continue;
|
|
327
|
-
if (
|
|
444
|
+
if (segsIntersect(a1, a2, b1, b2) || segsOverlap(a1, a2, b1, b2)) return true;
|
|
328
445
|
}
|
|
329
446
|
}
|
|
330
447
|
return false;
|
|
@@ -347,8 +464,8 @@ function ringInsideRing(inner, outer, outerBox) {
|
|
|
347
464
|
// True when a raw offset result is already valid (fast path). Sampled at VALIDATE_SEGS.
|
|
348
465
|
export function validateRawOffset(regions) {
|
|
349
466
|
const sampled = regions.map((rg) => ({
|
|
350
|
-
outer:
|
|
351
|
-
holes: rg.holes.map((h) =>
|
|
467
|
+
outer: sampleRing(rg.outer, VALIDATE_SEGS),
|
|
468
|
+
holes: rg.holes.map((h) => sampleRing(h, VALIDATE_SEGS)),
|
|
352
469
|
}));
|
|
353
470
|
const allRings = [];
|
|
354
471
|
for (const rg of sampled) {
|
|
@@ -367,112 +484,6 @@ export function validateRawOffset(regions) {
|
|
|
367
484
|
return true;
|
|
368
485
|
}
|
|
369
486
|
|
|
370
|
-
// A raw all-line outer ring can retrace the very same edge twice, in the same direction,
|
|
371
|
-
// when a narrow neck (or a hole) offsets past its own width: the two sides of the pinch
|
|
372
|
-
// land exactly on top of each other (see the whole-ring collapse check in _offsetContour
|
|
373
|
-
// for the convex-corner sibling of this — same underlying reflection, reached through a
|
|
374
|
-
// reflex-corner join instead of a trim, so it isn't a single collapsed ring to drop but a
|
|
375
|
-
// self-touching one to cut). That's a *valid, simple* polygon as far as paper.js is
|
|
376
|
-
// concerned — a zero-width slit, not a crossing — so resolveSelfRegions has nothing to
|
|
377
|
-
// untangle and leaves the two halves connected. Cut the ring at each duplicate edge
|
|
378
|
-
// instead: it always severs the ring into two closed sub-loops (repeat until none remain).
|
|
379
|
-
// Winding tells real material from the leftover artifact: the artifact comes back CW
|
|
380
|
-
// (negative area — the neck's own boundary retraced backwards) and is discarded; the two
|
|
381
|
-
// severed pieces come back CCW, matching the storage invariant for outers.
|
|
382
|
-
function splitAtDuplicateEdges(contour) {
|
|
383
|
-
if (contour.segments.some((s) => s.via || s.c1)) return null; // lines only
|
|
384
|
-
const pts = [contour.start, ...contour.segments.map((s) => s.to)];
|
|
385
|
-
const ring = pts.slice(0, -1); // drop the closing repeat of start
|
|
386
|
-
const eq = (a, b) => dist(a, b) <= JOIN_EPS;
|
|
387
|
-
const loops = [ring];
|
|
388
|
-
const pieces = [];
|
|
389
|
-
let splitAny = false;
|
|
390
|
-
while (loops.length) {
|
|
391
|
-
const r = loops.pop();
|
|
392
|
-
const m = r.length;
|
|
393
|
-
let found = null;
|
|
394
|
-
for (let i = 0; i < m && !found; i++) for (let j = i + 1; j < m; j++) {
|
|
395
|
-
if (eq(r[i], r[j]) && eq(r[(i + 1) % m], r[(j + 1) % m])) { found = [i, j]; break; }
|
|
396
|
-
}
|
|
397
|
-
if (!found) { pieces.push(r); continue; }
|
|
398
|
-
splitAny = true;
|
|
399
|
-
const [i, j] = found;
|
|
400
|
-
const a = r.slice(i + 1, j + 1), b = [...r.slice(j + 1), ...r.slice(0, i + 1)];
|
|
401
|
-
if (a.length >= 3) loops.push(a);
|
|
402
|
-
if (b.length >= 3) loops.push(b);
|
|
403
|
-
}
|
|
404
|
-
if (!splitAny) return null;
|
|
405
|
-
return pieces
|
|
406
|
-
.filter((r) => ringArea(r) > AREA_EPS) // discard the CW artifact
|
|
407
|
-
.map((r) => ({ start: r[0], segments: [...r.slice(1).map((p) => ({ to: p })), { to: [r[0][0], r[0][1]] }] }));
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
// Apply splitAtDuplicateEdges to EVERY region's outer ring, re-nesting that region's holes
|
|
411
|
-
// into whichever split piece contains them. Returns null when no outer split (nothing to
|
|
412
|
-
// recover); otherwise the re-nested region list. Severing a pinched neck is the only
|
|
413
|
-
// mechanism this engine has for cutting a ring that an inward offset closed shut, and it
|
|
414
|
-
// applies to holed and multi-region shapes exactly as much as to a single bare ring — a
|
|
415
|
-
// plate with a waist AND bolt holes, inset for print clearance, is the everyday case.
|
|
416
|
-
//
|
|
417
|
-
// A hole whose first point lands inside none of the split pieces (the neck it lived beside
|
|
418
|
-
// was severed out from under it, or it grew past its own outer) is NOT dropped: it is
|
|
419
|
-
// re-attached to the largest piece so validateRawOffset sees it and rejects the candidate,
|
|
420
|
-
// routing the whole thing to cleanupRegions — which subtracts hole rings from the united
|
|
421
|
-
// outers and so clips it correctly. Dropping it here would silently delete real material
|
|
422
|
-
// removal (measured: a dumbbell with a 1×1 hole at delta −2 came back 72 instead of 56).
|
|
423
|
-
function splitPinchedRegions(raw) {
|
|
424
|
-
let splitAny = false;
|
|
425
|
-
const out = [];
|
|
426
|
-
for (const rg of raw) {
|
|
427
|
-
const split = splitAtDuplicateEdges(rg.outer);
|
|
428
|
-
if (!split) { out.push(rg); continue; }
|
|
429
|
-
splitAny = true;
|
|
430
|
-
if (split.length === 0) continue; // every piece came back CW: pure artifact
|
|
431
|
-
const pieces = split.map((outer) => ({ outer, holes: [], ring: tessellateContour(outer, VALIDATE_SEGS) }));
|
|
432
|
-
for (const h of rg.holes) {
|
|
433
|
-
const p = tessellateContour(h, VALIDATE_SEGS)[0];
|
|
434
|
-
const inside = pieces.filter((q) => pointInRing(p, q.ring));
|
|
435
|
-
const home = inside.length
|
|
436
|
-
? inside.reduce((a, b) => (Math.abs(ringArea(a.ring)) <= Math.abs(ringArea(b.ring)) ? a : b))
|
|
437
|
-
: pieces.reduce((a, b) => (Math.abs(ringArea(a.ring)) >= Math.abs(ringArea(b.ring)) ? a : b));
|
|
438
|
-
home.holes.push(h);
|
|
439
|
-
}
|
|
440
|
-
for (const q of pieces) out.push({ outer: q.outer, holes: q.holes });
|
|
441
|
-
}
|
|
442
|
-
return splitAny ? out : null;
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
// Cleanup stage: resolve a raw offset result that the fast path rejected.
|
|
446
|
-
//
|
|
447
|
-
// This is a SUBTRACTION, not a self-union. Feeding outers and holes into one even-odd
|
|
448
|
-
// compound and self-uniting it (what this used to do) is only equivalent while every hole
|
|
449
|
-
// still sits cleanly inside its own outer and no two holes touch — precisely the conditions
|
|
450
|
-
// an over-offset breaks. Two eroding holes that grew into each other, or a hole that grew
|
|
451
|
-
// through its outer's boundary, cancel under even-odd instead of merging: the doubly-covered
|
|
452
|
-
// lens comes back SOLID. Extruded, that is a solid island inside a merged pocket (40×20
|
|
453
|
-
// plate, two 6×8 holes 3 mm apart, delta −2: 360 mm² instead of 348) or a tab of material
|
|
454
|
-
// hanging off the plate below its own boundary (10×10 hole 2 mm from the edge, delta −2:
|
|
455
|
-
// 436 mm² instead of 408). Uniting the outers and then subtracting the united hole rings
|
|
456
|
-
// gives the right answer in both cases because subtraction has no doubly-covered state.
|
|
457
|
-
//
|
|
458
|
-
// The holes are united into one region list first rather than subtracted one at a time —
|
|
459
|
-
// same result (subtraction distributes over union), one paper.js boolean instead of N, which
|
|
460
|
-
// matters on the worker hot path for many-counter text.
|
|
461
|
-
function cleanupRegions(regions) {
|
|
462
|
-
const outers = resolveSelfRegions(regions.map((rg) => ({ outer: rg.outer, holes: [] })));
|
|
463
|
-
const holes = regions.flatMap((rg) => rg.holes);
|
|
464
|
-
if (outers.length === 0 || holes.length === 0) return outers;
|
|
465
|
-
// Hole rings are stored CW; reverse each to the CCW "outer" winding resolveSelfRegions
|
|
466
|
-
// assumes before uniting them, or its inverted-region guard reads a perfectly good hole as
|
|
467
|
-
// a collapsed artifact and cancels it (measured: every hole silently vanished).
|
|
468
|
-
const holeUnion = resolveSelfRegions(holes.map((h) => ({
|
|
469
|
-
outer: ringArea(tessellateContour(h, VALIDATE_SEGS)) < 0 ? reverseContour(h) : h,
|
|
470
|
-
holes: [],
|
|
471
|
-
})));
|
|
472
|
-
if (holeUnion.length === 0) return outers;
|
|
473
|
-
return booleanRegions(outers, holeUnion, "subtract");
|
|
474
|
-
}
|
|
475
|
-
|
|
476
487
|
// Part 2 of the partial-reflection fix (task 5B) — REMOVED (review round 2). A global
|
|
477
488
|
// distance-from-source prune existed here through two review rounds, narrowing its scope each
|
|
478
489
|
// time (round 1: per-hole rather than whole-region, to stop swallowing legitimate hole
|
|
@@ -490,53 +501,385 @@ function cleanupRegions(regions) {
|
|
|
490
501
|
// distinguishes "prune this, it's really gone" from "don't prune this, cleanup will recover
|
|
491
502
|
// it" using only the raw, pre-cleanup ring. The prune's collateral (silently deleting real
|
|
492
503
|
// text counters at sub-millimetre offsets) is strictly worse than the single defect it fixes,
|
|
493
|
-
// so it's gone rather than shipped delicately tuned.
|
|
494
|
-
//
|
|
495
|
-
//
|
|
504
|
+
// so it's gone rather than shipped delicately tuned. See task-5B-report.md's round-2 section
|
|
505
|
+
// for the full sweep. (The wide-L-pocket case itself is no longer an open gap: task 7's
|
|
506
|
+
// resolveOffsetWinding — see below — resolves it correctly with no per-ring heuristic at all,
|
|
507
|
+
// since a fully-eroded hole ring is simply negative-winding everywhere and drops out on its
|
|
508
|
+
// own; see test/contour-offset.test.js's "wide L-shaped hole (5-unit arms) at +3" test.)
|
|
496
509
|
|
|
497
|
-
//
|
|
498
|
-
//
|
|
499
|
-
//
|
|
500
|
-
//
|
|
501
|
-
//
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
+
}
|
|
507
595
|
|
|
596
|
+
function heapPop(heap) {
|
|
597
|
+
const top = heap[0];
|
|
598
|
+
const last = heap.pop();
|
|
599
|
+
if (!heap.length) return top;
|
|
600
|
+
let i = 0;
|
|
601
|
+
while (true) {
|
|
602
|
+
const left = i * 2 + 1;
|
|
603
|
+
if (left >= heap.length) break;
|
|
604
|
+
const right = left + 1;
|
|
605
|
+
const child = right < heap.length && heap[right].max > heap[left].max ? right : left;
|
|
606
|
+
if (heap[child].max <= last.max) break;
|
|
607
|
+
heap[i] = heap[child];
|
|
608
|
+
i = child;
|
|
609
|
+
}
|
|
610
|
+
heap[i] = last;
|
|
611
|
+
return top;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
export function _sourceHoleContainsDisk(contour, radius, tolerance = SOURCE_DISK_TOL) {
|
|
615
|
+
if (radius <= 0) return true;
|
|
616
|
+
const ring = sourceDiskRing(closeContourGap(contour));
|
|
617
|
+
if (!ring) return true;
|
|
618
|
+
if (ring.length < 3) return false;
|
|
619
|
+
const [x0, y0, x1, y1] = ringBox(ring);
|
|
620
|
+
const width = x1 - x0, height = y1 - y0;
|
|
621
|
+
if (width <= 0 || height <= 0) return false;
|
|
622
|
+
|
|
623
|
+
// Keep a near-threshold hole rather than silently erase real material because of curve
|
|
624
|
+
// tessellation or floating-point error. A false positive merely leaves cleanup its former
|
|
625
|
+
// input; a false negative permanently deletes the counter.
|
|
626
|
+
const threshold = Math.max(0, radius - tolerance);
|
|
627
|
+
if (Math.min(width, height) / 2 < threshold) return false;
|
|
628
|
+
|
|
629
|
+
const heap = [];
|
|
630
|
+
heapPush(heap, diskCell((x0 + x1) / 2, (y0 + y1) / 2, width / 2, height / 2, ring));
|
|
631
|
+
while (heap.length) {
|
|
632
|
+
const cell = heapPop(heap); // greatest remaining upper bound
|
|
633
|
+
if (cell.d >= threshold) return true;
|
|
634
|
+
if (cell.max < threshold) return false;
|
|
635
|
+
if (Math.hypot(cell.hx, cell.hy) <= tolerance) return true;
|
|
636
|
+
|
|
637
|
+
const hx = cell.hx / 2, hy = cell.hy / 2;
|
|
638
|
+
for (const dx of [-hx, hx]) for (const dy of [-hy, hy])
|
|
639
|
+
heapPush(heap, diskCell(cell.x + dx, cell.y + dy, hx, hy, ring));
|
|
640
|
+
}
|
|
641
|
+
return false;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Raw per-ring offset of a whole region list, plus whether any surviving ring came back
|
|
645
|
+
// approximated. Split out of offsetRegions so the fallback ladder below can re-run it at a
|
|
646
|
+
// perturbed delta without recursing through the public entry point.
|
|
647
|
+
function rawOffset(regions, delta, corners) {
|
|
508
648
|
// _offsetContour signals a whole-ring collapse with contour:null (see its comment) — a
|
|
509
649
|
// dropped outer removes its whole region, a dropped hole is simply omitted. If everything
|
|
510
|
-
// drops, raw ends up [] and
|
|
650
|
+
// drops, raw ends up [] and offsetRegions' "no regions survive" throw fires naturally.
|
|
511
651
|
let dirty = false;
|
|
512
652
|
const raw = [];
|
|
513
653
|
for (const rg of regions) {
|
|
514
654
|
const o = _offsetContour(closeContourGap(rg.outer), delta, corners);
|
|
515
655
|
dirty = dirty || o.dirty;
|
|
516
656
|
if (!o.contour) continue;
|
|
517
|
-
const hs = rg.holes.map((h) =>
|
|
657
|
+
const hs = rg.holes.map((h) => {
|
|
658
|
+
const hole = closeContourGap(h);
|
|
659
|
+
if (delta > 0 && corners === "round" && !_sourceHoleContainsDisk(hole, delta))
|
|
660
|
+
return { contour: null, dirty: false };
|
|
661
|
+
return _offsetContour(hole, delta, corners);
|
|
662
|
+
});
|
|
518
663
|
// Only a SURVIVING hole's dirtiness can dirty the result. A hole that collapsed
|
|
519
664
|
// (contour === null) always reports dirty — that is how _offsetContour signals the drop —
|
|
520
665
|
// but the drop itself is a clean operation: the hole is simply gone and nothing else about
|
|
521
|
-
// the region moved. Folding that signal into `dirty`
|
|
522
|
-
//
|
|
523
|
-
//
|
|
524
|
-
// without it. On OCCT that is the difference between B_SPLINE and CIRCLE in the exported
|
|
525
|
-
// STEP — exact curve fidelity lost to a hole that isn't even in the output.
|
|
666
|
+
// the region moved. Folding that signal into `dirty` would send an otherwise-exact outer
|
|
667
|
+
// through resolveOffsetWinding for no reason — unnecessary crossing search over a ring that
|
|
668
|
+
// was already exact — for a hole that isn't even in the output.
|
|
526
669
|
dirty = dirty || hs.some((h) => h.contour && h.dirty);
|
|
527
670
|
raw.push({ outer: o.contour, holes: hs.filter((h) => h.contour).map((h) => h.contour) });
|
|
528
671
|
}
|
|
672
|
+
return { raw, dirty };
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const resolveOrRaw = ({ raw, dirty }) =>
|
|
676
|
+
(!dirty && validateRawOffset(raw)) ? raw : resolveOffsetWinding(raw);
|
|
677
|
+
|
|
678
|
+
// Three underscore hooks, none of them part of the public surface, all three existing for
|
|
679
|
+
// ONE reason: `scripts/offset-rates.mjs` is the committed instrument behind every offset rate
|
|
680
|
+
// quoted in docs/ERROR-PATTERNS.md and docs/KERNEL-CONTRACT.md, and it has to measure the
|
|
681
|
+
// SHIPPED ladder rather than a copy of it. Those rates previously came from scratch scripts
|
|
682
|
+
// that were never committed, and several of them turned out to be wrong.
|
|
683
|
+
// _rawOffset — the raw per-ring offset, the ladder's input.
|
|
684
|
+
// _offsetNoFallback — the offset as it behaved BEFORE the ladder existed (the "before"
|
|
685
|
+
// column of the rate table).
|
|
686
|
+
// _ladderRungs — the ladder itself, as named lazy thunks.
|
|
687
|
+
export const _rawOffset = rawOffset;
|
|
688
|
+
export const _offsetNoFallback = (regions, delta, corners) =>
|
|
689
|
+
resolveOrRaw(rawOffset(regions, delta, corners));
|
|
690
|
+
|
|
691
|
+
// A ring rebuilt as straight chords at `segs` facets per full turn — arcs and cubics
|
|
692
|
+
// replaced by their own tessellation, lines unchanged (tessellateContour emits a line's
|
|
693
|
+
// endpoints and nothing between). Used only by the fallback ladder's last rungs.
|
|
694
|
+
const flattenRing = (contour, segs) => {
|
|
695
|
+
const pts = tessellateContour(contour, segs);
|
|
696
|
+
// closeContourGap, not a bare rebuild: resolveOffsetWinding requires every ring to carry a
|
|
697
|
+
// real closing segment back to `start` (_splitRings throws on an implicitly-closed one), and
|
|
698
|
+
// whether tessellateContour's last point lands exactly on the first is a property of the
|
|
699
|
+
// input, not something to assume.
|
|
700
|
+
return closeContourGap({ start: [pts[0][0], pts[0][1]],
|
|
701
|
+
segments: pts.slice(1).map((p) => ({ to: [p[0], p[1]] })) });
|
|
702
|
+
};
|
|
703
|
+
|
|
704
|
+
// The fallback ladder for a raw offset whose arrangement the winding resolver cannot close
|
|
705
|
+
// (contour-winding.js's CHAIN_INCOMPLETE_MESSAGE). Each rung re-runs the SAME offset with one
|
|
706
|
+
// numerical nuisance perturbed, and the first rung that produces a non-empty region set wins.
|
|
707
|
+
//
|
|
708
|
+
// Why a ladder exists at all: the chain failure is a defect in resolving a degenerate
|
|
709
|
+
// arrangement, not a statement about the shape — the material is really there. Left as a
|
|
710
|
+
// throw it reads, in a parametric app that re-offsets on every slider move, as "the part
|
|
711
|
+
// builds at 2.4 mm and dies at 2.5 mm", which is a harder failure than any other defect class
|
|
712
|
+
// in this engine (all of which degrade to bounded inaccuracy).
|
|
713
|
+
//
|
|
714
|
+
// RATES, and where they come from. `node scripts/offset-rates.mjs` sweeps the committed
|
|
715
|
+
// corpus (600 seeded shapes + 6 glyphs, 20 deltas, 3 styles = 36 090 offsets). After the
|
|
716
|
+
// adaptive pinch classifier, failures before the ladder / after it are:
|
|
717
|
+
// round 1 -> 0 chamfer 2 -> 0 sharp 4 -> 0
|
|
718
|
+
// All seven rescues are oracle-checked: median area error 0.0972 %, worst 1.663 %, with zero
|
|
719
|
+
// region-count losses and zero complete arc losses. The ladder stays because those seven raw
|
|
720
|
+
// arrangements remain numerically unclosable, not because the formerly parked comb/text
|
|
721
|
+
// failures still exist.
|
|
722
|
+
//
|
|
723
|
+
// Rung ORDER is by fidelity of what survives, not by hit rate:
|
|
724
|
+
// 1. delta perturbed by ±1e-9 relative. Escapes an exactly-degenerate arrangement (two
|
|
725
|
+
// offset walls landing on the same coordinate) with the requested corner style and the
|
|
726
|
+
// exact curve IR both intact.
|
|
727
|
+
// 2. a coarser crossing-merge radius (4x and 20x CLUSTER_TOL). Keeps corner style AND the
|
|
728
|
+
// exact IR — a trimmed arc is still an arc — at the cost of collapsing crossings up to
|
|
729
|
+
// that radius apart onto one vertex. This can merge a genuine severing pinch, so the
|
|
730
|
+
// 20x rung is not widened further even though the current seven rescues preserve the
|
|
731
|
+
// oracle's region count.
|
|
732
|
+
// 3. the raw outline re-run as polylines (64/256/1024 facets per turn). Geometrically
|
|
733
|
+
// faithful to the chord error of that tessellation, but it DEGRADES THE IR: round joins
|
|
734
|
+
// come back as chords, so A STEP EXPORT OF A POLYLINE-RUNG RESULT LOSES ITS TRUE CIRCLES.
|
|
735
|
+
// No current corpus rescue loses every arc, but these rungs remain last because that
|
|
736
|
+
// fidelity cost is structural.
|
|
737
|
+
// Coverage is not monotonic in any rung's parameter (64 facets resolves cases 256 does not) —
|
|
738
|
+
// these are escapes from degeneracy, not refinements, so every rung earns its place by cases
|
|
739
|
+
// no other rung covers.
|
|
740
|
+
//
|
|
741
|
+
// Two things this deliberately does NOT do. (The numbers in this paragraph are task 7D's
|
|
742
|
+
// design measurements, taken on a 59-case round-only sweep that predates the committed corpus
|
|
743
|
+
// and is NOT reproducible from this repo — they are recorded as the reasoning behind two
|
|
744
|
+
// rejected rungs, not as current rates. Everything above is from scripts/offset-rates.mjs.)
|
|
745
|
+
// It never retries under a different JOIN: swapping
|
|
746
|
+
// to chamfer resolves 58 of the 59, but at a median 5 % from the round truth (worst 4 800 %),
|
|
747
|
+
// and it would hand back geometry with visibly different corners than the caller asked for —
|
|
748
|
+
// silently wrong beats loudly failed only when the wrongness is bounded, and that one is not.
|
|
749
|
+
// And it never perturbs delta by more than 1e-9: the failures are NOT the knife-edge
|
|
750
|
+
// degeneracies they look like (the four-notch comb throws across a whole 0.02 mm band of delta,
|
|
751
|
+
// not at one value), so escaping by delta alone takes ~1e-2 relative, which resolves 100 % at a
|
|
752
|
+
// median 0.15 % and a worst 9 % error — well outside the band above. A bounded middle ground
|
|
753
|
+
// (±1e-4 and ±1e-3 mm absolute rungs) was measured too: it bought ONE extra case out of 62 and
|
|
754
|
+
// more than doubled the worst absolute error, 0.048 → 0.112 mm², so it is not here either.
|
|
755
|
+
//
|
|
756
|
+
// The ladder as named, LAZY rungs — one list, walked by chainFallback below and by
|
|
757
|
+
// scripts/offset-rates.mjs, so a measurement of "what each rung costs" can never drift from
|
|
758
|
+
// the ladder that actually ships. Every rung's whole body (including tessellating the outline
|
|
759
|
+
// for the polyline rungs) runs inside its own thunk. The expected chain-incomplete signal
|
|
760
|
+
// advances to the next rung; any other setup or resolver exception propagates as a bug report,
|
|
761
|
+
// matching offsetRegions' policy instead of being silently hidden by the fallback ladder.
|
|
762
|
+
export function _ladderRungs(regions, raw, delta, corners) {
|
|
763
|
+
return [
|
|
764
|
+
...[-1, 1].map((sign) => ({ name: `delta*(1${sign < 0 ? "-" : "+"}1e-9)`,
|
|
765
|
+
run: () => resolveOrRaw(rawOffset(regions, delta * (1 + sign * 1e-9), corners)) })),
|
|
766
|
+
...[4, 20].map((mult) => ({ name: `clusterTol*${mult}`,
|
|
767
|
+
run: () => resolveOffsetWinding(raw, { clusterTol: CLUSTER_TOL * mult }) })),
|
|
768
|
+
...[64, 256, 1024].map((segs) => ({ name: `polyline@${segs}`,
|
|
769
|
+
run: () => resolveOffsetWinding(raw.map((rg) => ({ outer: flattenRing(rg.outer, segs),
|
|
770
|
+
holes: rg.holes.map((h) => flattenRing(h, segs)) }))) })),
|
|
771
|
+
];
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// Returns null when every rung fails, which is the caller's signal to rethrow the original.
|
|
775
|
+
function chainFallback(regions, raw, delta, corners) {
|
|
776
|
+
for (const rung of _ladderRungs(regions, raw, delta, corners)) {
|
|
777
|
+
let out;
|
|
778
|
+
try {
|
|
779
|
+
out = rung.run();
|
|
780
|
+
} catch (err) {
|
|
781
|
+
// A rung may fail with the SAME unresolvable-arrangement signal the ladder exists
|
|
782
|
+
// for — move to the next rung. Anything else (the _splitRings clustering tripwire,
|
|
783
|
+
// a TypeError) is a bug report, exactly as offsetRegions' own policy says below;
|
|
784
|
+
// swallowing it here would defeat those tripwires' stated fail-at-the-source purpose
|
|
785
|
+
// (review finding).
|
|
786
|
+
if (err?.message === CHAIN_INCOMPLETE_MESSAGE) continue;
|
|
787
|
+
throw err;
|
|
788
|
+
}
|
|
789
|
+
if (out.length > 0) return out;
|
|
790
|
+
}
|
|
791
|
+
return null;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// Dilation is extensive: S ⊆ S+B. A resolver artifact that contains no point from any
|
|
795
|
+
// source outer therefore cannot be a real output component. Unlike an area cutoff this keeps
|
|
796
|
+
// arbitrarily small source components, and it is valid for every positive join style. Source
|
|
797
|
+
// boundary samples lie strictly inside their dilation once delta clears the numerical band.
|
|
798
|
+
function sourceBackedPositiveRegions(source, out, delta) {
|
|
799
|
+
if (delta <= SOURCE_DISK_TOL) return out;
|
|
800
|
+
const witnesses = source.flatMap((rg) => sampleRing(closeContourGap(rg.outer), VALIDATE_SEGS));
|
|
801
|
+
const entries = out.map((rg) => {
|
|
802
|
+
const outer = sampleRing(rg.outer, VALIDATE_SEGS);
|
|
803
|
+
const box = ringBox(outer);
|
|
804
|
+
const holeRings = rg.holes.map((h) => sampleRing(h, VALIDATE_SEGS));
|
|
805
|
+
const backed = witnesses.some((p) => p[0] >= box[0] && p[0] <= box[2]
|
|
806
|
+
&& p[1] >= box[1] && p[1] <= box[3] && pointInRing(p, outer)
|
|
807
|
+
&& !holeRings.some((h) => pointInRing(p, h)));
|
|
808
|
+
return { rg, outer, box, backed };
|
|
809
|
+
});
|
|
810
|
+
const kept = entries.filter((e) => e.backed);
|
|
811
|
+
// If numerical sampling cannot witness even one component, preserve the resolver result.
|
|
812
|
+
// The filter is allowed to remove proved source-less artifacts, never all caller geometry.
|
|
813
|
+
if (!kept.length || kept.length === entries.length) return out;
|
|
814
|
+
|
|
815
|
+
const result = kept.map((e) => ({ ...e.rg, holes: [...e.rg.holes] }));
|
|
816
|
+
for (const entry of entries.filter((e) => !e.backed)) for (const hole of entry.rg.holes) {
|
|
817
|
+
const p = hole.start;
|
|
818
|
+
const homes = kept.map((e, i) => ({ e, i })).filter(({ e }) =>
|
|
819
|
+
p[0] >= e.box[0] && p[0] <= e.box[2] && p[1] >= e.box[1] && p[1] <= e.box[3]
|
|
820
|
+
&& pointInRing(p, e.outer));
|
|
821
|
+
homes.sort((a, b) => Math.abs(ringArea(a.e.outer)) - Math.abs(ringArea(b.e.outer)));
|
|
822
|
+
if (homes.length) result[homes[0].i].holes.push(hole);
|
|
823
|
+
}
|
|
824
|
+
return result;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// Crossing clustering can snap both ends of a tiny curve run onto the same pool vertex.
|
|
828
|
+
// The run then survives as a closed loop attached at one point (or as a whole one-segment
|
|
829
|
+
// contour). It encloses only geometry inside the resolver's own 2*CLUSTER_TOL cluster-
|
|
830
|
+
// diameter uncertainty, but a downstream cap triangulator may bridge that loop across the
|
|
831
|
+
// face and expose the bridge as a bogus feature edge. Positive dilation may discard these
|
|
832
|
+
// sub-resolution counter loops: they are collapsed negative boundaries, never new material.
|
|
833
|
+
// Keep erosion unchanged; its tiny surviving islands have no equivalent source-domain proof.
|
|
834
|
+
function dropSubresolutionPositiveLoops(out, delta) {
|
|
835
|
+
if (delta <= 0) return out;
|
|
836
|
+
const radius = 2 * CLUSTER_TOL;
|
|
837
|
+
const clean = (contour) => {
|
|
838
|
+
const segments = [];
|
|
839
|
+
let from = contour.start;
|
|
840
|
+
for (const seg of contour.segments) {
|
|
841
|
+
const controls = [seg.via, seg.c1, seg.c2].filter(Boolean);
|
|
842
|
+
const clusteredLoop = dist(from, seg.to) <= 1e-12
|
|
843
|
+
&& controls.every((p) => dist(from, p) <= radius);
|
|
844
|
+
if (!clusteredLoop) segments.push(seg);
|
|
845
|
+
from = seg.to;
|
|
846
|
+
}
|
|
847
|
+
return segments.length ? { start: contour.start, segments } : null;
|
|
848
|
+
};
|
|
849
|
+
|
|
850
|
+
return out.flatMap((rg) => {
|
|
851
|
+
const outer = clean(rg.outer);
|
|
852
|
+
return outer ? [{ outer, holes: rg.holes.map(clean).filter(Boolean) }] : [];
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// Region-in / region-out offset: the engine behind Shape2D.offset on BOTH backends.
|
|
857
|
+
// Fast path: raw per-ring offsets that validate cleanly are returned as-is (lines/arcs
|
|
858
|
+
// exact). Cleanup path: anything dirty or invalid goes through resolveOffsetWinding
|
|
859
|
+
// (contour-winding.js), which computes the positive-winding region of the raw offset
|
|
860
|
+
// outline directly — no boolean engine, no degenerate-shape recovery heuristics. When THAT
|
|
861
|
+
// cannot close its arrangement, chainFallback above retries it a few ways before the failure
|
|
862
|
+
// is allowed to reach the caller.
|
|
863
|
+
export function offsetRegions(regions, delta, { corners = "round" } = {}) {
|
|
864
|
+
if (!["round", "chamfer", "sharp"].includes(corners))
|
|
865
|
+
throw new Error('Shape2D.offset: corners must be "round" | "chamfer" | "sharp"');
|
|
866
|
+
if (!Number.isFinite(delta)) throw new Error("Shape2D.offset: delta must be a finite number");
|
|
867
|
+
if (delta === 0) return JSON.parse(JSON.stringify(regions));
|
|
529
868
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
//
|
|
536
|
-
//
|
|
537
|
-
|
|
538
|
-
|
|
869
|
+
const first = rawOffset(regions, delta, corners);
|
|
870
|
+
let out;
|
|
871
|
+
try {
|
|
872
|
+
out = resolveOrRaw(first);
|
|
873
|
+
} catch (err) {
|
|
874
|
+
// Only the unclosable-arrangement failure has a measured degradation behind it. Anything
|
|
875
|
+
// else out of the resolver (a clustering regression, an implicitly-closed ring) is a bug
|
|
876
|
+
// report, not a case to paper over, and goes straight up.
|
|
877
|
+
if (err?.message !== CHAIN_INCOMPLETE_MESSAGE) throw err;
|
|
878
|
+
out = chainFallback(regions, first.raw, delta, corners);
|
|
879
|
+
if (out === null) throw err; // pinned message, unchanged, when nothing works
|
|
539
880
|
}
|
|
881
|
+
out = sourceBackedPositiveRegions(regions, out, delta);
|
|
882
|
+
out = dropSubresolutionPositiveLoops(out, delta);
|
|
540
883
|
if (out.length === 0) throw new Error("Shape2D.offset: offset collapses the shape (reduce |delta|)");
|
|
541
884
|
return out;
|
|
542
885
|
}
|