partforge 0.58.1 → 0.59.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 +76 -0
- package/docs/KERNEL-CONTRACT.md +114 -13
- package/package.json +1 -1
- package/src/framework/geometry/contour-offset.js +542 -0
- package/src/framework/geometry/contour-ops.js +2 -2
- 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 +29 -0
- package/src/framework/geometry/shape2d-regions.js +13 -104
- package/src/framework/geometry/shape2d.js +16 -12
|
@@ -0,0 +1,542 @@
|
|
|
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, resolveSelfRegions, booleanRegions } from "./paper-bridge.js";
|
|
12
|
+
import { cubicAt, splitCubic, jointTangents, SMOOTH_JOINT_DEG } from "./contour-ops.js";
|
|
13
|
+
import { tessellateContour, closeContourGap, reverseContour } from "./profile.js";
|
|
14
|
+
import { ringArea, pointInRing } from "./shape2d-regions.js";
|
|
15
|
+
|
|
16
|
+
export const OFFSET_TOL = 1e-3; // mm — max deviation of a cubic offset approximation
|
|
17
|
+
const MAX_DEPTH = 12; // cubic subdivision recursion cap
|
|
18
|
+
const JOIN_EPS = 1e-6; // endpoints closer than this are coincident
|
|
19
|
+
|
|
20
|
+
const VALIDATE_SEGS = 32;
|
|
21
|
+
const AREA_EPS = 1e-9;
|
|
22
|
+
|
|
23
|
+
const sub = (a, b) => [a[0] - b[0], a[1] - b[1]];
|
|
24
|
+
const add = (a, b) => [a[0] + b[0], a[1] + b[1]];
|
|
25
|
+
const scl = (v, s) => [v[0] * s, v[1] * s];
|
|
26
|
+
const cross = (a, b) => a[0] * b[1] - a[1] * b[0];
|
|
27
|
+
const dot = (a, b) => a[0] * b[0] + a[1] * b[1];
|
|
28
|
+
const len = (v) => Math.hypot(v[0], v[1]);
|
|
29
|
+
const norm = (v) => { const L = len(v) || 1; return [v[0] / L, v[1] / L]; };
|
|
30
|
+
const rightOf = ([tx, ty]) => [ty, -tx]; // unit right-of-travel normal
|
|
31
|
+
const dist = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]);
|
|
32
|
+
|
|
33
|
+
function offsetLine(from, to, delta) {
|
|
34
|
+
const n = scl(rightOf(norm(sub(to, from))), delta);
|
|
35
|
+
return { start: add(from, n), segments: [{ to: add(to, n) }], dirty: false };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function offsetArc(from, seg, delta) {
|
|
39
|
+
const c = arcCenterAndSweep(from, seg.via, seg.to);
|
|
40
|
+
if (!c) return offsetLine(from, seg.to, delta); // collinear via → straight
|
|
41
|
+
const { center, r, dA } = c;
|
|
42
|
+
// CCW sweep (dA>0): right-of-travel is the outward radial → r+delta; CW: inward → r-delta
|
|
43
|
+
const rNew = r + (dA >= 0 ? delta : -delta);
|
|
44
|
+
if (Math.abs(rNew) <= JOIN_EPS) {
|
|
45
|
+
// fully collapsed arc: bridge the offset endpoints with a line, let cleanup cope
|
|
46
|
+
const tanAt = (p) => { const rad = sub(p, center); return norm(dA >= 0 ? [-rad[1], rad[0]] : [rad[1], -rad[0]]); };
|
|
47
|
+
const q = (p) => add(p, scl(rightOf(tanAt(p)), delta));
|
|
48
|
+
return { start: q(from), segments: [{ to: q(seg.to) }], dirty: true };
|
|
49
|
+
}
|
|
50
|
+
// rNew < 0 lands every point on the opposite side of center — the inverted loop
|
|
51
|
+
// that stage-3 cleanup removes. Same projection formula either way.
|
|
52
|
+
const proj = (p) => add(center, scl(norm(sub(p, center)), rNew));
|
|
53
|
+
return { start: proj(from), segments: [{ via: proj(seg.via), to: proj(seg.to) }], dirty: rNew < 0 };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Tiller–Hanson single-piece offset of cubic (p0,c1,c2,p1): displace endpoints along
|
|
57
|
+
// their endpoint normals and the handle line by the normal of the c1→c2 chord, then
|
|
58
|
+
// accept only if sampled deviation stays within OFFSET_TOL; otherwise split at t=0.5.
|
|
59
|
+
// (Ported from paperjs-offset's offsetSegment/adaptiveOffsetCurve.)
|
|
60
|
+
function offsetCubic(p0, c1, c2, p1, delta, depth) {
|
|
61
|
+
const nz = (v) => (len(v) > 1e-9 ? v : null);
|
|
62
|
+
const t0 = norm(nz(sub(c1, p0)) ?? nz(sub(c2, p0)) ?? sub(p1, p0));
|
|
63
|
+
const t1 = norm(nz(sub(p1, c2)) ?? nz(sub(p1, c1)) ?? sub(p1, p0));
|
|
64
|
+
const off0 = scl(rightOf(t0), delta), off1 = scl(rightOf(t1), delta);
|
|
65
|
+
const hChord = nz(sub(c2, c1)) ?? sub(p1, p0);
|
|
66
|
+
const hN = scl(rightOf(norm(hChord)), delta);
|
|
67
|
+
const q0 = add(p0, off0), q1 = add(p1, off1);
|
|
68
|
+
const qc1 = add(c1, scl(add(hN, off0), 0.5)), qc2 = add(c2, scl(add(hN, off1), 0.5));
|
|
69
|
+
let ok = true;
|
|
70
|
+
for (const t of [0.25, 0.5, 0.75]) {
|
|
71
|
+
const d = dist(cubicAt(q0, qc1, qc2, q1, t), cubicAt(p0, c1, c2, p1, t));
|
|
72
|
+
if (Math.abs(d - Math.abs(delta)) > OFFSET_TOL) { ok = false; break; }
|
|
73
|
+
}
|
|
74
|
+
if (ok || depth >= MAX_DEPTH) return { start: q0, segments: [{ to: q1, c1: qc1, c2: qc2 }], dirty: !ok };
|
|
75
|
+
const [L, R] = splitCubic(p0, c1, c2, p1, 0.5);
|
|
76
|
+
const a = offsetCubic(L.p0, L.c1, L.c2, L.p1, delta, depth + 1);
|
|
77
|
+
const b = offsetCubic(R.p0, R.c1, R.c2, R.p1, delta, depth + 1);
|
|
78
|
+
return { start: a.start, segments: [...a.segments, ...b.segments], dirty: a.dirty || b.dirty };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// One IR segment (running from `from` to seg.to) → its raw offset piece.
|
|
82
|
+
export function _offsetSegment(from, seg, delta) {
|
|
83
|
+
if (seg.c1) return offsetCubic(from, seg.c1, seg.c2, seg.to, delta, 0);
|
|
84
|
+
if (seg.via) return offsetArc(from, seg, delta);
|
|
85
|
+
return offsetLine(from, seg.to, delta);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const MITER_LIMIT = 2;
|
|
89
|
+
|
|
90
|
+
// Intersection of the line through P (direction u) with the line through Q (direction v).
|
|
91
|
+
function lineIntersect(P, u, Q, v) {
|
|
92
|
+
const d = cross(u, v);
|
|
93
|
+
if (Math.abs(d) < 1e-12) return null;
|
|
94
|
+
const w = sub(Q, P);
|
|
95
|
+
return add(P, scl(u, cross(w, v) / d));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Segments bridging aEnd → bStart around `corner` on the gap side.
|
|
99
|
+
function joinSegs(corner, aEnd, bStart, inTan, outTan, delta, corners) {
|
|
100
|
+
if (corners === "chamfer") return [{ to: bStart }];
|
|
101
|
+
if (corners === "sharp") {
|
|
102
|
+
const X = lineIntersect(aEnd, inTan, bStart, outTan);
|
|
103
|
+
if (X && dist(X, corner) <= MITER_LIMIT * Math.abs(delta)) return [{ to: X }, { to: bStart }];
|
|
104
|
+
return [{ to: bStart }]; // miter-limit fallback = bevel
|
|
105
|
+
}
|
|
106
|
+
// round: exact arc about the corner, via on the displacement bisector
|
|
107
|
+
const d1 = sub(aEnd, corner), d2 = sub(bStart, corner);
|
|
108
|
+
let m = add(d1, d2);
|
|
109
|
+
if (len(m) < 1e-9) m = delta > 0 ? rightOf(norm(d1)) : scl(rightOf(norm(d1)), -1); // 180° turn
|
|
110
|
+
return [{ via: add(corner, scl(norm(m), Math.abs(delta))), to: bStart }];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Offset one explicitly-closed ring. Returns { contour, dirty }.
|
|
114
|
+
export function _offsetContour(contour, delta, corners) {
|
|
115
|
+
const pts = [contour.start, ...contour.segments.map((s) => s.to)];
|
|
116
|
+
// drop zero-length line segments (they carry no direction)
|
|
117
|
+
const keep = contour.segments.map((s, i) => s.c1 || s.via || dist(pts[i], s.to) > 1e-9);
|
|
118
|
+
const segs = contour.segments.filter((_, i) => keep[i]);
|
|
119
|
+
const froms = [];
|
|
120
|
+
{ let p = contour.start; for (const s of contour.segments) { froms.push(p); p = s.to; } }
|
|
121
|
+
const fromsKept = froms.filter((_, i) => keep[i]);
|
|
122
|
+
// NB: feed jointTangents the KEPT chain's start — if the first segment was dropped
|
|
123
|
+
// as zero-length, contour.start no longer heads the filtered ring.
|
|
124
|
+
const joints = jointTangents({ start: fromsKept[0] ?? contour.start, segments: segs });
|
|
125
|
+
const pieces = segs.map((s, i) => _offsetSegment(fromsKept[i], s, delta));
|
|
126
|
+
let dirty = pieces.some((p) => p.dirty);
|
|
127
|
+
const n = segs.length;
|
|
128
|
+
const joins = new Array(n).fill(null); // joins[i] bridges piece[i-1] → piece[i] at vertex i
|
|
129
|
+
|
|
130
|
+
for (let i = 0; i < n; i++) {
|
|
131
|
+
const prev = pieces[(i - 1 + n) % n], next = pieces[i];
|
|
132
|
+
const aEnd = prev.segments.at(-1).to, bStart = next.start;
|
|
133
|
+
const { point, inTan, outTan } = joints[i];
|
|
134
|
+
const turn = cross(inTan, outTan);
|
|
135
|
+
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
|
+
if (dist(aEnd, bStart) <= JOIN_EPS || turnDeg < SMOOTH_JOINT_DEG) continue; // smooth
|
|
137
|
+
if (turn * delta > 0) { joins[i] = joinSegs(point, aEnd, bStart, inTan, outTan, delta, corners); continue; }
|
|
138
|
+
// overlap side: trim when both neighbors are plain lines, else chord + dirty
|
|
139
|
+
const aSeg = prev.segments.at(-1), bSeg = next.segments[0];
|
|
140
|
+
if (!aSeg.via && !aSeg.c1 && !bSeg.via && !bSeg.c1) {
|
|
141
|
+
const X = lineIntersect(aEnd, inTan, bStart, outTan);
|
|
142
|
+
if (X) { aSeg.to = X; next.start = X; continue; } // exact trim, stays clean
|
|
143
|
+
}
|
|
144
|
+
joins[i] = [{ to: bStart }]; dirty = true;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Whole-ring collapse check: when delta exceeds the ring's own inradius, EVERY plain-line
|
|
148
|
+
// piece's trimmed direction reverses relative to its pre-offset direction — reflection
|
|
149
|
+
// through the collapse point preserves winding, so the reflected ring passes every other
|
|
150
|
+
// validity check there is (paper.js included: it's a genuinely simple polygon, just the
|
|
151
|
+
// wrong one). That whole-ring signal is reliable; a PER-PIECE version of it is not — it
|
|
152
|
+
// also fires on ordinary trims (acute barbs, narrow slots, non-square holes, 45° chamfers)
|
|
153
|
+
// that never reflected, and "fixing" those by un-trimming produces over-inclusive geometry
|
|
154
|
+
// instead of the correct, already-exact Task 1-4 result. So: only act when ALL plain-line
|
|
155
|
+
// pieces agree; when some but not all do, this is a normal partial trim — leave it alone.
|
|
156
|
+
// Critically, "the whole ring" means EVERY piece of the ring, not just its line pieces: a
|
|
157
|
+
// ring where lines are a minority (a mostly-arc disc with a small tab, say) can have every
|
|
158
|
+
// one of its few line pieces reverse while the ring as a whole is nowhere near collapsed —
|
|
159
|
+
// requiring lineReversals.length === n makes this a genuine whole-ring predicate again. An
|
|
160
|
+
// all-arc ring that truly collapses is still caught downstream: offsetArc already marks
|
|
161
|
+
// rNew<0 / fully-collapsed arcs dirty, routing to cleanup instead of a false fast-path pass.
|
|
162
|
+
// pieceDot[i] is the reversal signal (dot of post-trim vs pre-offset direction) for
|
|
163
|
+
// plain-line piece i, or null for arc/cubic pieces — a dot of zero here also flags a
|
|
164
|
+
// piece trimmed down to zero length, since a zero vector's dot with anything is 0.
|
|
165
|
+
const pieceDot = pieces.map((p, i) => {
|
|
166
|
+
if (p.segments.length !== 1 || p.segments[0].via || p.segments[0].c1) return null;
|
|
167
|
+
const origDir = sub(segs[i].to, fromsKept[i]);
|
|
168
|
+
const newDir = sub(p.segments[0].to, p.start);
|
|
169
|
+
return dot(newDir, origDir);
|
|
170
|
+
});
|
|
171
|
+
const lineReversals = pieceDot.filter((d) => d !== null).map((d) => d <= 0);
|
|
172
|
+
if (lineReversals.length === n && n > 0 && lineReversals.every(Boolean)) return { contour: null, dirty: true };
|
|
173
|
+
|
|
174
|
+
// Part 1 of the partial-reflection fix (task 5B): the whole-ring gate above only fires
|
|
175
|
+
// when EVERY plain-line piece reverses — by design, since a per-piece version of that
|
|
176
|
+
// gate also fires on ordinary trims that never reflected (see the comment above). But a
|
|
177
|
+
// ring that does NOT collapse wholesale can still carry one or two individually-reversed
|
|
178
|
+
// pieces (an over-offset corner that trimmed past its own neighbor) — that's the seed of
|
|
179
|
+
// the partial-reflection residual: those pieces survive into the ring and validateRawOffset
|
|
180
|
+
// can't see anything locally wrong with them. Delete them (not un-trim — un-trimming was
|
|
181
|
+
// rejected in an earlier round for other over-inclusive regressions) and re-link the
|
|
182
|
+
// surviving neighbors with a direct chord, marking the ring dirty so resolveSelfRegions
|
|
183
|
+
// gets a chance to untangle whatever that chord leaves behind. Every non-line piece
|
|
184
|
+
// always survives this pass; the whole-ring gate above already guarantees at least one
|
|
185
|
+
// piece survives here too.
|
|
186
|
+
const allIdx = pieces.map((_, i) => i);
|
|
187
|
+
const dropped = pieceDot.map((d) => d !== null && d <= 0);
|
|
188
|
+
if (!dropped.some(Boolean)) return { contour: assembleRing(pieces, joins, allIdx, n), dirty };
|
|
189
|
+
|
|
190
|
+
const keptIdx = allIdx.filter((i) => !dropped[i]);
|
|
191
|
+
if (keptIdx.length === 0) return { contour: null, dirty: true };
|
|
192
|
+
|
|
193
|
+
// Guard (review round 1, Important 3): deletion is unrecoverable — unlike a chord/dirty
|
|
194
|
+
// join, which resolveSelfRegions can still untangle downstream, a deleted piece is gone for
|
|
195
|
+
// good, so a bad deletion can turn perfectly good geometry into a false "offset collapses
|
|
196
|
+
// the shape" throw (measured: 18 new throws per 3000 random polygons; repro: a 9-gon at
|
|
197
|
+
// delta -2.79/chamfer with true eroded area 2.76). A raw *piece count* floor doesn't work as
|
|
198
|
+
// the discriminator — an arc-dominated ring can be legitimately reduced to a single
|
|
199
|
+
// surviving piece (this file's own storage convention stores a full circle as just two arcs;
|
|
200
|
+
// the keyed-bore regression test below reduces to one surviving arc + closing chord and that
|
|
201
|
+
// IS the correct answer). Routing through resolveSelfRegions doesn't work either — it was
|
|
202
|
+
// tried first, and rejected: it assumes the CCW/positive-area "outer" convention (an
|
|
203
|
+
// uninverted self-union is real material, an inverted one that flips positive is a
|
|
204
|
+
// discarded artifact), but _offsetContour has no idea here whether it's assembling an outer
|
|
205
|
+
// or a hole, and a perfectly valid CW/negative-area hole ring (like the keyed bore's) reads
|
|
206
|
+
// as "inverted" under that assumption and gets wrongly discarded. What actually
|
|
207
|
+
// distinguishes "deletion destroyed real geometry" from "deletion correctly trimmed it
|
|
208
|
+
// down" is winding-agnostic: is the assembled result still a SIMPLE (non-self-intersecting)
|
|
209
|
+
// ring with nonzero area — exactly what validateRawOffset's own segment-crossing test
|
|
210
|
+
// (ringSelfIntersects, defined below) already checks without any orientation assumption.
|
|
211
|
+
// Fall back to the un-deleted ring (still marked dirty, since a piece DID look reversed)
|
|
212
|
+
// when deletion isn't simple: cleanup gets a chance to untangle whatever the un-deleted
|
|
213
|
+
// piece leaves behind, which is strictly more recoverable than deletion's dead end.
|
|
214
|
+
const deleted = assembleRing(pieces, joins, keptIdx, n);
|
|
215
|
+
const deletedRing = tessellateContour(deleted, VALIDATE_SEGS);
|
|
216
|
+
const deletedArea = Math.abs(ringArea(deletedRing));
|
|
217
|
+
if (deletedArea <= AREA_EPS || ringSelfIntersects(deletedRing))
|
|
218
|
+
return { contour: assembleRing(pieces, joins, allIdx, n), dirty: true };
|
|
219
|
+
return { contour: deleted, dirty: true };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Assemble a closed ring from a subset of offset pieces (by index into `pieces`, in ring
|
|
223
|
+
// order), bridging consecutive survivors with their original join (when adjacent in the
|
|
224
|
+
// source ring) or a single chord (when one or more pieces were skipped in between — Part 1's
|
|
225
|
+
// deletion path). `idx = [0..n-1]` (every piece kept) reproduces the plain, no-deletion
|
|
226
|
+
// assembly exactly, since every piece is then "adjacent" to the next by construction.
|
|
227
|
+
function assembleRing(pieces, joins, idx, n) {
|
|
228
|
+
const out = [];
|
|
229
|
+
for (let k = 0; k < idx.length; k++) {
|
|
230
|
+
const i = idx[k];
|
|
231
|
+
out.push(...pieces[i].segments);
|
|
232
|
+
const nextI = idx[(k + 1) % idx.length];
|
|
233
|
+
if (nextI === (i + 1) % n) {
|
|
234
|
+
const j = joins[nextI];
|
|
235
|
+
if (j) out.push(...j);
|
|
236
|
+
} else {
|
|
237
|
+
// one or more pieces were skipped between i and nextI: bridge with a single chord
|
|
238
|
+
out.push({ to: [pieces[nextI].start[0], pieces[nextI].start[1]] });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const start = pieces[idx[0]].start;
|
|
242
|
+
const last = out.at(-1);
|
|
243
|
+
if (dist(last.to, start) <= JOIN_EPS) last.to = [start[0], start[1]]; // snap the closure exactly
|
|
244
|
+
else out.push({ to: [start[0], start[1]] });
|
|
245
|
+
return { start, segments: out };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function segsCross(a1, a2, b1, b2) {
|
|
249
|
+
const o = (p, q, r) => Math.sign((q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]));
|
|
250
|
+
const o1 = o(a1, a2, b1), o2 = o(a1, a2, b2), o3 = o(b1, b2, a1), o4 = o(b1, b2, a2);
|
|
251
|
+
return o1 !== o2 && o3 !== o4 && o1 !== 0 && o2 !== 0 && o3 !== 0 && o4 !== 0; // strict crossings only
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// True when two (non-adjacent) segments are collinear and overlap along more than a point.
|
|
255
|
+
// segsCross's strict-crossing test deliberately ignores collinear touches, but an offset
|
|
256
|
+
// ring can retrace the same line twice (a neck/hole pinched shut by delta past its own
|
|
257
|
+
// width flips the offset pieces from either side onto each other) — that produces exact
|
|
258
|
+
// duplicate or overlapping collinear edges with no transversal crossing anywhere, which
|
|
259
|
+
// segsCross alone can't see.
|
|
260
|
+
function segsOverlap(a1, a2, b1, b2) {
|
|
261
|
+
const d = sub(a2, a1);
|
|
262
|
+
const L = len(d);
|
|
263
|
+
if (L < 1e-9) return false;
|
|
264
|
+
const u = norm(d);
|
|
265
|
+
const perp = (p) => Math.abs(cross(u, sub(p, a1))); // distance off the a1→a2 line
|
|
266
|
+
if (perp(b1) > 1e-9 || perp(b2) > 1e-9) return false; // not collinear with a
|
|
267
|
+
const t = (p) => dot(sub(p, a1), u); // param along a1→a2
|
|
268
|
+
const [ta1, ta2] = [0, L];
|
|
269
|
+
const [tb1, tb2] = [t(b1), t(b2)].sort((x, y) => x - y);
|
|
270
|
+
return Math.min(ta2, tb2) - Math.max(ta1, tb1) > 1e-9; // overlap longer than a touch
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Axis-aligned bounding box of a ring / of one segment, and a disjointness test between
|
|
274
|
+
// two boxes. These exist purely as a rejection filter in front of the pairwise segment
|
|
275
|
+
// tests below: validateRawOffset runs on the geometry worker on EVERY offset (so on every
|
|
276
|
+
// parameter change), and its pairwise loops are O(R²·m²) in ring count and ring resolution
|
|
277
|
+
// with nothing to stop them. Measured on offsetRegions end to end, +0.5 round over N disjoint
|
|
278
|
+
// squares: 40 → 11.6 ms before this filter and 1.0 ms after, 100 → 64.0 / 1.3 ms, 200 → 326.2
|
|
279
|
+
// / 2.0 ms — clean quadratic before, effectively flat after. (Many-region TEXT is a smaller
|
|
280
|
+
// win — a 24-glyph string went 93 → 85 ms — because a glyph's raw offset ring self-intersects,
|
|
281
|
+
// so validation short-circuits early and paper.js cleanup dominates that case regardless.)
|
|
282
|
+
// Boxes never change the ANSWER — two segments whose boxes are disjoint cannot cross or
|
|
283
|
+
// overlap — so this is a pure short-circuit, not an approximation.
|
|
284
|
+
const BOX_EPS = 1e-9;
|
|
285
|
+
function ringBox(ring) {
|
|
286
|
+
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
|
287
|
+
for (const [x, y] of ring) {
|
|
288
|
+
if (x < x0) x0 = x; if (x > x1) x1 = x;
|
|
289
|
+
if (y < y0) y0 = y; if (y > y1) y1 = y;
|
|
290
|
+
}
|
|
291
|
+
return [x0, y0, x1, y1];
|
|
292
|
+
}
|
|
293
|
+
const boxesApart = (a, b) =>
|
|
294
|
+
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
|
+
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
|
+
|
|
297
|
+
function ringSelfIntersects(ring) {
|
|
298
|
+
const m = ring.length;
|
|
299
|
+
const boxes = [];
|
|
300
|
+
for (let i = 0; i < m; i++) boxes.push(segBox(ring[i], ring[(i + 1) % m]));
|
|
301
|
+
for (let i = 0; i < m; i++) for (let j = i + 2; j < m; j++) {
|
|
302
|
+
if (i === 0 && j === m - 1) continue; // adjacent via wraparound
|
|
303
|
+
if (boxesApart(boxes[i], boxes[j])) continue;
|
|
304
|
+
const a1 = ring[i], a2 = ring[(i + 1) % m], b1 = ring[j], b2 = ring[(j + 1) % m];
|
|
305
|
+
if (segsCross(a1, a2, b1, b2) || segsOverlap(a1, a2, b1, b2)) return true;
|
|
306
|
+
}
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Two DIFFERENT rings interfering. Like ringSelfIntersects this checks segsOverlap as well
|
|
311
|
+
// as segsCross: two rings can interfere without any transversal crossing at all when their
|
|
312
|
+
// boundaries run along the same line — exactly what two eroding holes that grew into each
|
|
313
|
+
// other produce under a sharp join, where every actual crossing lands on a shared vertex
|
|
314
|
+
// (o === 0, which segsCross deliberately ignores) and the only usable signal is the pair of
|
|
315
|
+
// collinear, partially-overlapping edges. Missing that let an invalid double-ring result
|
|
316
|
+
// pass the fast path and reach extrude, where even-odd fill turned the doubly-covered lens
|
|
317
|
+
// back into SOLID material inside the merged pocket.
|
|
318
|
+
function ringsCross(a, b, boxA = ringBox(a), boxB = ringBox(b)) {
|
|
319
|
+
if (boxesApart(boxA, boxB)) return false;
|
|
320
|
+
for (let i = 0; i < a.length; i++) {
|
|
321
|
+
const a1 = a[i], a2 = a[(i + 1) % a.length];
|
|
322
|
+
const bx = segBox(a1, a2);
|
|
323
|
+
if (boxesApart(bx, boxB)) continue;
|
|
324
|
+
for (let j = 0; j < b.length; j++) {
|
|
325
|
+
const b1 = b[j], b2 = b[(j + 1) % b.length];
|
|
326
|
+
if (boxesApart(bx, segBox(b1, b2))) continue;
|
|
327
|
+
if (segsCross(a1, a2, b1, b2) || segsOverlap(a1, a2, b1, b2)) return true;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Every point of `inner` strictly inside `outer`. Sampling ONE point (which this used to do)
|
|
334
|
+
// is satisfied by a hole that has grown most of the way out through its own outer boundary
|
|
335
|
+
// as long as its first vertex happens to still be inside — and a hole ring poking outside
|
|
336
|
+
// its outer is not merely inaccurate, it becomes real material: fed through toRegions() and
|
|
337
|
+
// CrossSection.ofPolygons(…,"EvenOdd"), the escaped part of the ring extrudes to a solid tab
|
|
338
|
+
// hanging off the plate below its own boundary.
|
|
339
|
+
function ringInsideRing(inner, outer, outerBox) {
|
|
340
|
+
for (const p of inner) {
|
|
341
|
+
if (p[0] < outerBox[0] || p[0] > outerBox[2] || p[1] < outerBox[1] || p[1] > outerBox[3]) return false;
|
|
342
|
+
if (!pointInRing(p, outer)) return false;
|
|
343
|
+
}
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// True when a raw offset result is already valid (fast path). Sampled at VALIDATE_SEGS.
|
|
348
|
+
export function validateRawOffset(regions) {
|
|
349
|
+
const sampled = regions.map((rg) => ({
|
|
350
|
+
outer: tessellateContour(rg.outer, VALIDATE_SEGS),
|
|
351
|
+
holes: rg.holes.map((h) => tessellateContour(h, VALIDATE_SEGS)),
|
|
352
|
+
}));
|
|
353
|
+
const allRings = [];
|
|
354
|
+
for (const rg of sampled) {
|
|
355
|
+
if (ringArea(rg.outer) <= AREA_EPS) return false; // flipped or collapsed outer
|
|
356
|
+
const outerBox = ringBox(rg.outer);
|
|
357
|
+
for (const h of rg.holes) {
|
|
358
|
+
if (ringArea(h) >= -AREA_EPS) return false; // flipped or collapsed hole
|
|
359
|
+
if (!ringInsideRing(h, rg.outer, outerBox)) return false; // hole escaped its outer
|
|
360
|
+
}
|
|
361
|
+
allRings.push(rg.outer, ...rg.holes);
|
|
362
|
+
}
|
|
363
|
+
for (const r of allRings) if (ringSelfIntersects(r)) return false;
|
|
364
|
+
const boxes = allRings.map(ringBox);
|
|
365
|
+
for (let i = 0; i < allRings.length; i++) for (let j = i + 1; j < allRings.length; j++)
|
|
366
|
+
if (ringsCross(allRings[i], allRings[j], boxes[i], boxes[j])) return false;
|
|
367
|
+
return true;
|
|
368
|
+
}
|
|
369
|
+
|
|
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
|
+
// Part 2 of the partial-reflection fix (task 5B) — REMOVED (review round 2). A global
|
|
477
|
+
// distance-from-source prune existed here through two review rounds, narrowing its scope each
|
|
478
|
+
// time (round 1: per-hole rather than whole-region, to stop swallowing legitimate hole
|
|
479
|
+
// merges/breakthroughs; round 2: exact closed-form line/arc distance and an adaptively-
|
|
480
|
+
// flattened cubic distance instead of a chord-length-bounded tolerance, to stop losing curved
|
|
481
|
+
// holes to discretization noise). Round 2 confirmed the prune's own justification was sound —
|
|
482
|
+
// the wide-arm-L-pocket case it exists to fix is a genuine defect (max inscribed circle in a
|
|
483
|
+
// 5-wide-arm L has radius 2.5 < delta 3, so the hole DOES fully vanish; a truth value is
|
|
484
|
+
// derivable and the pre-fix engine got it wrong) — but even with exact-geometry distances, a
|
|
485
|
+
// sweep of real glyph counters (uppercase/lowercase/digit counters on 10mm text at delta
|
|
486
|
+
// 0.1–0.5mm) still lost 36 of 76 entirely, all silent total-hole-loss, none
|
|
487
|
+
// recoverable by further tolerance tuning: the wide-L-pocket's own raw hole ring is ALREADY
|
|
488
|
+
// `dirty` from Part 1 before any distance check runs, and so are the failing glyph counters —
|
|
489
|
+
// there is no scoping condition (dirty vs not, tolerance size, source curve type) that
|
|
490
|
+
// distinguishes "prune this, it's really gone" from "don't prune this, cleanup will recover
|
|
491
|
+
// it" using only the raw, pre-cleanup ring. The prune's collateral (silently deleting real
|
|
492
|
+
// text counters at sub-millimetre offsets) is strictly worse than the single defect it fixes,
|
|
493
|
+
// so it's gone rather than shipped delicately tuned. The wide-L-pocket case is parked as a
|
|
494
|
+
// test.todo pending a proper oracle (Clipper2 or the OCCT backend) rather than this engine's
|
|
495
|
+
// own per-ring heuristics. See task-5B-report.md's round-2 section for the full sweep.
|
|
496
|
+
|
|
497
|
+
// Region-in / region-out offset: the engine behind Shape2D.offset on BOTH backends.
|
|
498
|
+
// Fast path: raw per-ring offsets that validate cleanly are returned as-is (lines/arcs
|
|
499
|
+
// exact). Cleanup path: anything dirty or invalid is self-united through paper.js, with
|
|
500
|
+
// one recovery attempted first (see splitAtDuplicateEdges) for the specific degenerate
|
|
501
|
+
// shape paper.js's boolean engine can't see as invalid.
|
|
502
|
+
export function offsetRegions(regions, delta, { corners = "round" } = {}) {
|
|
503
|
+
if (!["round", "chamfer", "sharp"].includes(corners))
|
|
504
|
+
throw new Error('Shape2D.offset: corners must be "round" | "chamfer" | "sharp"');
|
|
505
|
+
if (!Number.isFinite(delta)) throw new Error("Shape2D.offset: delta must be a finite number");
|
|
506
|
+
if (delta === 0) return JSON.parse(JSON.stringify(regions));
|
|
507
|
+
|
|
508
|
+
// _offsetContour signals a whole-ring collapse with contour:null (see its comment) — a
|
|
509
|
+
// dropped outer removes its whole region, a dropped hole is simply omitted. If everything
|
|
510
|
+
// drops, raw ends up [] and the "no regions survive" throw below fires naturally.
|
|
511
|
+
let dirty = false;
|
|
512
|
+
const raw = [];
|
|
513
|
+
for (const rg of regions) {
|
|
514
|
+
const o = _offsetContour(closeContourGap(rg.outer), delta, corners);
|
|
515
|
+
dirty = dirty || o.dirty;
|
|
516
|
+
if (!o.contour) continue;
|
|
517
|
+
const hs = rg.holes.map((h) => _offsetContour(closeContourGap(h), delta, corners));
|
|
518
|
+
// Only a SURVIVING hole's dirtiness can dirty the result. A hole that collapsed
|
|
519
|
+
// (contour === null) always reports dirty — that is how _offsetContour signals the drop —
|
|
520
|
+
// but the drop itself is a clean operation: the hole is simply gone and nothing else about
|
|
521
|
+
// the region moved. Folding that signal into `dirty` sent an otherwise-exact outer through
|
|
522
|
+
// paper.js for no reason, and paper.js has no arc primitive: a 10×10 square at +2 round
|
|
523
|
+
// came back line,cubic,line,cubic… with a collapsing 2×2 hole present and line,arc,line,arc
|
|
524
|
+
// without it. On OCCT that is the difference between B_SPLINE and CIRCLE in the exported
|
|
525
|
+
// STEP — exact curve fidelity lost to a hole that isn't even in the output.
|
|
526
|
+
dirty = dirty || hs.some((h) => h.contour && h.dirty);
|
|
527
|
+
raw.push({ outer: o.contour, holes: hs.filter((h) => h.contour).map((h) => h.contour) });
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
let out = (!dirty && validateRawOffset(raw)) ? raw : null;
|
|
531
|
+
if (!out) {
|
|
532
|
+
const split = splitPinchedRegions(raw);
|
|
533
|
+
// `split` can be [] (every piece came back CW, i.e. a spurious artifact rather than real
|
|
534
|
+
// material) — validateRawOffset([]) is vacuously true, so an explicit length check is
|
|
535
|
+
// required or a fully-collapsed split silently short-circuits past cleanup and this
|
|
536
|
+
// throws "collapses the shape" even when cleanup would find real area.
|
|
537
|
+
const cand = split && split.length > 0 ? split : raw;
|
|
538
|
+
out = cand !== raw && validateRawOffset(cand) ? cand : cleanupRegions(cand);
|
|
539
|
+
}
|
|
540
|
+
if (out.length === 0) throw new Error("Shape2D.offset: offset collapses the shape (reduce |delta|)");
|
|
541
|
+
return out;
|
|
542
|
+
}
|
|
@@ -239,7 +239,7 @@ const normalize2 = ([x, y]) => { const L = Math.hypot(x, y) || 1; return [x / L,
|
|
|
239
239
|
const rot90 = ([x, y]) => [-y, x];
|
|
240
240
|
const addScaled = (p, v, s) => [p[0] + v[0] * s, p[1] + v[1] * s];
|
|
241
241
|
|
|
242
|
-
function cubicAt(p0, c1, c2, p1, t) {
|
|
242
|
+
export function cubicAt(p0, c1, c2, p1, t) {
|
|
243
243
|
const u = 1 - t;
|
|
244
244
|
return [0, 1].map((k) => u * u * u * p0[k] + 3 * u * u * t * c1[k] + 3 * u * t * t * c2[k] + t * t * t * p1[k]);
|
|
245
245
|
}
|
|
@@ -248,7 +248,7 @@ function cubicDeriv(p0, c1, c2, p1, t) {
|
|
|
248
248
|
return [0, 1].map((k) => 3 * u * u * (c1[k] - p0[k]) + 6 * u * t * (c2[k] - c1[k]) + 3 * t * t * (p1[k] - c2[k]));
|
|
249
249
|
}
|
|
250
250
|
// Exact de Casteljau split of cubic (p0,c1,c2,p1) at t → two exact cubic pieces.
|
|
251
|
-
function splitCubic(p0, c1, c2, p1, t) {
|
|
251
|
+
export function splitCubic(p0, c1, c2, p1, t) {
|
|
252
252
|
const lerp = (a, b) => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
|
|
253
253
|
const p01 = lerp(p0, c1), p12 = lerp(c1, c2), p23 = lerp(c2, p1);
|
|
254
254
|
const p012 = lerp(p01, p12), p123 = lerp(p12, p23);
|