partforge 0.55.1 → 0.56.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 +110 -2
- package/docs/ERROR-PATTERNS.md +28 -2
- package/docs/KERNEL-CONTRACT.md +107 -30
- package/package.json +1 -1
- package/src/app-gasket.js +14 -0
- package/src/framework/backend-select.js +5 -2
- package/src/framework/geometry/contour-ops.js +1090 -0
- package/src/framework/geometry/curve-fill.js +1 -59
- package/src/framework/geometry/kernel.js +24 -6
- package/src/framework/geometry/manifold-backend.js +72 -62
- package/src/framework/geometry/occt-backend.js +121 -84
- package/src/framework/geometry/paper-bridge.js +224 -0
- package/src/framework/geometry/polygon.js +8 -0
- package/src/framework/geometry/probe.js +41 -16
- package/src/framework/geometry/profile.js +51 -0
- package/src/framework/geometry/shape2d-regions.js +100 -0
- package/src/framework/geometry/shape2d.js +91 -0
- package/src/framework/lint/rules-build.js +5 -2
- package/src/gasket-worker.js +3 -0
- package/src/parts/gasket.js +111 -0
- package/types/geometry.d.ts +121 -2
- package/types/kernel.d.ts +66 -6
|
@@ -0,0 +1,1090 @@
|
|
|
1
|
+
import { isPathContour, tessellateContour, pointsToContour, reverseContour, closeContourGap } from "./profile.js";
|
|
2
|
+
import { ringArea, pointInRing } from "./shape2d-regions.js";
|
|
3
|
+
import { arcToCubicSegments, arcCenterAndSweep, paperScope, toPaperPath, toContour, toOpenContour } from "./paper-bridge.js";
|
|
4
|
+
|
|
5
|
+
// Re-exported so existing importers (test/contour-ops-lift.test.js and others) keep
|
|
6
|
+
// working unchanged — the definitions live in profile.js (the contour-IR home) so
|
|
7
|
+
// paper-bridge.js can use them without importing this module (which already imports
|
|
8
|
+
// paper-bridge.js; importing back would cycle).
|
|
9
|
+
export { pointsToContour, reverseContour };
|
|
10
|
+
|
|
11
|
+
const WINDING_SEGS = 64; // tessellation LOD for orientation/containment sampling
|
|
12
|
+
|
|
13
|
+
const isPointList = (x) => Array.isArray(x) && x.length > 0 && Array.isArray(x[0]);
|
|
14
|
+
// pointsToContour always closes explicitly; a raw {start,segments} contour (hand-authored
|
|
15
|
+
// via pathProfile, or handed back from a Shape2D's own stored regions) might not —
|
|
16
|
+
// closeContourGap is a no-op when it's already closed, so every ring liftProfile hands to
|
|
17
|
+
// contour-ops' corner/transform/query functions is guaranteed explicitly closed.
|
|
18
|
+
const liftContour = (c) => (isPointList(c) ? pointsToContour(c) : closeContourGap(c));
|
|
19
|
+
|
|
20
|
+
export function liftProfile(input) {
|
|
21
|
+
if (input && input._shape2d) return { kind: "regions", regions: input.toContours() };
|
|
22
|
+
if (isPointList(input)) return { kind: "points", regions: [{ outer: pointsToContour(input), holes: [] }] };
|
|
23
|
+
if (isPathContour(input)) return { kind: "contour", regions: [{ outer: closeContourGap(input), holes: [] }] };
|
|
24
|
+
if (Array.isArray(input) && input.every((r) => r && r.outer))
|
|
25
|
+
return { kind: "regions", regions: input.map((r) => ({ outer: liftContour(r.outer), holes: (r.holes ?? []).map(liftContour) })) };
|
|
26
|
+
if (input && input.outer)
|
|
27
|
+
return { kind: "region", regions: [{ outer: liftContour(input.outer), holes: (input.holes ?? []).map(liftContour) }] };
|
|
28
|
+
throw new Error("contour-ops: input must be [[x,y],…], a {start,segments} contour, {outer,holes}, or a region array");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function restoreProfile(kind, regions) {
|
|
32
|
+
if (kind === "regions") return regions;
|
|
33
|
+
if (kind === "region") return regions[0];
|
|
34
|
+
const outer = regions[0].outer;
|
|
35
|
+
if (kind === "contour") return outer;
|
|
36
|
+
// "points": only restorable if every segment stayed a straight line
|
|
37
|
+
if (outer.segments.every((s) => !s.c1 && !s.via)) {
|
|
38
|
+
const pts = [outer.start, ...outer.segments.map((s) => s.to)];
|
|
39
|
+
const [fx, fy] = pts[0], [lx, ly] = pts[pts.length - 1];
|
|
40
|
+
if (Math.hypot(lx - fx, ly - fy) < 1e-9) pts.pop(); // drop the closing duplicate
|
|
41
|
+
return pts;
|
|
42
|
+
}
|
|
43
|
+
return outer; // curves were introduced — upgrade to a contour
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const contourIsCCW = (c) => ringArea(tessellateContour(c, WINDING_SEGS)) >= 0;
|
|
47
|
+
|
|
48
|
+
export function ensureRegionWinding(region) {
|
|
49
|
+
return {
|
|
50
|
+
outer: contourIsCCW(region.outer) ? region.outer : reverseContour(region.outer),
|
|
51
|
+
holes: region.holes.map((h) => (contourIsCCW(h) ? reverseContour(h) : h)),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Affine transform core: M = [a, b, c, d, tx, ty], p' = [a·x + c·y + tx, b·x + d·y + ty]
|
|
56
|
+
const apply = (M, [x, y]) => [M[0] * x + M[2] * y + M[4], M[1] * x + M[3] * y + M[5]];
|
|
57
|
+
const isSimilarity = (M) => {
|
|
58
|
+
const [a, b, c, d] = M;
|
|
59
|
+
return Math.abs(a * a + b * b - (c * c + d * d)) < 1e-9 && Math.abs(a * c + b * d) < 1e-9;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
function transformContour(contour, M) {
|
|
63
|
+
const similar = isSimilarity(M);
|
|
64
|
+
const segments = [];
|
|
65
|
+
let prev = contour.start;
|
|
66
|
+
for (const s of contour.segments) {
|
|
67
|
+
if (s.via && !similar) { // arc under a non-similarity map → cubics first
|
|
68
|
+
for (const piece of arcToCubicSegments(prev, s.via, s.to)) segments.push(piece);
|
|
69
|
+
} else segments.push(s);
|
|
70
|
+
prev = s.to;
|
|
71
|
+
}
|
|
72
|
+
return { start: apply(M, contour.start), segments: segments.map((s) => {
|
|
73
|
+
const m = { to: apply(M, s.to) };
|
|
74
|
+
if (s.via) m.via = apply(M, s.via);
|
|
75
|
+
if (s.c1) { m.c1 = apply(M, s.c1); m.c2 = apply(M, s.c2); }
|
|
76
|
+
return m;
|
|
77
|
+
}) };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function transformProfile(input, M) {
|
|
81
|
+
const { kind, regions } = liftProfile(input);
|
|
82
|
+
const flips = M[0] * M[3] - M[1] * M[2] < 0;
|
|
83
|
+
let out = regions.map((rg) => ({ outer: transformContour(rg.outer, M), holes: rg.holes.map((h) => transformContour(h, M)) }));
|
|
84
|
+
if (flips) {
|
|
85
|
+
out = (kind === "region" || kind === "regions")
|
|
86
|
+
? out.map(ensureRegionWinding)
|
|
87
|
+
// bare inputs: restore the ORIGINAL orientation sense of each ring
|
|
88
|
+
: out.map((rg, i) => ({
|
|
89
|
+
outer: contourIsCCW(rg.outer) === contourIsCCW(regions[i].outer) ? rg.outer : reverseContour(rg.outer),
|
|
90
|
+
holes: rg.holes,
|
|
91
|
+
}));
|
|
92
|
+
}
|
|
93
|
+
return restoreProfile(kind, out);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const translateProfile = (input, [dx, dy]) => transformProfile(input, [1, 0, 0, 1, dx, dy]);
|
|
97
|
+
export function rotateProfile(input, deg, center = [0, 0]) {
|
|
98
|
+
const t = (deg * Math.PI) / 180, c = Math.cos(t), s = Math.sin(t), [cx, cy] = center;
|
|
99
|
+
return transformProfile(input, [c, s, -s, c, cx - c * cx + s * cy, cy - s * cx - c * cy]);
|
|
100
|
+
}
|
|
101
|
+
export function scaleProfile(input, s, center = [0, 0]) {
|
|
102
|
+
const [sx, sy] = Array.isArray(s) ? s : [s, s];
|
|
103
|
+
if (!(sx !== 0 && sy !== 0) || !Number.isFinite(sx) || !Number.isFinite(sy))
|
|
104
|
+
throw new Error("scaleProfile: scale factors must be finite and non-zero");
|
|
105
|
+
const [cx, cy] = center;
|
|
106
|
+
return transformProfile(input, [sx, 0, 0, sy, cx - sx * cx, cy - sy * cy]);
|
|
107
|
+
}
|
|
108
|
+
export function mirrorProfile(input, axis) {
|
|
109
|
+
if (axis === "x") return transformProfile(input, [1, 0, 0, -1, 0, 0]);
|
|
110
|
+
if (axis === "y") return transformProfile(input, [-1, 0, 0, 1, 0, 0]);
|
|
111
|
+
const { point: [px, py], dir: [ux0, uy0] } = axis;
|
|
112
|
+
const L = Math.hypot(ux0, uy0);
|
|
113
|
+
if (!(L > 0)) throw new Error('mirrorProfile: axis must be "x", "y", or {point, dir} with a non-zero dir');
|
|
114
|
+
const ux = ux0 / L, uy = uy0 / L;
|
|
115
|
+
const a = ux * ux - uy * uy, b = 2 * ux * uy; // reflection across line through point along dir
|
|
116
|
+
return transformProfile(input, [a, b, b, -a, px - a * px - b * py, py - b * px + a * py]);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Corner model ────────────────────────────────────────────────────────────
|
|
120
|
+
// profileCorners() walks a contour's joints and reports each non-smooth one: the interior
|
|
121
|
+
// angle, whether it's convex (material-relative — see below), and the segment kinds either
|
|
122
|
+
// side of it. jointTangents() is the shared per-vertex tangent computation, reused by Task 6.
|
|
123
|
+
|
|
124
|
+
export const SMOOTH_JOINT_DEG = 1;
|
|
125
|
+
|
|
126
|
+
// Unit tangent of segment `s` (from `from`) at its start (dir=+1) or end (dir=-1 → arrival direction).
|
|
127
|
+
function segTangent(from, s, atStart) {
|
|
128
|
+
const norm = ([x, y]) => { const L = Math.hypot(x, y) || 1; return [x / L, y / L]; };
|
|
129
|
+
if (s.c1) {
|
|
130
|
+
if (atStart) {
|
|
131
|
+
const d = [s.c1[0] - from[0], s.c1[1] - from[1]];
|
|
132
|
+
return norm(Math.hypot(d[0], d[1]) > 1e-9 ? d : [s.c2[0] - from[0], s.c2[1] - from[1]]);
|
|
133
|
+
}
|
|
134
|
+
const d = [s.to[0] - s.c2[0], s.to[1] - s.c2[1]];
|
|
135
|
+
return norm(Math.hypot(d[0], d[1]) > 1e-9 ? d : [s.to[0] - s.c1[0], s.to[1] - s.c1[1]]);
|
|
136
|
+
}
|
|
137
|
+
if (s.via) {
|
|
138
|
+
// tangent ⊥ radius, oriented along the sweep (recover center like arcToCubicSegments)
|
|
139
|
+
const c = arcCenterAndSweep(from, s.via, s.to); // {center:[x,y], dA} or null
|
|
140
|
+
if (!c) return norm([s.to[0] - from[0], s.to[1] - from[1]]);
|
|
141
|
+
const p = atStart ? from : s.to;
|
|
142
|
+
const r = [p[0] - c.center[0], p[1] - c.center[1]];
|
|
143
|
+
const t = c.dA >= 0 ? [-r[1], r[0]] : [r[1], -r[0]];
|
|
144
|
+
return norm(t);
|
|
145
|
+
}
|
|
146
|
+
return norm([s.to[0] - from[0], s.to[1] - from[1]]);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function jointTangents(contour) { // per vertex i: tangent arriving at and leaving vertex i
|
|
150
|
+
const n = contour.segments.length;
|
|
151
|
+
const pts = [contour.start, ...contour.segments.map((s) => s.to)];
|
|
152
|
+
return contour.segments.map((_, i) => {
|
|
153
|
+
const prevSeg = contour.segments[(i - 1 + n) % n];
|
|
154
|
+
const prevFrom = pts[(i - 1 + n) % n];
|
|
155
|
+
return {
|
|
156
|
+
point: pts[i],
|
|
157
|
+
inTan: segTangent(prevFrom, prevSeg, false),
|
|
158
|
+
outTan: segTangent(pts[i], contour.segments[i], true),
|
|
159
|
+
};
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const segType = (s) => (s.c1 ? "cubic" : s.via ? "arc" : "line");
|
|
164
|
+
|
|
165
|
+
function contourCorners(contour) {
|
|
166
|
+
const ccw = contourIsCCW(contour);
|
|
167
|
+
const n = contour.segments.length;
|
|
168
|
+
const out = [];
|
|
169
|
+
jointTangents(contour).forEach(({ point, inTan, outTan }, i) => {
|
|
170
|
+
const cross = inTan[0] * outTan[1] - inTan[1] * outTan[0];
|
|
171
|
+
const dot = Math.min(1, Math.max(-1, inTan[0] * outTan[0] + inTan[1] * outTan[1]));
|
|
172
|
+
const turnDeg = (Math.atan2(Math.abs(cross), dot) * 180) / Math.PI;
|
|
173
|
+
if (turnDeg < SMOOTH_JOINT_DEG) return;
|
|
174
|
+
const leftTurn = cross > 0;
|
|
175
|
+
out.push({
|
|
176
|
+
index: i, point: [point[0], point[1]],
|
|
177
|
+
interiorAngleDeg: ccw === leftTurn ? 180 - turnDeg : 180 + turnDeg,
|
|
178
|
+
convex: leftTurn === ccw,
|
|
179
|
+
segTypes: [segType(contour.segments[(i - 1 + n) % n]), segType(contour.segments[i])],
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function profileCorners(input) {
|
|
186
|
+
const { kind, regions } = liftProfile(input);
|
|
187
|
+
if (kind === "points" || kind === "contour") return contourCorners(regions[0].outer);
|
|
188
|
+
const out = [];
|
|
189
|
+
regions.forEach((rg, regionIndex) => {
|
|
190
|
+
for (const c of contourCorners(rg.outer)) out.push({ regionIndex, ring: "outer", ...c });
|
|
191
|
+
rg.holes.forEach((h, hi) => { for (const c of contourCorners(h)) out.push({ regionIndex, ring: { hole: hi }, ...c }); });
|
|
192
|
+
});
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── Fillet / chamfer ─────────────────────────────────────────────────────────
|
|
197
|
+
// Resolve opts.corners against a flat corner list (contourCorners()'s order, or
|
|
198
|
+
// profileCorners()'s flattened order for region/regions input — {indices} always
|
|
199
|
+
// indexes this array POSITIONALLY, matching profileCorners' documented contract).
|
|
200
|
+
// r/dist may be an array paired positionally with {indices}; every other selector
|
|
201
|
+
// broadcasts the scalar to every match. Throws when nothing matches.
|
|
202
|
+
function resolveCornerSelector(corners, param, opts, label) {
|
|
203
|
+
const sel = (opts && opts.corners) ?? "all";
|
|
204
|
+
const isArrayParam = Array.isArray(param);
|
|
205
|
+
if (isArrayParam && !(sel && Array.isArray(sel.indices)))
|
|
206
|
+
throw new Error(`${label}: a per-corner radius array requires a {corners: {indices}} selector`);
|
|
207
|
+
let picked;
|
|
208
|
+
if (sel === "all") picked = corners.map((corner) => ({ corner, param }));
|
|
209
|
+
else if (sel === "convex") picked = corners.filter((c) => c.convex).map((corner) => ({ corner, param }));
|
|
210
|
+
else if (sel === "concave") picked = corners.filter((c) => !c.convex).map((corner) => ({ corner, param }));
|
|
211
|
+
else if (sel && Array.isArray(sel.indices)) {
|
|
212
|
+
const perCorner = isArrayParam ? param : null;
|
|
213
|
+
if (perCorner && perCorner.length !== sel.indices.length)
|
|
214
|
+
throw new Error(`${label}: per-corner radius array has ${perCorner.length} entries but {indices} has ${sel.indices.length}`);
|
|
215
|
+
picked = sel.indices
|
|
216
|
+
.map((idx, j) => ({ corner: corners[idx], param: perCorner ? perCorner[j] : param }))
|
|
217
|
+
.filter((p) => p.corner);
|
|
218
|
+
} else if (sel && Array.isArray(sel.near)) {
|
|
219
|
+
const [nx, ny] = sel.near;
|
|
220
|
+
const count = sel.count ?? 1;
|
|
221
|
+
const distSq = (c) => (c.point[0] - nx) ** 2 + (c.point[1] - ny) ** 2;
|
|
222
|
+
picked = corners.slice().sort((a, b) => distSq(a) - distSq(b)).slice(0, count).map((corner) => ({ corner, param }));
|
|
223
|
+
} else picked = [];
|
|
224
|
+
if (picked.length === 0) throw new Error(`${label}: no corner matched selector ${JSON.stringify(sel)}`);
|
|
225
|
+
return picked;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const roundNice = (x) => Math.round(x * 1e6) / 1e6;
|
|
229
|
+
|
|
230
|
+
// ── Curve-adjacent corners (Task 7) ─────────────────────────────────────────
|
|
231
|
+
// Line-line corners keep buildCornerOpRing's exact closed-form path below.
|
|
232
|
+
// A corner with at least one curved (cubic/arc) neighbor goes through this
|
|
233
|
+
// numeric machinery instead: a 2-D evaluator per segment (`at(t)`/`tan(t)`),
|
|
234
|
+
// a damped-Newton tangency solver for fillet, and an arc-length solver (via
|
|
235
|
+
// paper.js) for chamfer. Both trim their curved neighbor exactly via
|
|
236
|
+
// de Casteljau splitting (cubic) or angle interpolation (arc).
|
|
237
|
+
|
|
238
|
+
const normalize2 = ([x, y]) => { const L = Math.hypot(x, y) || 1; return [x / L, y / L]; };
|
|
239
|
+
const rot90 = ([x, y]) => [-y, x];
|
|
240
|
+
const addScaled = (p, v, s) => [p[0] + v[0] * s, p[1] + v[1] * s];
|
|
241
|
+
|
|
242
|
+
function cubicAt(p0, c1, c2, p1, t) {
|
|
243
|
+
const u = 1 - t;
|
|
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
|
+
}
|
|
246
|
+
function cubicDeriv(p0, c1, c2, p1, t) {
|
|
247
|
+
const u = 1 - t;
|
|
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
|
+
}
|
|
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) {
|
|
252
|
+
const lerp = (a, b) => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
|
|
253
|
+
const p01 = lerp(p0, c1), p12 = lerp(c1, c2), p23 = lerp(c2, p1);
|
|
254
|
+
const p012 = lerp(p01, p12), p123 = lerp(p12, p23);
|
|
255
|
+
const p0123 = lerp(p012, p123);
|
|
256
|
+
return [{ p0, c1: p01, c2: p012, p1: p0123 }, { p0: p0123, c1: p123, c2: p23, p1 }];
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Evaluable curve E = {at(t), tan(t)} for a segment `seg` running from `from`
|
|
260
|
+
// (t=0) to seg.to (t=1). Mirrors segTangent's arc-tangent recovery exactly.
|
|
261
|
+
function curveEvaluator(from, seg) {
|
|
262
|
+
if (seg.c1) {
|
|
263
|
+
const p0 = from, c1 = seg.c1, c2 = seg.c2, p1 = seg.to;
|
|
264
|
+
return { at: (t) => cubicAt(p0, c1, c2, p1, t), tan: (t) => normalize2(cubicDeriv(p0, c1, c2, p1, t)) };
|
|
265
|
+
}
|
|
266
|
+
if (seg.via) {
|
|
267
|
+
const c = arcCenterAndSweep(from, seg.via, seg.to);
|
|
268
|
+
if (c) {
|
|
269
|
+
const a0 = Math.atan2(from[1] - c.center[1], from[0] - c.center[0]);
|
|
270
|
+
return {
|
|
271
|
+
at: (t) => { const a = a0 + c.dA * t; return [c.center[0] + c.r * Math.cos(a), c.center[1] + c.r * Math.sin(a)]; },
|
|
272
|
+
tan: (t) => { const a = a0 + c.dA * t, rx = Math.cos(a), ry = Math.sin(a); return c.dA >= 0 ? [-ry, rx] : [ry, -rx]; },
|
|
273
|
+
};
|
|
274
|
+
} // collinear (degenerate) triple → fall through to line below
|
|
275
|
+
}
|
|
276
|
+
const p0 = from, p1 = seg.to, d = normalize2([p1[0] - p0[0], p1[1] - p0[1]]);
|
|
277
|
+
return { at: (t) => [p0[0] + (p1[0] - p0[0]) * t, p0[1] + (p1[1] - p0[1]) * t], tan: () => d };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Trim segment `seg` (running `from` → seg.to) to the sub-range [tStart,tEnd]
|
|
281
|
+
// of its own parameterization, returning {from, seg} for the kept portion.
|
|
282
|
+
// Cubic: two exact de Casteljau splits. Arc: angle interpolation, `via`
|
|
283
|
+
// recomputed at the kept sweep's angular midpoint. Line: trivial endpoints.
|
|
284
|
+
function trimSegment(from, seg, tStart, tEnd) {
|
|
285
|
+
if (seg.c1) {
|
|
286
|
+
let cur = { p0: from, c1: seg.c1, c2: seg.c2, p1: seg.to };
|
|
287
|
+
if (tStart > 1e-12) { cur = splitCubic(cur.p0, cur.c1, cur.c2, cur.p1, tStart)[1]; }
|
|
288
|
+
const localEnd = tStart > 1e-12 ? (tEnd - tStart) / (1 - tStart) : tEnd;
|
|
289
|
+
if (localEnd < 1 - 1e-12) cur = splitCubic(cur.p0, cur.c1, cur.c2, cur.p1, localEnd)[0];
|
|
290
|
+
return { from: cur.p0, seg: { to: cur.p1, c1: cur.c1, c2: cur.c2 } };
|
|
291
|
+
}
|
|
292
|
+
if (seg.via) {
|
|
293
|
+
const c = arcCenterAndSweep(from, seg.via, seg.to);
|
|
294
|
+
if (c) {
|
|
295
|
+
const a0 = Math.atan2(from[1] - c.center[1], from[0] - c.center[0]);
|
|
296
|
+
const pointAt = (a) => [c.center[0] + c.r * Math.cos(a), c.center[1] + c.r * Math.sin(a)];
|
|
297
|
+
const aS = a0 + c.dA * tStart, aE = a0 + c.dA * tEnd;
|
|
298
|
+
return { from: pointAt(aS), seg: { to: pointAt(aE), via: pointAt((aS + aE) / 2) } };
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
const p0 = from, p1 = seg.to;
|
|
302
|
+
const at = (t) => [p0[0] + (p1[0] - p0[0]) * t, p0[1] + (p1[1] - p0[1]) * t];
|
|
303
|
+
return { from: at(tStart), seg: { to: at(tEnd) } };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const T_LO = 1e-6, T_HI = 1 - 1e-6;
|
|
307
|
+
const clampT = (v) => Math.min(T_HI, Math.max(T_LO, v));
|
|
308
|
+
const pinnedT = (t) => t[0] <= T_LO + 1e-12 || t[0] >= T_HI - 1e-12 || t[1] <= T_LO + 1e-12 || t[1] >= T_HI - 1e-12;
|
|
309
|
+
|
|
310
|
+
// Damped Newton on F: R² → R² (numeric Jacobian, h=1e-6), 40 iterations, step
|
|
311
|
+
// clamp 0.25, halving while |F| doesn't decrease (max 8 halvings). Returns
|
|
312
|
+
// {t, pinned} on convergence (|F| < 1e-9·max(1,r)) or null.
|
|
313
|
+
function newton2D(F, t0, r) {
|
|
314
|
+
const h = 1e-6, tol = 1e-9 * Math.max(1, r);
|
|
315
|
+
let t = [clampT(t0[0]), clampT(t0[1])];
|
|
316
|
+
let Fv = F(t), normF = Math.hypot(Fv[0], Fv[1]);
|
|
317
|
+
for (let iter = 0; iter < 40; iter++) {
|
|
318
|
+
if (normF < tol) return { t, pinned: pinnedT(t) };
|
|
319
|
+
const Fx = F([t[0] + h, t[1]]), Fy = F([t[0], t[1] + h]);
|
|
320
|
+
const J00 = (Fx[0] - Fv[0]) / h, J10 = (Fx[1] - Fv[1]) / h;
|
|
321
|
+
const J01 = (Fy[0] - Fv[0]) / h, J11 = (Fy[1] - Fv[1]) / h;
|
|
322
|
+
const det = J00 * J11 - J01 * J10;
|
|
323
|
+
if (Math.abs(det) < 1e-300) break;
|
|
324
|
+
// Cramer's rule: [J00 J01; J10 J11]·delta = -Fv
|
|
325
|
+
let delta = [(-Fv[0] * J11 + Fv[1] * J01) / det, (J00 * -Fv[1] - J10 * -Fv[0]) / det];
|
|
326
|
+
delta = delta.map((d) => Math.max(-0.25, Math.min(0.25, d)));
|
|
327
|
+
let tNew = [clampT(t[0] + delta[0]), clampT(t[1] + delta[1])];
|
|
328
|
+
let FvNew = F(tNew), normNew = Math.hypot(FvNew[0], FvNew[1]);
|
|
329
|
+
for (let halvings = 0; normNew >= normF && halvings < 8; halvings++) {
|
|
330
|
+
delta = [delta[0] / 2, delta[1] / 2];
|
|
331
|
+
tNew = [clampT(t[0] + delta[0]), clampT(t[1] + delta[1])];
|
|
332
|
+
FvNew = F(tNew); normNew = Math.hypot(FvNew[0], FvNew[1]);
|
|
333
|
+
}
|
|
334
|
+
t = tNew; Fv = FvNew; normF = normNew;
|
|
335
|
+
}
|
|
336
|
+
return normF < tol ? { t, pinned: pinnedT(t) } : null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Solve for tangency points (tA on incoming curve A, tB on outgoing curve B)
|
|
340
|
+
// of a radius-r circle tangent to both, per the brief's F(tA,tB) = C_A − C_B.
|
|
341
|
+
// Tries all 4 inward-normal sign combos at the seed, keeping the one that
|
|
342
|
+
// minimizes |F| there (the combo landing on the corner's bisector side).
|
|
343
|
+
function solveFilletTangency(A, B, r) {
|
|
344
|
+
const seed = [1 - 1e-3, 1e-3];
|
|
345
|
+
const Ffor = (sA, sB) => (t) => {
|
|
346
|
+
const CA = addScaled(A.at(t[0]), rot90(A.tan(t[0])), sA * r);
|
|
347
|
+
const CB = addScaled(B.at(t[1]), rot90(B.tan(t[1])), sB * r);
|
|
348
|
+
return [CA[0] - CB[0], CA[1] - CB[1]];
|
|
349
|
+
};
|
|
350
|
+
let best = null, bestNorm = Infinity;
|
|
351
|
+
for (const sA of [1, -1]) for (const sB of [1, -1]) {
|
|
352
|
+
const F = Ffor(sA, sB), f0 = F(seed), n = Math.hypot(f0[0], f0[1]);
|
|
353
|
+
if (n < bestNorm) { bestNorm = n; best = { sA, sB, F }; }
|
|
354
|
+
}
|
|
355
|
+
const solved = newton2D(best.F, seed, r);
|
|
356
|
+
if (!solved || solved.pinned) return null;
|
|
357
|
+
const [tA, tB] = solved.t;
|
|
358
|
+
const TA = A.at(tA), C = addScaled(TA, rot90(A.tan(tA)), best.sA * r);
|
|
359
|
+
return { tA, tB, TA, TB: B.at(tB), C };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function bisectMaxRFillet(A, B, r) {
|
|
363
|
+
let lo = 0, hi = r;
|
|
364
|
+
for (let i = 0; i < 12; i++) { const mid = (lo + hi) / 2; if (solveFilletTangency(A, B, mid)) lo = mid; else hi = mid; }
|
|
365
|
+
return lo;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Arc length of segment `seg` (from `from`); cubic arc length via a scratch
|
|
369
|
+
// open paper path (paper's numeric integration), line/arc in closed form.
|
|
370
|
+
function curveLength(from, seg) {
|
|
371
|
+
if (seg.via) { const c = arcCenterAndSweep(from, seg.via, seg.to); return c ? Math.abs(c.dA) * c.r : Math.hypot(seg.to[0] - from[0], seg.to[1] - from[1]); }
|
|
372
|
+
if (seg.c1) return toPaperPath(paperScope(), { start: from, segments: [seg] }, null, { open: true }).length;
|
|
373
|
+
return Math.hypot(seg.to[0] - from[0], seg.to[1] - from[1]);
|
|
374
|
+
}
|
|
375
|
+
// t-parameter at arc-length `dist` along segment `seg` (from `from`, t=0).
|
|
376
|
+
function paramAtArcLength(from, seg, dist) {
|
|
377
|
+
if (seg.via) { const c = arcCenterAndSweep(from, seg.via, seg.to); return c ? dist / (Math.abs(c.dA) * c.r) : dist / Math.hypot(seg.to[0] - from[0], seg.to[1] - from[1]); }
|
|
378
|
+
if (seg.c1) return toPaperPath(paperScope(), { start: from, segments: [seg] }, null, { open: true }).curves[0].getParameterAt(dist);
|
|
379
|
+
return dist / Math.hypot(seg.to[0] - from[0], seg.to[1] - from[1]);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Chamfer's curve solver: no Newton needed — tA/tB are set directly by
|
|
383
|
+
// arc-length setbacks of `dist` from the corner, measured along each curve.
|
|
384
|
+
function solveChamferArcLength(fromA, segA, fromB, segB, dist) {
|
|
385
|
+
if (!(dist > 0)) return null;
|
|
386
|
+
const totalA = curveLength(fromA, segA), totalB = curveLength(fromB, segB);
|
|
387
|
+
if (dist > totalA - 1e-9 || dist > totalB - 1e-9) return null;
|
|
388
|
+
const tA = paramAtArcLength(fromA, segA, totalA - dist);
|
|
389
|
+
const tB = paramAtArcLength(fromB, segB, dist);
|
|
390
|
+
if (!(tA > T_LO && tA < T_HI && tB > T_LO && tB < T_HI)) return null;
|
|
391
|
+
return { tA, tB, TA: curveEvaluator(fromA, segA).at(tA), TB: curveEvaluator(fromB, segB).at(tB) };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function bisectMaxDistChamfer(fromA, segA, fromB, segB, dist) {
|
|
395
|
+
let lo = 0, hi = dist;
|
|
396
|
+
for (let i = 0; i < 12; i++) { const mid = (lo + hi) / 2; if (solveChamferArcLength(fromA, segA, fromB, segB, mid)) lo = mid; else hi = mid; }
|
|
397
|
+
return lo;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Solve one curve-adjacent corner (at least one of its two neighbors is a
|
|
401
|
+
// cubic or arc) and return {tA, tB, TA, TB, connector} — tA/tB in the
|
|
402
|
+
// neighbors' own parameterizations, ready for trimSegment(); connector is
|
|
403
|
+
// the {to,via?} spliced between the trimmed neighbors.
|
|
404
|
+
function solveCurveCorner(pts, contour, n, i, param, isFillet, label) {
|
|
405
|
+
const inIdx = (i - 1 + n) % n, fromA = pts[inIdx], segA = contour.segments[inIdx];
|
|
406
|
+
const fromB = pts[i], segB = contour.segments[i];
|
|
407
|
+
const A = curveEvaluator(fromA, segA), B = curveEvaluator(fromB, segB);
|
|
408
|
+
const p1 = pts[i];
|
|
409
|
+
if (isFillet) {
|
|
410
|
+
const solved = solveFilletTangency(A, B, param);
|
|
411
|
+
if (!solved) {
|
|
412
|
+
const maxR = roundNice(bisectMaxRFillet(A, B, param));
|
|
413
|
+
throw new Error(`${label}: corner ${i} at (${p1[0]}, ${p1[1]}): could not fit r=${param} against the curved segment; max ≈ ${maxR}`);
|
|
414
|
+
}
|
|
415
|
+
const { tA, tB, TA, TB, C } = solved;
|
|
416
|
+
const a0 = Math.atan2(TA[1] - C[1], TA[0] - C[0]);
|
|
417
|
+
let dA = Math.atan2(TB[1] - C[1], TB[0] - C[0]) - a0;
|
|
418
|
+
while (dA <= -Math.PI) dA += 2 * Math.PI;
|
|
419
|
+
while (dA > Math.PI) dA -= 2 * Math.PI;
|
|
420
|
+
const mid = a0 + dA / 2;
|
|
421
|
+
const M = [C[0] + param * Math.cos(mid), C[1] + param * Math.sin(mid)];
|
|
422
|
+
return { tA, tB, TA, connector: { to: TB, via: M } };
|
|
423
|
+
}
|
|
424
|
+
const solved = solveChamferArcLength(fromA, segA, fromB, segB, param);
|
|
425
|
+
if (!solved) {
|
|
426
|
+
const maxDist = roundNice(bisectMaxDistChamfer(fromA, segA, fromB, segB, param));
|
|
427
|
+
throw new Error(`${label}: corner ${i} at (${p1[0]}, ${p1[1]}): could not fit dist=${param} against the curved segment; max ≈ ${maxDist}`);
|
|
428
|
+
}
|
|
429
|
+
const { tA, tB, TA, TB } = solved;
|
|
430
|
+
return { tA, tB, TA, connector: { to: TB } };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Fillet/chamfer a single ring given its already-resolved {corner, param} picks.
|
|
434
|
+
// Mirrors cornerArc's tangent/center math (polygon.js:107) but WITHOUT its silent
|
|
435
|
+
// per-corner clamp — filletProfile/chamferProfile throw instead of clamping, so the
|
|
436
|
+
// clamp math is reproduced here unclamped, gated by our own explicit fit checks.
|
|
437
|
+
function buildCornerOpRing(contour, picks, isFillet, label) {
|
|
438
|
+
const n = contour.segments.length;
|
|
439
|
+
const pts = [contour.start, ...contour.segments.map((s) => s.to)].slice(0, n);
|
|
440
|
+
const plans = new Map(); // vertex index -> {A, B, M, setback} (line-line corners only)
|
|
441
|
+
const curvePlans = new Map(); // vertex index -> {tA, tB, connector} (curve-adjacent corners)
|
|
442
|
+
const selected = new Set(picks.map((p) => p.corner.index)); // this ring's selected vertex indices
|
|
443
|
+
|
|
444
|
+
for (const { corner, param } of picks) {
|
|
445
|
+
const i = corner.index;
|
|
446
|
+
if (corner.segTypes[0] !== "line" || corner.segTypes[1] !== "line") {
|
|
447
|
+
// Curve-adjacent corner: routed through the numeric tangency solver, never
|
|
448
|
+
// through the line-line closed-form math below (exactness/speed for lines).
|
|
449
|
+
curvePlans.set(i, solveCurveCorner(pts, contour, n, i, param, isFillet, label));
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
const p0 = pts[(i - 1 + n) % n], p1 = pts[i], p2 = pts[(i + 1) % n];
|
|
453
|
+
const v0x = p0[0] - p1[0], v0y = p0[1] - p1[1], v2x = p2[0] - p1[0], v2y = p2[1] - p1[1];
|
|
454
|
+
const l0 = Math.hypot(v0x, v0y), l2 = Math.hypot(v2x, v2y);
|
|
455
|
+
const v0 = [v0x / l0, v0y / l0], v2 = [v2x / l2, v2y / l2];
|
|
456
|
+
const cosA = Math.max(-1, Math.min(1, v0[0] * v2[0] + v0[1] * v2[1]));
|
|
457
|
+
const half = Math.acos(cosA) / 2; // angle between the two edges, halved
|
|
458
|
+
const setback = isFillet ? param / Math.tan(half) : param;
|
|
459
|
+
// Per-corner ceiling: never past either edge's own end (hard cap, always full — a
|
|
460
|
+
// tangent point can never pass an edge's own extent regardless of who else is
|
|
461
|
+
// selected), and never past half the LONGER edge's "fair share" (soft cap). The soft
|
|
462
|
+
// cap only halves an edge when its OTHER endpoint is ALSO among this operation's
|
|
463
|
+
// selected corners — an isolated corner with an unselected neighbour gets that edge's
|
|
464
|
+
// full length, since nothing else is claiming it. Exceeding one edge's fair share
|
|
465
|
+
// alone isn't fatal (soft cap uses the more generous of the two); that's exactly what
|
|
466
|
+
// the segment-level overlap check below is for.
|
|
467
|
+
const prevShared = selected.has((i - 1 + n) % n), nextShared = selected.has((i + 1) % n);
|
|
468
|
+
const softL0 = prevShared ? l0 / 2 : l0, softL2 = nextShared ? l2 / 2 : l2;
|
|
469
|
+
const maxSetback = Math.min(l0, l2, Math.max(softL0, softL2));
|
|
470
|
+
if (setback > maxSetback + 1e-9) {
|
|
471
|
+
const maxParam = roundNice(isFillet ? maxSetback * Math.tan(half) : maxSetback);
|
|
472
|
+
const paramTxt = isFillet ? `r=${param}` : `dist=${param}`;
|
|
473
|
+
throw new Error(`${label}: corner ${i} at (${p1[0]}, ${p1[1]}): ${paramTxt} does not fit; max ≈ ${maxParam}`);
|
|
474
|
+
}
|
|
475
|
+
const A = [p1[0] + v0[0] * setback, p1[1] + v0[1] * setback];
|
|
476
|
+
const B = [p1[0] + v2[0] * setback, p1[1] + v2[1] * setback];
|
|
477
|
+
let M;
|
|
478
|
+
if (isFillet) {
|
|
479
|
+
let bx = v0[0] + v2[0], by = v0[1] + v2[1];
|
|
480
|
+
const bl = Math.hypot(bx, by);
|
|
481
|
+
bx /= bl; by /= bl;
|
|
482
|
+
const C = [p1[0] + bx * (param / Math.sin(half)), p1[1] + by * (param / Math.sin(half))];
|
|
483
|
+
const a0 = Math.atan2(A[1] - C[1], A[0] - C[0]);
|
|
484
|
+
let dA = Math.atan2(B[1] - C[1], B[0] - C[0]) - a0; // short sweep from A to B
|
|
485
|
+
while (dA <= -Math.PI) dA += 2 * Math.PI;
|
|
486
|
+
while (dA > Math.PI) dA -= 2 * Math.PI;
|
|
487
|
+
const mid = a0 + dA / 2;
|
|
488
|
+
M = [C[0] + param * Math.cos(mid), C[1] + param * Math.sin(mid)];
|
|
489
|
+
}
|
|
490
|
+
plans.set(i, { A, B, M, setback });
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
for (let k = 0; k < n; k++) { // overlap: claims from BOTH ends of segment k,
|
|
494
|
+
const kNext = (k + 1) % n; // from either plans (line-line) or curvePlans
|
|
495
|
+
const startPlan = plans.get(k), endPlan = plans.get(kNext);
|
|
496
|
+
const startCurve = curvePlans.get(k), endCurve = curvePlans.get(kNext);
|
|
497
|
+
if (!(startPlan || startCurve) || !(endPlan || endCurve)) continue; // only one end claimed → no overlap possible
|
|
498
|
+
const seg = contour.segments[k];
|
|
499
|
+
if (seg.c1 || seg.via) {
|
|
500
|
+
// Curved segment: only curve corners can claim it (line-line requires both
|
|
501
|
+
// neighbors to be "line", so a curved seg is never in `plans`). Overlap ⇔
|
|
502
|
+
// the kept t-span [startCurve.tB, endCurve.tA] collapses or reverses.
|
|
503
|
+
if (endCurve.tA - startCurve.tB <= 1e-9)
|
|
504
|
+
throw new Error(`${label}: corners ${k} and ${kNext} overlap on segment ${k} (reduce r)`);
|
|
505
|
+
} else {
|
|
506
|
+
// Line segment: a curve-corner claim on it is a t-parameter (curvePlans.tB
|
|
507
|
+
// measures forward from this segment's start; curvePlans.tA forward from its
|
|
508
|
+
// start too, so the far-end claim is the remainder (1−tA)) — convert both to
|
|
509
|
+
// the same setback-distance units buildCornerOpRing's line-line plans use.
|
|
510
|
+
const segLen = Math.hypot(pts[kNext][0] - pts[k][0], pts[kNext][1] - pts[k][1]);
|
|
511
|
+
const startClaim = startPlan ? startPlan.setback : startCurve.tB * segLen;
|
|
512
|
+
const endClaim = endPlan ? endPlan.setback : (1 - endCurve.tA) * segLen;
|
|
513
|
+
if (startClaim + endClaim > segLen + 1e-9)
|
|
514
|
+
throw new Error(`${label}: corners ${k} and ${kNext} overlap on segment ${k} (reduce r)`);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const segments = [];
|
|
519
|
+
let start = null;
|
|
520
|
+
for (let i = 0; i < n; i++) {
|
|
521
|
+
const startPlan = plans.get(i), endPlan = plans.get((i + 1) % n);
|
|
522
|
+
const startCurve = curvePlans.get(i), endCurve = curvePlans.get((i + 1) % n);
|
|
523
|
+
const seg = contour.segments[i];
|
|
524
|
+
if ((seg.c1 || seg.via) && (startCurve || endCurve)) {
|
|
525
|
+
// Segment itself is curved and trimmed by a curve-corner solve on one or
|
|
526
|
+
// both ends: exact de Casteljau split (cubic) / angle interpolation (arc).
|
|
527
|
+
const tStart = startCurve ? startCurve.tB : 0, tEnd = endCurve ? endCurve.tA : 1;
|
|
528
|
+
segments.push(trimSegment(pts[i], seg, tStart, tEnd).seg);
|
|
529
|
+
} else {
|
|
530
|
+
const effEnd = endPlan ? endPlan.A : endCurve ? endCurve.TA : pts[(i + 1) % n];
|
|
531
|
+
segments.push(startPlan || endPlan || startCurve || endCurve ? { to: effEnd } : seg);
|
|
532
|
+
}
|
|
533
|
+
if (i === 0) start = startPlan ? startPlan.B : startCurve ? startCurve.connector.to : pts[0];
|
|
534
|
+
if (endPlan) segments.push(isFillet ? { to: endPlan.B, via: endPlan.M } : { to: endPlan.B });
|
|
535
|
+
if (endCurve) segments.push(endCurve.connector);
|
|
536
|
+
}
|
|
537
|
+
return { start, segments };
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function applyCornerOp(input, param, opts, label, isFillet) {
|
|
541
|
+
const { kind, regions } = liftProfile(input);
|
|
542
|
+
if (kind === "points" || kind === "contour") {
|
|
543
|
+
const picks = resolveCornerSelector(contourCorners(regions[0].outer), param, opts, label);
|
|
544
|
+
const outer = buildCornerOpRing(regions[0].outer, picks, isFillet, label);
|
|
545
|
+
// Always surface a {start,segments} contour, even for a "points" input and an
|
|
546
|
+
// all-line chamfer result: restoreProfile's points-downgrade is for shape-preserving
|
|
547
|
+
// transforms, but a corner op changes the vertex count — it must not collapse back.
|
|
548
|
+
return restoreProfile(kind === "points" ? "contour" : kind, [{ outer, holes: [] }]);
|
|
549
|
+
}
|
|
550
|
+
// region/regions: selector resolves against the flattened profileCorners() order;
|
|
551
|
+
// picks are then grouped back by ring so each ring rebuilds independently.
|
|
552
|
+
const newRegions = regions.map((rg) => ({ outer: rg.outer, holes: rg.holes.slice() }));
|
|
553
|
+
const flat = [];
|
|
554
|
+
newRegions.forEach((rg, ri) => {
|
|
555
|
+
for (const c of contourCorners(rg.outer)) flat.push({ ...c, ringRef: { ri, key: "outer" } });
|
|
556
|
+
rg.holes.forEach((h, hi) => { for (const c of contourCorners(h)) flat.push({ ...c, ringRef: { ri, key: "hole", hi } }); });
|
|
557
|
+
});
|
|
558
|
+
const picks = resolveCornerSelector(flat, param, opts, label);
|
|
559
|
+
const byRing = new Map();
|
|
560
|
+
for (const p of picks) {
|
|
561
|
+
const r = p.corner.ringRef;
|
|
562
|
+
const key = r.key === "outer" ? `${r.ri}:outer` : `${r.ri}:hole:${r.hi}`;
|
|
563
|
+
if (!byRing.has(key)) byRing.set(key, { ringRef: r, picks: [] });
|
|
564
|
+
byRing.get(key).picks.push(p);
|
|
565
|
+
}
|
|
566
|
+
for (const { ringRef, picks: ringPicks } of byRing.values()) {
|
|
567
|
+
const rg = newRegions[ringRef.ri];
|
|
568
|
+
const contour = ringRef.key === "outer" ? rg.outer : rg.holes[ringRef.hi];
|
|
569
|
+
const rebuilt = buildCornerOpRing(contour, ringPicks, isFillet, label);
|
|
570
|
+
if (ringRef.key === "outer") rg.outer = rebuilt; else rg.holes[ringRef.hi] = rebuilt;
|
|
571
|
+
}
|
|
572
|
+
return restoreProfile(kind, newRegions);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
export function filletProfile(input, r, opts) {
|
|
576
|
+
return applyCornerOp(input, r, opts, "filletProfile", true);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
export function chamferProfile(input, dist, opts) {
|
|
580
|
+
return applyCornerOp(input, dist, opts, "chamferProfile", false);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// ── simplifyProfile (Task 9) ─────────────────────────────────────────────────
|
|
584
|
+
// Corner-preserving decimation/refit: split each contour at its corners (contourCorners,
|
|
585
|
+
// SMOOTH_JOINT_DEG), then reduce each run independently, and reassemble. Corner points are
|
|
586
|
+
// always bit-exact preserved — a run's endpoints are the corners bounding it.
|
|
587
|
+
//
|
|
588
|
+
// A run is only cheaply "line-only" when EVERY point in it is exactly collinear with its
|
|
589
|
+
// neighbors (the walk below drops every interior vertex) — that's the common polygon case
|
|
590
|
+
// (extra waypoints along an otherwise-straight edge) and stays bit-exact, no paper involved.
|
|
591
|
+
// A line-typed run that does NOT fully collapse (an over-segmented, smoothly-curving
|
|
592
|
+
// polyline — every joint below SMOOTH_JOINT_DEG so none of it split off into its own
|
|
593
|
+
// corner, but never exactly straight either) falls through to the same paper refit as a
|
|
594
|
+
// run that already contains a real arc/cubic segment. Arcs come back as cubics after paper's
|
|
595
|
+
// fit (paper has no arc primitive) — a "points"/"contour" input that gains cubics upgrades
|
|
596
|
+
// to a contour on restore (restoreProfile's existing curves-introduced check, same mechanism
|
|
597
|
+
// applyCornerOp relies on for corner ops).
|
|
598
|
+
function mergeCollinearRun(points) {
|
|
599
|
+
const out = [points[0]];
|
|
600
|
+
for (let i = 1; i < points.length - 1; i++) {
|
|
601
|
+
const p0 = out[out.length - 1], p1 = points[i], p2 = points[i + 1];
|
|
602
|
+
const v1 = [p1[0] - p0[0], p1[1] - p0[1]], v2 = [p2[0] - p1[0], p2[1] - p1[1]];
|
|
603
|
+
const cross = v1[0] * v2[1] - v1[1] * v2[0];
|
|
604
|
+
const mag1 = Math.hypot(v1[0], v1[1]), mag2 = Math.hypot(v2[0], v2[1]);
|
|
605
|
+
if (Math.abs(cross) < 1e-9 * mag1 * mag2) continue; // exactly collinear — drop p1
|
|
606
|
+
out.push(p1);
|
|
607
|
+
}
|
|
608
|
+
out.push(points[points.length - 1]);
|
|
609
|
+
return out;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// paper's Path#simplify() fits only through the path's EXISTING anchor points (paper's
|
|
613
|
+
// PathFitter reads path._segments directly, never samples along existing curves) — handed a
|
|
614
|
+
// sparse, already-curved path (e.g. one authored arc segment) it fits a curve through just
|
|
615
|
+
// the two endpoints, which degenerates toward their straight chord. flatten() first samples
|
|
616
|
+
// the true curve into a dense polyline so simplify() has real shape to fit against; its own
|
|
617
|
+
// tolerance is a small fraction of the caller's so flattening error never dominates the fit.
|
|
618
|
+
function flattenThenSimplify(path, tolerance) {
|
|
619
|
+
path.flatten(Math.max(tolerance / 20, 1e-6));
|
|
620
|
+
path.simplify(tolerance);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Refit an open run (from `from` through `segs`, the run's OWN segments — real arcs/cubics
|
|
624
|
+
// preserved, never flattened to their endpoints) via paper's simplify; pin the exact corner
|
|
625
|
+
// coordinate back onto the last anchor (paper may nudge endpoints during the fit) and return
|
|
626
|
+
// the run's replacement segments.
|
|
627
|
+
function refitRunViaPaper(scope, from, segs, tolerance) {
|
|
628
|
+
const sub = { start: from, segments: segs };
|
|
629
|
+
const path = toPaperPath(scope, sub, null, { open: true });
|
|
630
|
+
flattenThenSimplify(path, tolerance);
|
|
631
|
+
const out = toOpenContour(path);
|
|
632
|
+
const lastTo = segs[segs.length - 1].to;
|
|
633
|
+
out.segments[out.segments.length - 1].to = [lastTo[0], lastTo[1]];
|
|
634
|
+
return out.segments;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// One corner-to-corner run: `from`/segs describe it (segs[i] runs pts[i]->pts[i+1], pts[0]
|
|
638
|
+
// === from). Pure-line runs get an exact collinear-merge attempt first; only a run that
|
|
639
|
+
// doesn't fully collapse to its two endpoints (i.e. it isn't really straight) falls through
|
|
640
|
+
// to paper. A run with any real curve segment (arc/cubic) always goes straight to paper,
|
|
641
|
+
// with its original curve data intact (never flattened to a chord first).
|
|
642
|
+
function simplifyRun(scope, from, segs, tolerance) {
|
|
643
|
+
const allLines = segs.every((s) => !s.c1 && !s.via);
|
|
644
|
+
if (allLines) {
|
|
645
|
+
const pts = [from, ...segs.map((s) => s.to)];
|
|
646
|
+
const merged = mergeCollinearRun(pts);
|
|
647
|
+
if (merged.length === 2) return [{ to: [merged[1][0], merged[1][1]] }]; // exactly straight
|
|
648
|
+
return refitRunViaPaper(scope, from, merged.slice(1).map((p) => ({ to: p })), tolerance);
|
|
649
|
+
}
|
|
650
|
+
return refitRunViaPaper(scope, from, segs, tolerance);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function simplifyContour(scope, contour, tolerance) {
|
|
654
|
+
const corners = contourCorners(contour);
|
|
655
|
+
if (corners.length === 0) {
|
|
656
|
+
// Cornerless (smooth closed loop): simplify as ONE closed path — toPaperPath's default
|
|
657
|
+
// (open: false) already closes it (path.closed = true) so paper fits smoothly across
|
|
658
|
+
// the seam instead of treating the start point as a free open endpoint.
|
|
659
|
+
const path = toPaperPath(scope, contour);
|
|
660
|
+
flattenThenSimplify(path, tolerance);
|
|
661
|
+
return closeContourGap(toContour(path));
|
|
662
|
+
}
|
|
663
|
+
const n = contour.segments.length;
|
|
664
|
+
const startIdx = corners[0].index;
|
|
665
|
+
const pts0 = [contour.start, ...contour.segments.map((s) => s.to)]; // length n+1, pts0[n] === contour.start
|
|
666
|
+
const segs = [...contour.segments.slice(startIdx), ...contour.segments.slice(0, startIdx)];
|
|
667
|
+
const pts = [];
|
|
668
|
+
for (let i = 0; i <= n; i++) pts.push(pts0[(startIdx + i) % n]); // rotated to start at the first corner
|
|
669
|
+
const rotatedCornerIdx = corners.map((c) => (c.index - startIdx + n) % n); // ascending, [0] === 0
|
|
670
|
+
|
|
671
|
+
const segments = [];
|
|
672
|
+
for (let j = 0; j < rotatedCornerIdx.length; j++) {
|
|
673
|
+
const r0 = rotatedCornerIdx[j];
|
|
674
|
+
const r1 = j + 1 < rotatedCornerIdx.length ? rotatedCornerIdx[j + 1] : n;
|
|
675
|
+
segments.push(...simplifyRun(scope, pts[r0], segs.slice(r0, r1), tolerance));
|
|
676
|
+
}
|
|
677
|
+
return { start: [pts[0][0], pts[0][1]], segments };
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
export function simplifyProfile(input, tolerance) {
|
|
681
|
+
const { kind, regions } = liftProfile(input);
|
|
682
|
+
const scope = paperScope();
|
|
683
|
+
try {
|
|
684
|
+
const out = regions.map((rg) => ({
|
|
685
|
+
outer: simplifyContour(scope, rg.outer, tolerance),
|
|
686
|
+
holes: rg.holes.map((h) => simplifyContour(scope, h, tolerance)),
|
|
687
|
+
}));
|
|
688
|
+
return restoreProfile(kind, out);
|
|
689
|
+
} finally {
|
|
690
|
+
scope.project.clear();
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// ── Queries (Task 8) ─────────────────────────────────────────────────────────
|
|
695
|
+
// Arc-length queries (length/pointAt/tangentAt) only make sense on a single open/closed
|
|
696
|
+
// contour, not a multi-ring region — liftProfile's "points"/"contour" kinds are the two
|
|
697
|
+
// bare-contour shapes; everything else (region/regions) is rejected with a pointer to the
|
|
698
|
+
// per-ring accessors. All three route through paper.js for exact curve arc-length.
|
|
699
|
+
function singleContour(input, fnName) {
|
|
700
|
+
const { kind, regions } = liftProfile(input);
|
|
701
|
+
if (kind !== "points" && kind !== "contour")
|
|
702
|
+
throw new Error(`${fnName}: pass a single contour (use region.outer / region.holes[i])`);
|
|
703
|
+
return regions[0].outer;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function lengthToParam(path, opts, fnName) {
|
|
707
|
+
if (opts && Number.isFinite(opts.t)) return opts.t * path.length;
|
|
708
|
+
if (opts && Number.isFinite(opts.length)) return Math.min(path.length, Math.max(0, opts.length));
|
|
709
|
+
throw new Error(`${fnName}: pass a finite {t} or {length}`);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
export function profileLength(input) {
|
|
713
|
+
const contour = singleContour(input, "profileLength");
|
|
714
|
+
const scope = paperScope();
|
|
715
|
+
try {
|
|
716
|
+
return toPaperPath(scope, contour).length;
|
|
717
|
+
} finally {
|
|
718
|
+
scope.project.clear();
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
export function profilePointAt(input, opts) {
|
|
723
|
+
const contour = singleContour(input, "profilePointAt");
|
|
724
|
+
const scope = paperScope();
|
|
725
|
+
try {
|
|
726
|
+
const path = toPaperPath(scope, contour);
|
|
727
|
+
const len = lengthToParam(path, opts, "profilePointAt");
|
|
728
|
+
const pt = path.getPointAt(len);
|
|
729
|
+
return [pt.x, pt.y];
|
|
730
|
+
} finally {
|
|
731
|
+
scope.project.clear();
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
export function profileTangentAt(input, opts) {
|
|
736
|
+
const contour = singleContour(input, "profileTangentAt");
|
|
737
|
+
const scope = paperScope();
|
|
738
|
+
try {
|
|
739
|
+
const path = toPaperPath(scope, contour);
|
|
740
|
+
const len = lengthToParam(path, opts, "profileTangentAt");
|
|
741
|
+
const tan = path.getTangentAt(len);
|
|
742
|
+
return [tan.x, tan.y];
|
|
743
|
+
} finally {
|
|
744
|
+
scope.project.clear();
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
export function profileNearestPoint(input, [x, y]) {
|
|
749
|
+
const { regions } = liftProfile(input);
|
|
750
|
+
const scope = paperScope();
|
|
751
|
+
try {
|
|
752
|
+
let best = null;
|
|
753
|
+
let contourIndex = 0;
|
|
754
|
+
for (const rg of regions) {
|
|
755
|
+
for (const contour of [rg.outer, ...rg.holes]) {
|
|
756
|
+
const segMap = [];
|
|
757
|
+
const path = toPaperPath(scope, contour, segMap);
|
|
758
|
+
const loc = path.getNearestLocation(new scope.Point(x, y));
|
|
759
|
+
if (!best || loc.distance < best.distance) {
|
|
760
|
+
best = {
|
|
761
|
+
point: [loc.point.x, loc.point.y],
|
|
762
|
+
distance: loc.distance,
|
|
763
|
+
contourIndex,
|
|
764
|
+
segmentIndex: segMap[loc.index],
|
|
765
|
+
t: loc.time,
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
contourIndex++;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
return best;
|
|
772
|
+
} finally {
|
|
773
|
+
scope.project.clear();
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
export function profileBounds(input) {
|
|
778
|
+
const { regions } = liftProfile(input);
|
|
779
|
+
const scope = paperScope();
|
|
780
|
+
try {
|
|
781
|
+
let min = null, max = null;
|
|
782
|
+
for (const rg of regions) {
|
|
783
|
+
for (const contour of [rg.outer, ...rg.holes]) {
|
|
784
|
+
const b = toPaperPath(scope, contour).bounds;
|
|
785
|
+
const lo = [b.left, b.top], hi = [b.right, b.bottom];
|
|
786
|
+
min = min ? [Math.min(min[0], lo[0]), Math.min(min[1], lo[1])] : lo;
|
|
787
|
+
max = max ? [Math.max(max[0], hi[0]), Math.max(max[1], hi[1])] : hi;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
return { min, max };
|
|
791
|
+
} finally {
|
|
792
|
+
scope.project.clear();
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
export function profileArea(input) {
|
|
797
|
+
const { regions } = liftProfile(input);
|
|
798
|
+
const scope = paperScope();
|
|
799
|
+
try {
|
|
800
|
+
let area = 0;
|
|
801
|
+
for (const rg of regions) {
|
|
802
|
+
area += Math.abs(toPaperPath(scope, rg.outer).area);
|
|
803
|
+
for (const h of rg.holes) area -= Math.abs(toPaperPath(scope, h).area);
|
|
804
|
+
}
|
|
805
|
+
return area;
|
|
806
|
+
} finally {
|
|
807
|
+
scope.project.clear();
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
export function profileContains(input, [x, y]) {
|
|
812
|
+
const { regions } = liftProfile(input);
|
|
813
|
+
const scope = paperScope();
|
|
814
|
+
try {
|
|
815
|
+
const point = new scope.Point(x, y);
|
|
816
|
+
return regions.some((rg) => {
|
|
817
|
+
const children = [rg.outer, ...rg.holes].map((c) => toPaperPath(scope, c));
|
|
818
|
+
const compound = new scope.CompoundPath({ children, insert: false, fillRule: "evenodd" });
|
|
819
|
+
return compound.contains(point);
|
|
820
|
+
});
|
|
821
|
+
} finally {
|
|
822
|
+
scope.project.clear();
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// ── validateProfile (Task 10) ─────────────────────────────────────────────────
|
|
827
|
+
// Geometric sanity checks against a profile's SAMPLED (piecewise-linear) approximation.
|
|
828
|
+
// Never throws on geometric badness — only liftProfile's own "can't make sense of this
|
|
829
|
+
// input" throw propagates. contourIndex uses the same flattened outer-then-holes-per-region
|
|
830
|
+
// numbering as profileNearestPoint.
|
|
831
|
+
|
|
832
|
+
const VALIDATE_SEGS = 8; // uniform-parameter samples per curved segment
|
|
833
|
+
const DEGENERATE_EPS = 1e-9; // chord+control-net length / |ringArea| floor
|
|
834
|
+
|
|
835
|
+
const ptDist = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]);
|
|
836
|
+
|
|
837
|
+
// Per segment: line -> [to]; cubic/arc -> VALIDATE_SEGS uniform-parameter points (t =
|
|
838
|
+
// i/VALIDATE_SEGS, i=1..VALIDATE_SEGS), last pinned exactly to `to`. A collinear (degenerate)
|
|
839
|
+
// arc triple has no center — falls back to a single point, same as a line. Every sample keeps
|
|
840
|
+
// its source segmentIndex.
|
|
841
|
+
function sampleForValidation(contour) {
|
|
842
|
+
const out = [];
|
|
843
|
+
let from = contour.start;
|
|
844
|
+
contour.segments.forEach((seg, si) => {
|
|
845
|
+
if (seg.c1) {
|
|
846
|
+
for (let i = 1; i <= VALIDATE_SEGS; i++) out.push({ p: cubicAt(from, seg.c1, seg.c2, seg.to, i / VALIDATE_SEGS), segmentIndex: si });
|
|
847
|
+
out[out.length - 1].p = [seg.to[0], seg.to[1]];
|
|
848
|
+
} else if (seg.via) {
|
|
849
|
+
const c = arcCenterAndSweep(from, seg.via, seg.to);
|
|
850
|
+
if (c) {
|
|
851
|
+
const a0 = Math.atan2(from[1] - c.center[1], from[0] - c.center[0]);
|
|
852
|
+
for (let i = 1; i <= VALIDATE_SEGS; i++) {
|
|
853
|
+
const a = a0 + c.dA * (i / VALIDATE_SEGS);
|
|
854
|
+
out.push({ p: [c.center[0] + c.r * Math.cos(a), c.center[1] + c.r * Math.sin(a)], segmentIndex: si });
|
|
855
|
+
}
|
|
856
|
+
out[out.length - 1].p = [seg.to[0], seg.to[1]];
|
|
857
|
+
} else out.push({ p: [seg.to[0], seg.to[1]], segmentIndex: si });
|
|
858
|
+
} else out.push({ p: [seg.to[0], seg.to[1]], segmentIndex: si });
|
|
859
|
+
from = seg.to;
|
|
860
|
+
});
|
|
861
|
+
return out;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// chord + control-net length < DEGENERATE_EPS — both the endpoints AND any control/via
|
|
865
|
+
// points must collapse together; a curve that loops back near its own start (small chord,
|
|
866
|
+
// large control net) is real shape, not degeneracy.
|
|
867
|
+
function segmentDegenerate(from, seg) {
|
|
868
|
+
const chord = ptDist(from, seg.to);
|
|
869
|
+
let controlNet = 0;
|
|
870
|
+
if (seg.c1) controlNet = ptDist(from, seg.c1) + ptDist(seg.c1, seg.c2) + ptDist(seg.c2, seg.to);
|
|
871
|
+
else if (seg.via) controlNet = ptDist(from, seg.via) + ptDist(seg.via, seg.to);
|
|
872
|
+
return chord + controlNet < DEGENERATE_EPS;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// Two 2-D line segments p1->p2 and p3->p4 → their intersection point, or null. Tiny outward
|
|
876
|
+
// epsilon on the [0,1] parameter clamp so touching-at-an-endpoint pairs still register.
|
|
877
|
+
function segmentIntersect(p1, p2, p3, p4) {
|
|
878
|
+
const d1x = p2[0] - p1[0], d1y = p2[1] - p1[1];
|
|
879
|
+
const d2x = p4[0] - p3[0], d2y = p4[1] - p3[1];
|
|
880
|
+
const denom = d1x * d2y - d1y * d2x;
|
|
881
|
+
if (Math.abs(denom) < 1e-15) return null; // parallel (collinear overlap not specially handled)
|
|
882
|
+
const ex = p3[0] - p1[0], ey = p3[1] - p1[1];
|
|
883
|
+
const t = (ex * d2y - ey * d2x) / denom;
|
|
884
|
+
const u = (ex * d1y - ey * d1x) / denom;
|
|
885
|
+
if (t < -1e-9 || t > 1 + 1e-9 || u < -1e-9 || u > 1 + 1e-9) return null;
|
|
886
|
+
return [p1[0] + t * d1x, p1[1] + t * d1y];
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// One contour's cyclic vertex loop, deduplicated: `start` plus every sample, with the
|
|
890
|
+
// trailing duplicate-of-`start` point (contours that close via their own last segment,
|
|
891
|
+
// the common case) dropped so the wraparound edge lands exactly on the real final segment
|
|
892
|
+
// instead of a spurious zero-length "closing" edge — that phantom edge would otherwise
|
|
893
|
+
// break index-adjacency between the first and last real edges (they'd no longer be ±1
|
|
894
|
+
// apart). `verts` (used for area/containment, where a repeated or missing point is
|
|
895
|
+
// immaterial) keeps every sample; `edges` (used for self-intersection) additionally DROPS
|
|
896
|
+
// any zero-length edge, wherever it falls — a duplicate consecutive point anywhere in the
|
|
897
|
+
// contour (not just at the closing seam) would otherwise widen the index-gap between its
|
|
898
|
+
// two genuinely-adjacent neighbors past the ±1 rule, so they'd be tested against each other
|
|
899
|
+
// and "cross" at the shared vertex they both already touch. Each surviving edge keeps the
|
|
900
|
+
// ORIGINAL segmentIndex that produced its target vertex; an open contour's synthetic
|
|
901
|
+
// wraparound edge (start not reached by any real segment) is tagged with the synthetic
|
|
902
|
+
// index contour.segments.length, matching profileNearestPoint's convention for paper's own
|
|
903
|
+
// synthesized closing curve.
|
|
904
|
+
function contourLoop(contour) {
|
|
905
|
+
const samples = sampleForValidation(contour);
|
|
906
|
+
const N = samples.length;
|
|
907
|
+
const V = [[contour.start[0], contour.start[1]], ...samples.map((s) => s.p)];
|
|
908
|
+
const closed = N > 0 && ptDist(V[N], V[0]) < DEGENERATE_EPS;
|
|
909
|
+
const verts = closed ? V.slice(0, N) : V;
|
|
910
|
+
const m = verts.length;
|
|
911
|
+
const rawEdges = [];
|
|
912
|
+
for (let i = 0; i < m; i++) {
|
|
913
|
+
const targetIdx = (i + 1) % m;
|
|
914
|
+
const segmentIndex = targetIdx === 0
|
|
915
|
+
? (closed ? samples[N - 1].segmentIndex : contour.segments.length)
|
|
916
|
+
: samples[targetIdx - 1].segmentIndex;
|
|
917
|
+
rawEdges.push({ a: verts[i], b: verts[targetIdx], segmentIndex });
|
|
918
|
+
}
|
|
919
|
+
const edges = rawEdges.filter((e) => ptDist(e.a, e.b) >= DEGENERATE_EPS);
|
|
920
|
+
return { verts, samples, edges };
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// Flattened outer-then-holes-per-region list, contourIndex running across ALL regions —
|
|
924
|
+
// same order/numbering profileNearestPoint uses.
|
|
925
|
+
function flattenForValidation(regions) {
|
|
926
|
+
const flat = [];
|
|
927
|
+
let contourIndex = 0;
|
|
928
|
+
regions.forEach((rg, regionIndex) => {
|
|
929
|
+
flat.push({ contour: rg.outer, role: "outer", regionIndex, contourIndex: contourIndex++ });
|
|
930
|
+
rg.holes.forEach((h, holeIndex) => flat.push({ contour: h, role: "hole", holeIndex, regionIndex, contourIndex: contourIndex++ }));
|
|
931
|
+
});
|
|
932
|
+
return flat.map((f) => ({ ...f, loop: contourLoop(f.contour) }));
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// Self-intersection within ONE region: all sampled edges of ALL its contours (outer +
|
|
936
|
+
// holes) go into a uniform grid (cell = region bbox max-dim / 64); candidate pairs sharing
|
|
937
|
+
// a cell are tested once each (deduped across cells), skipping edges adjacent in the same
|
|
938
|
+
// contour (index ±1, wrap-aware — see contourLoop). Cross-contour pairs within the region
|
|
939
|
+
// (e.g. outer vs. its own hole) are NOT skipped; cross-REGION pairs are never considered
|
|
940
|
+
// here (handled by nesting).
|
|
941
|
+
function selfIntersectionInRegion(contours) {
|
|
942
|
+
const edges = [];
|
|
943
|
+
for (const c of contours) {
|
|
944
|
+
const n = c.loop.edges.length;
|
|
945
|
+
c.loop.edges.forEach((e, i) => {
|
|
946
|
+
edges.push({ a: e.a, b: e.b, contourIndex: c.contourIndex, localIndex: i, n, segmentIndex: e.segmentIndex });
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
const issues = [], flagged = new Set();
|
|
950
|
+
if (edges.length < 2) return { issues, flagged };
|
|
951
|
+
|
|
952
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
953
|
+
for (const e of edges) for (const p of [e.a, e.b]) {
|
|
954
|
+
minX = Math.min(minX, p[0]); minY = Math.min(minY, p[1]);
|
|
955
|
+
maxX = Math.max(maxX, p[0]); maxY = Math.max(maxY, p[1]);
|
|
956
|
+
}
|
|
957
|
+
const cellSize = Math.max(maxX - minX, maxY - minY) / 64 || 1e-6;
|
|
958
|
+
const grid = new Map();
|
|
959
|
+
edges.forEach((e, idx) => {
|
|
960
|
+
const lox = Math.min(e.a[0], e.b[0]), hix = Math.max(e.a[0], e.b[0]);
|
|
961
|
+
const loy = Math.min(e.a[1], e.b[1]), hiy = Math.max(e.a[1], e.b[1]);
|
|
962
|
+
const cx0 = Math.floor((lox - minX) / cellSize), cx1 = Math.floor((hix - minX) / cellSize);
|
|
963
|
+
const cy0 = Math.floor((loy - minY) / cellSize), cy1 = Math.floor((hiy - minY) / cellSize);
|
|
964
|
+
for (let cx = cx0; cx <= cx1; cx++) for (let cy = cy0; cy <= cy1; cy++) {
|
|
965
|
+
const key = `${cx},${cy}`;
|
|
966
|
+
if (!grid.has(key)) grid.set(key, []);
|
|
967
|
+
grid.get(key).push(idx);
|
|
968
|
+
}
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
const tested = new Set();
|
|
972
|
+
for (const cellEdges of grid.values()) {
|
|
973
|
+
for (let a = 0; a < cellEdges.length; a++) {
|
|
974
|
+
for (let b = a + 1; b < cellEdges.length; b++) {
|
|
975
|
+
const i = cellEdges[a], j = cellEdges[b];
|
|
976
|
+
const key = i < j ? `${i}:${j}` : `${j}:${i}`;
|
|
977
|
+
if (tested.has(key)) continue;
|
|
978
|
+
tested.add(key);
|
|
979
|
+
const eI = edges[i], eJ = edges[j];
|
|
980
|
+
if (eI.contourIndex === eJ.contourIndex) {
|
|
981
|
+
const d = (eI.localIndex - eJ.localIndex + eI.n) % eI.n;
|
|
982
|
+
if (d === 1 || d === eI.n - 1) continue; // adjacent in the same contour
|
|
983
|
+
}
|
|
984
|
+
const pt = segmentIntersect(eI.a, eI.b, eJ.a, eJ.b);
|
|
985
|
+
if (!pt) continue;
|
|
986
|
+
const first = eI.contourIndex <= eJ.contourIndex ? eI : eJ;
|
|
987
|
+
const other = first === eI ? eJ : eI;
|
|
988
|
+
const crossSuffix = other.contourIndex === first.contourIndex ? "" : ` (or crosses contour ${other.contourIndex})`;
|
|
989
|
+
issues.push({
|
|
990
|
+
type: "self-intersection", contourIndex: first.contourIndex, segmentIndex: first.segmentIndex, point: pt,
|
|
991
|
+
message: `contour ${first.contourIndex} self-intersects${crossSuffix} near (${pt[0].toFixed(4)}, ${pt[1].toFixed(4)})`,
|
|
992
|
+
});
|
|
993
|
+
flagged.add(eI.contourIndex); flagged.add(eJ.contourIndex);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
return { issues, flagged };
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
export function validateProfile(input) {
|
|
1001
|
+
const { kind, regions } = liftProfile(input);
|
|
1002
|
+
const issues = [];
|
|
1003
|
+
const flat = flattenForValidation(regions);
|
|
1004
|
+
|
|
1005
|
+
// 1a. Per-segment degenerate (chord + control-net length near zero).
|
|
1006
|
+
for (const f of flat) {
|
|
1007
|
+
let from = f.contour.start;
|
|
1008
|
+
f.contour.segments.forEach((seg, si) => {
|
|
1009
|
+
if (segmentDegenerate(from, seg))
|
|
1010
|
+
issues.push({ type: "degenerate", contourIndex: f.contourIndex, segmentIndex: si, message: `contour ${f.contourIndex} segment ${si} is degenerate (near-zero length)` });
|
|
1011
|
+
from = seg.to;
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// 1a'. Non-finite coordinates (NaN/±Infinity) anywhere in a contour's start/segment
|
|
1016
|
+
// points/controls. Every other check below is arithmetic comparisons against NaN, which
|
|
1017
|
+
// are always false — so without this sweep a contour with e.g. a NaN corner radius baked
|
|
1018
|
+
// into it would silently validate ok:true instead of surfacing the bad geometry.
|
|
1019
|
+
for (const f of flat) {
|
|
1020
|
+
let from = f.contour.start;
|
|
1021
|
+
f.contour.segments.forEach((seg, si) => {
|
|
1022
|
+
const pts = [from, seg.to, seg.via, seg.c1, seg.c2].filter(Boolean);
|
|
1023
|
+
if (pts.some((p) => !Number.isFinite(p[0]) || !Number.isFinite(p[1])))
|
|
1024
|
+
issues.push({ type: "degenerate", contourIndex: f.contourIndex, segmentIndex: si, message: `contour ${f.contourIndex} segment ${si} has non-finite coordinates` });
|
|
1025
|
+
from = seg.to;
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// 2. Self-intersection, within-region only.
|
|
1030
|
+
const byRegion = new Map();
|
|
1031
|
+
for (const f of flat) {
|
|
1032
|
+
if (!byRegion.has(f.regionIndex)) byRegion.set(f.regionIndex, []);
|
|
1033
|
+
byRegion.get(f.regionIndex).push(f);
|
|
1034
|
+
}
|
|
1035
|
+
const selfIntersecting = new Set();
|
|
1036
|
+
for (const contours of byRegion.values()) {
|
|
1037
|
+
const { issues: regionIssues, flagged } = selfIntersectionInRegion(contours);
|
|
1038
|
+
issues.push(...regionIssues);
|
|
1039
|
+
for (const c of flagged) selfIntersecting.add(c);
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// 1b. Per-contour degenerate area (sampled |ringArea| near zero) — skip contours already
|
|
1043
|
+
// flagged self-intersecting: a self-crossing ring's near-zero SIGNED area is a byproduct
|
|
1044
|
+
// of the crossing (opposite-winding lobes cancel), not evidence the ring is truly tiny.
|
|
1045
|
+
for (const f of flat) {
|
|
1046
|
+
if (selfIntersecting.has(f.contourIndex)) continue;
|
|
1047
|
+
if (Math.abs(ringArea(f.loop.verts)) < DEGENERATE_EPS)
|
|
1048
|
+
issues.push({ type: "degenerate", contourIndex: f.contourIndex, message: `contour ${f.contourIndex} has near-zero area` });
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// 3. Winding — explicit region/regions input only (a bare contour/point list has no
|
|
1052
|
+
// declared outer/hole role to check).
|
|
1053
|
+
if (kind === "region" || kind === "regions") {
|
|
1054
|
+
for (const f of flat) {
|
|
1055
|
+
const area = ringArea(f.loop.verts);
|
|
1056
|
+
if (f.role === "outer" && area < 0)
|
|
1057
|
+
issues.push({ type: "winding", contourIndex: f.contourIndex, message: `contour ${f.contourIndex} (outer) winds clockwise; outers must be CCW` });
|
|
1058
|
+
if (f.role === "hole" && area > 0)
|
|
1059
|
+
issues.push({ type: "winding", contourIndex: f.contourIndex, message: `contour ${f.contourIndex} (hole) winds counter-clockwise; holes must be CW` });
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
// 4a. Nesting: each hole's first sample must land inside its own outer.
|
|
1064
|
+
const outerByRegion = new Map();
|
|
1065
|
+
for (const f of flat) if (f.role === "outer") outerByRegion.set(f.regionIndex, f);
|
|
1066
|
+
for (const f of flat) {
|
|
1067
|
+
if (f.role !== "hole") continue;
|
|
1068
|
+
const outer = outerByRegion.get(f.regionIndex);
|
|
1069
|
+
const testPoint = f.loop.samples.length ? f.loop.samples[0].p : f.contour.start;
|
|
1070
|
+
if (!pointInRing(testPoint, outer.loop.verts))
|
|
1071
|
+
issues.push({ type: "nesting", contourIndex: f.contourIndex, message: `contour ${f.contourIndex} (hole) lies outside its outer (contour ${outer.contourIndex})` });
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// 4b. Nesting: region pairs — any sampled vertex of one region's outer inside the
|
|
1075
|
+
// other's outer (checked both directions: overlap and full containment either way).
|
|
1076
|
+
const outers = flat.filter((f) => f.role === "outer");
|
|
1077
|
+
for (let a = 0; a < outers.length; a++) {
|
|
1078
|
+
for (let b = a + 1; b < outers.length; b++) {
|
|
1079
|
+
const A = outers[a], B = outers[b];
|
|
1080
|
+
const overlaps = B.loop.verts.some((p) => pointInRing(p, A.loop.verts)) || A.loop.verts.some((p) => pointInRing(p, B.loop.verts));
|
|
1081
|
+
if (overlaps)
|
|
1082
|
+
issues.push({
|
|
1083
|
+
type: "nesting", contourIndex: A.contourIndex,
|
|
1084
|
+
message: `regions overlap or nest — merge with union() or make it a hole (contours ${A.contourIndex} and ${B.contourIndex})`,
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
return { ok: issues.length === 0, issues };
|
|
1090
|
+
}
|