partforge 0.132.1 → 0.133.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.
@@ -0,0 +1,1089 @@
1
+ // Laser cutting — process module #1. A ProcessDescriptor is DATA: which layers a sheet
2
+ // part draws, which keys it reserves, which destinations the kit offers. Later phases
3
+ // add its lint rules, DFM metrics and 2-D checks here.
4
+ //
5
+ // Import-free apart from sheet/constants.js: lint and the oracle read the registry, so
6
+ // nothing reachable from here may touch a kernel, a Shape2D or paper
7
+ // (test/lint-purity.test.js, test/oracle-no-paper.test.js). The writers that draw this
8
+ // process's cut files live in process/laser/export.js, which only the kit loads.
9
+ import {
10
+ widthFloor, WIDTH_RESOLUTION, LOSS_TOL_MM2, SOLID_MATCH_PCT, SHEET_DOC_ID, fmtMm,
11
+ isSheetPart, sheetMeta, LASER_THICKNESS_RANGE,
12
+ } from "../../sheet/constants.js";
13
+ import { warn } from "../../lint/finding.js";
14
+
15
+ // ── 2-D design-for-manufacture facts ──────────────────────────────────────────
16
+ // Read from the RESOLVED sheet (sheet/resolve.js) through Shape2D methods alone —
17
+ // offset, area, cut, union, intersect, regions, boundingBox — so this file stays
18
+ // import-free and paper never enters lint's or the oracle's module graph: the
19
+ // shapes' own methods carry the booleans. 2-D only; the oracle lifts a location
20
+ // into the assembly itself (oracle/measure.js).
21
+
22
+ // Sharp corners make shrink-then-regrow exact on rectilinear joinery; round ones
23
+ // would shave every corner and report it as a lost web.
24
+ const SHARP = { corners: "sharp" };
25
+ // Shape2D.offset's refusal when a shrink removes everything (geometry/contour-offset.js).
26
+ const COLLAPSES = /collapses the shape/;
27
+ // Thrown by `spend` once the shared deadline has passed; caught only in facts().
28
+ const OUT_OF_TIME = Symbol("sheet check budget");
29
+ const round2 = (x) => Math.round(x * 100) / 100;
30
+ const round3 = (x) => Number(x.toFixed(3));
31
+
32
+ // The centre of the bounding box of `shape`'s largest region; null for an empty shape.
33
+ function centreOfLargest(shape) {
34
+ if (shape.isEmpty()) return null;
35
+ let best = null, bestArea = -Infinity;
36
+ for (const region of shape.regions()) {
37
+ const a = region.area();
38
+ if (a > bestArea) { best = region; bestArea = a; }
39
+ }
40
+ if (!best) return null;
41
+ const { min, max } = best.boundingBox();
42
+ return [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2];
43
+ }
44
+
45
+ // ── contour geometry, from the IR alone (toContours) ──────────────────────────
46
+ // A ring is a point list (all lines) or { start, segments }, a segment a line
47
+ // ({ to }), an arc ({ via, to }) or a cubic ({ c1, c2, to }).
48
+ const dist = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]);
49
+ const cubicMid = (p0, { c1, c2, to }) => [0, 1].map((i) => (p0[i] + 3 * c1[i] + 3 * c2[i] + to[i]) / 8);
50
+ // A hole ring that is a circle — every segment curved, and the circle through each
51
+ // segment's ends and midpoint (an arc's own, a cubic's through its midpoint) within 1 % of
52
+ // one centre and radius — as { d, at }, else null. Read from each segment's circle, not a
53
+ // bounding box of its points: recovered arcs (withArcs) end wherever the fit split them.
54
+ function roundHole(ring) {
55
+ if (Array.isArray(ring) || !ring.segments?.length) return null;
56
+ const circles = [];
57
+ let from = ring.start;
58
+ for (const seg of ring.segments) {
59
+ if (!seg.via && !seg.c1) return null;
60
+ const a = arcCircle(from, seg.via ?? cubicMid(from, seg), seg.to);
61
+ if (!a) return null;
62
+ circles.push(a);
63
+ from = seg.to;
64
+ }
65
+ const n = circles.length;
66
+ const at = [circles.reduce((x, a) => x + a.c[0], 0) / n, circles.reduce((y, a) => y + a.c[1], 0) / n];
67
+ const r = circles.reduce((acc, a) => acc + a.r, 0) / n;
68
+ return r > 0 && circles.every((a) => dist(a.c, at) <= 0.01 * r && Math.abs(a.r - r) <= 0.01 * r) ? { d: 2 * r, at } : null;
69
+ }
70
+
71
+ // Each segment of a ring as the hole plan sees it: its end tangents and points along it to
72
+ // FLAT_TOL (its end included). Zero-length lines are dropped: they have no direction.
73
+ // FLAT_TOL is also how closely the searches' polylines follow a cubic (searchable, below):
74
+ // a cubic is cut where its control points lie within FLAT_TOL of the chord, which puts
75
+ // the curve within 3/4 of that (0.00375 mm) of the polyline.
76
+ const FLAT_TOL = 0.005;
77
+ const sub2 = (a, b) => [a[0] - b[0], a[1] - b[1]];
78
+ const cross2 = (a, b) => a[0] * b[1] - a[1] * b[0];
79
+ const dot2 = (a, b) => a[0] * b[0] + a[1] * b[1];
80
+ const unit = (v) => { const L = Math.hypot(v[0], v[1]); return L > 1e-12 ? [v[0] / L, v[1] / L] : null; };
81
+ const asContour = (ring) => (Array.isArray(ring)
82
+ ? { start: ring[0], segments: [...ring.slice(1).map((q) => ({ to: q })), { to: ring[0] }] }
83
+ : ring);
84
+ const pointSegDist = (q, a, b) => {
85
+ const ab = sub2(b, a), L2 = dot2(ab, ab);
86
+ const t = L2 > 0 ? Math.max(0, Math.min(1, dot2(sub2(q, a), ab) / L2)) : 0;
87
+ return dist(q, [a[0] + t * ab[0], a[1] + t * ab[1]]);
88
+ };
89
+ function arcCircle(from, via, to) {
90
+ const d = 2 * (from[0] * (via[1] - to[1]) + via[0] * (to[1] - from[1]) + to[0] * (from[1] - via[1]));
91
+ if (Math.abs(d) < 1e-12) return null;
92
+ const n = (q) => q[0] * q[0] + q[1] * q[1];
93
+ const c = [(n(from) * (via[1] - to[1]) + n(via) * (to[1] - from[1]) + n(to) * (from[1] - via[1])) / d,
94
+ (n(from) * (to[0] - via[0]) + n(via) * (from[0] - to[0]) + n(to) * (via[0] - from[0])) / d];
95
+ const TAU = 2 * Math.PI, mod = (x) => ((x % TAU) + TAU) % TAU, ang = (q) => Math.atan2(q[1] - c[1], q[0] - c[0]);
96
+ const a0 = ang(from), toVia = mod(ang(via) - a0), toEnd = mod(ang(to) - a0);
97
+ return { c, r: dist(from, c), a0, sweep: toVia <= toEnd ? toEnd : toEnd - TAU }; // + counter-clockwise
98
+ }
99
+ // How far a cubic's control polygon turns — at least as far as the curve does: the angles
100
+ // between its legs, a leg of no length skipped.
101
+ function polyTurn(p0, c1, c2, p3) {
102
+ const legs = [sub2(c1, p0), sub2(c2, c1), sub2(p3, c2)].filter((v) => Math.hypot(v[0], v[1]) > 1e-12);
103
+ let turn = 0;
104
+ for (let k = 1; k < legs.length; k++) turn += Math.abs(Math.atan2(cross2(legs[k - 1], legs[k]), dot2(legs[k - 1], legs[k])));
105
+ return turn;
106
+ }
107
+ // A cubic as the ends of lines within FLAT_TOL of it — each piece's control points within
108
+ // FLAT_TOL of its chord — and, with `maxTurn`, each piece turning at most that far.
109
+ function flattenCubic(p0, c1, c2, p3, out, maxTurn = Infinity, depth = 0) {
110
+ if (depth >= 10 || (Math.max(pointSegDist(c1, p0, p3), pointSegDist(c2, p0, p3)) <= FLAT_TOL
111
+ && (maxTurn === Infinity || polyTurn(p0, c1, c2, p3) <= maxTurn))) { out.push(p3); return; }
112
+ const m = (a, b) => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
113
+ const a = m(p0, c1), b = m(c1, c2), c = m(c2, p3), ab = m(a, b), bc = m(b, c), mid = m(ab, bc);
114
+ flattenCubic(p0, a, ab, mid, out, maxTurn, depth + 1);
115
+ flattenCubic(mid, bc, c, p3, out, maxTurn, depth + 1);
116
+ }
117
+ function segInfo(from, seg) {
118
+ const line = () => {
119
+ const t = unit(sub2(seg.to, from));
120
+ return t && { from, to: seg.to, t0: t, t1: t, pts: [seg.to], line: true };
121
+ };
122
+ if (seg.via) {
123
+ const a = arcCircle(from, seg.via, seg.to);
124
+ if (!a) return line();
125
+ const tan = (q) => { const rad = sub2(q, a.c); return unit(a.sweep > 0 ? [-rad[1], rad[0]] : [rad[1], -rad[0]]); };
126
+ const steps = Math.min(512, Math.max(1, Math.ceil(Math.abs(a.sweep) / (2 * Math.acos(Math.max(-1, 1 - FLAT_TOL / a.r))))));
127
+ const pts = [];
128
+ for (let i = 1; i < steps; i++) { const t = a.a0 + (a.sweep * i) / steps; pts.push([a.c[0] + a.r * Math.cos(t), a.c[1] + a.r * Math.sin(t)]); }
129
+ pts.push(seg.to);
130
+ return { from, to: seg.to, t0: tan(from), t1: tan(seg.to), pts };
131
+ }
132
+ if (!seg.c1) return line();
133
+ const { c1, c2, to } = seg;
134
+ const t0 = unit(sub2(c1, from)) ?? unit(sub2(c2, from)) ?? unit(sub2(to, from));
135
+ const t1 = unit(sub2(to, c2)) ?? unit(sub2(to, c1)) ?? unit(sub2(to, from));
136
+ if (!t0 || !t1) return null;
137
+ const pts = [];
138
+ flattenCubic(from, c1, c2, to, pts);
139
+ return { from, to, t0, t1, pts };
140
+ }
141
+ function ringParts(ring) {
142
+ const c = asContour(ring), parts = [];
143
+ let from = c.start;
144
+ for (const seg of c.segments) { const p = segInfo(from, seg); if (p) parts.push(p); from = seg.to; }
145
+ return parts;
146
+ }
147
+ const polyArea = (pts) => pts.reduce((a, q, i) => a + cross2(q, pts[(i + 1) % pts.length]), 0) / 2;
148
+
149
+ // ── the shapes the searches run on ─────────────────────────────────────────────
150
+ // paper hands every arc back from a boolean as cubics — a booleaned round hole as four, a
151
+ // fillet as one — and the offset engine only approximates a cubic's offset (adaptive
152
+ // Tiller–Hanson, subdivided to OFFSET_TOL). That approximation has a slow band no price
153
+ // follows: a cubic an offset moves toward its centre of curvature until little of its
154
+ // radius is left subdivides toward the depth limit, and ONE step ran for seconds — 4–73 s
155
+ // on an ordinary rounded mounting plate with four M3 holes; 20 s on a row of six booleaned
156
+ // 2 × 8 mm ovals on 6 mm stock, whose tips a test grew first and then moved back past
157
+ // their centres; 1.6 s closing a plate's elliptical corners. And a sharp join extends a
158
+ // cubic only along its end tangent, which cuts short a corner where the curve meets
159
+ // another edge — a false narrow web or gap there. So each search reads its shape in two
160
+ // passes:
161
+ // • every run of cubics on one circle becomes that circle's arcs (withArcs), through the
162
+ // resolved sheet's `recoverArcs` (geometry/arc-fit.js, handed over by sheet/resolve.js:
163
+ // this file imports nothing) — the fit the kit draws the cut files with, so the
164
+ // searches read the geometry that is cut. It fits a whole run at once, through the
165
+ // run's own ends (a split circle stays ONE circle; fitted cubic by cubic, the pieces of
166
+ // a booleaned D met at a kink the offset engine read as a false 1 mm web), and accepts
167
+ // it only within min(1e-3·r, 2e-3·chord) of every cubic at every probe and joint,
168
+ // refusing a fit through three near-collinear points (a wave is not an arc);
169
+ // • every cubic left that the search could carry into the slow band becomes a polyline
170
+ // within FLAT_TOL of it, no piece turning more than SEARCH_TURN (searchable): lines
171
+ // offset exactly, a line meets another edge at a corner the sharp join mitres exactly,
172
+ // and a test on lines costs what its price says.
173
+ // At the widest test (h = ceiling/2) a cubic the search's first offset moves toward its
174
+ // centre of curvature is slow below about 2.2·h — the spike at 0.9–1.35·h and
175
+ // superlinear around it — and one moved away first below about 0.35·h (grown to r + h,
176
+ // then moved toward its centre by h by the second offset); the edges are measured on
177
+ // elliptical corners and holes at 3 and 6 mm (docs/research/sheet-inspect-timing.md).
178
+ // A cubic under TOWARD·h, or AWAY·h, is flattened: margins of 1.1× and 2× over those
179
+ // edges, and a cubic that bends both ways is judged the strict way.
180
+ // The cubics left are benign at every width the search tests (every width is at most the
181
+ // ceiling), and are priced per cubic like any segment. Flattening EVERY cubic was measured
182
+ // and set aside: a benign curve becomes a hundred lines where it was four cubics, and a
183
+ // line-heavy test costs superlinearly (the prices below), so panels of ordinary elliptical
184
+ // holes that read in tens of milliseconds as cubics were priced out — 97 of the 151
185
+ // calibration panels read under the budget, against 105 this way.
186
+ // What the lines READ is the cubics' reading wherever a web has length. Where it is a point
187
+ // — two tight tips facing, two elliptical holes tip to tip — the search reads the width at
188
+ // which LOSS_TOL_MM2 of area leaves, and that area grows so slowly past the true web that a
189
+ // small change in the outline moves the reading a long way, either way. Cut to FLAT_TOL
190
+ // alone, 2 × 5 mm ovals 1.2 mm apart on 3 mm stock read 1.83 (a silent pass; the exact
191
+ // curves read 1.22), and 2 × 8 mm ovals 2.85 mm apart on 6 mm read 3.00. So no piece turns
192
+ // more than SEARCH_TURN either: on the reviewer's 40 tip-to-tip rows under the budget that
193
+ // leaves no silent pass the exact curves do not share, and six panels of the helpers-and-
194
+ // ellipses fuzz (test/sheet-dfm.test.js) read the notice instead of a reading. Area on a
195
+ // point contact is still the exact curves' own limit: they too pass some sub-floor tip
196
+ // webs (docs/research/sheet-inspect-timing.md, "Point contacts").
197
+ // Only the shapes the width searches run on: the area, pieces and marks read the profile.
198
+ const TOWARD = 2.5, AWAY = 0.75; // × h at the ceiling — see above
199
+ const SEARCH_TURN = (4 * Math.PI) / 180; // per flattened piece — see above
200
+ // A cubic's tightest radius of curvature: the radius at CURVE_SAMPLES + 1 points, and the
201
+ // radius each sample interval turns through (its chord over its turn), whichever is
202
+ // smaller — a turn too sharp to show at any sample still shows across its interval. A
203
+ // cusp or a loop turns fast and reads tight.
204
+ const CURVE_SAMPLES = 32;
205
+ const bezAt = (p0, c1, c2, p3, t) => { const u = 1 - t; return [0, 1].map((i) => u * u * u * p0[i] + 3 * u * u * t * c1[i] + 3 * u * t * t * c2[i] + t * t * t * p3[i]); };
206
+ const bez1 = (p0, c1, c2, p3, t) => { const u = 1 - t; return [0, 1].map((i) => 3 * (u * u * (c1[i] - p0[i]) + 2 * u * t * (c2[i] - c1[i]) + t * t * (p3[i] - c2[i]))); };
207
+ const bez2 = (p0, c1, c2, p3, t) => [0, 1].map((i) => 6 * ((1 - t) * (c2[i] - 2 * c1[i] + p0[i]) + t * (p3[i] - 2 * c2[i] + c1[i])));
208
+ // → { r: its tightest radius, dir: +1 when it bends toward the material everywhere
209
+ // (left of travel: the storage winding keeps the material on the left), -1 away, 0 both }
210
+ function curveOf(p0, c1, c2, p3) {
211
+ let r = Infinity, left = false, right = false, prev = null;
212
+ for (let k = 0; k <= CURVE_SAMPLES; k++) {
213
+ const t = k / CURVE_SAMPLES, v = bez1(p0, c1, c2, p3, t), a = bez2(p0, c1, c2, p3, t);
214
+ const speed = Math.hypot(v[0], v[1]), x = cross2(v, a);
215
+ if (speed < 1e-9) return { r: 0, dir: 0 }; // a cusp
216
+ if (x > 1e-12) left = true; else if (x < -1e-12) right = true;
217
+ if (Math.abs(x) > 1e-12) r = Math.min(r, speed ** 3 / Math.abs(x));
218
+ const here = { q: bezAt(p0, c1, c2, p3, t), dir: [v[0] / speed, v[1] / speed] };
219
+ if (prev) {
220
+ const turn = Math.abs(Math.atan2(cross2(prev.dir, here.dir), dot2(prev.dir, here.dir)));
221
+ if (turn > 1e-9) r = Math.min(r, dist(prev.q, here.q) / turn);
222
+ }
223
+ prev = here;
224
+ }
225
+ return { r, dir: left && right ? 0 : left ? 1 : right ? -1 : 0 };
226
+ }
227
+ // Whether a search whose first offset moves the material `shrinks` (+1: an opening shrinks
228
+ // it; -1: a closing grows it) must flatten this cubic. A shrink moves a cubic that bends
229
+ // toward the material toward its centre.
230
+ function isSlow(p0, c1, c2, p3, ceiling, shrinks) {
231
+ const { r, dir } = curveOf(p0, c1, c2, p3);
232
+ return r < (dir === -shrinks ? AWAY : TOWARD) * (ceiling / 2);
233
+ }
234
+ // The contours with every circular run of cubics read as its arcs, or the same array when
235
+ // that changes nothing.
236
+ function withArcs(contours, recover) {
237
+ let changed = false;
238
+ const ring = (r) => {
239
+ if (Array.isArray(r) || !r.segments.some((g) => g.c1)) return r;
240
+ const arcs = recover(r);
241
+ if (arcs.segments.some((g) => g.via)) changed = true;
242
+ return arcs;
243
+ };
244
+ const out = contours.map((rg) => ({ outer: ring(rg.outer), holes: rg.holes.map(ring) }));
245
+ return changed ? out : contours;
246
+ }
247
+ // The contours with every cubic a search moving the material `shrinks` could carry into
248
+ // the slow band as a polyline, or the same array when there is none.
249
+ function searchable(contours, ceiling, shrinks) {
250
+ let changed = false;
251
+ const ring = (r) => {
252
+ if (Array.isArray(r) || !r.segments.some((g) => g.c1)) return r;
253
+ const segments = [];
254
+ let from = r.start, flat = false;
255
+ for (const g of r.segments) {
256
+ if (g.c1 && isSlow(from, g.c1, g.c2, g.to, ceiling, shrinks)) {
257
+ const pts = [];
258
+ flattenCubic(from, g.c1, g.c2, g.to, pts, SEARCH_TURN);
259
+ for (const q of pts) segments.push({ to: q });
260
+ flat = true;
261
+ } else segments.push(g);
262
+ from = g.to;
263
+ }
264
+ if (!flat) return r;
265
+ changed = true;
266
+ return { start: r.start, segments };
267
+ };
268
+ const out = contours.map((rg) => ({ outer: ring(rg.outer), holes: rg.holes.map(ring) }));
269
+ return changed ? out : contours;
270
+ }
271
+
272
+ // ── what a width search can leave out ────────────────────────────────────────
273
+ // Every test shrinks and regrows the whole profile, so every ring a search can leave out
274
+ // is work it does not do — most of all a curve that is not a circle, which the searches
275
+ // may read as a polyline of many short lines (above). A hole that cannot take part in what
276
+ // a search counts is left out of it instead: the search runs on the profile rebuilt from
277
+ // its own rings without that hole (the kernel's trusted lift — no boolean, no validation,
278
+ // arcs kept).
279
+ // • The closing (gap) grows the material and shrinks it back, which shrinks each hole
280
+ // from its own boundary and regrows it alone: holes never meet in it. A round hole
281
+ // is read directly (its narrowest opening is its diameter), and a convex hole at
282
+ // least WIDTH_RESOLUTION wider than the ceiling with no corner sharper than 60° has
283
+ // nothing in it that closes (it only loses rounded corners, which the closing does
284
+ // not count): both leave the closing.
285
+ // • The opening (bridge) shrinks and regrows the material BETWEEN boundaries, so a hole
286
+ // takes part wherever another ring is within reach. A sharp offset moves a boundary
287
+ // at most w/2 along a straight run or an arc and at most w at a mitred corner
288
+ // (MITER_LIMIT 2), so two boundaries can meet in a test only when they are closer
289
+ // than w + w. A convex hole — it has no material of its own to lose — whose
290
+ // clearance to every other ring is at least twice the ceiling (1.5 times for a round
291
+ // one, which has no corner) meets nothing at any tested width and leaves the
292
+ // opening; the web around it is wider than the ceiling, so no finding goes with it.
293
+ // A hole with another piece of the profile inside it is never left out (filling it
294
+ // would bury the piece), and a profile of more than PLAN_RING_CAP rings is not planned
295
+ // (the plan's pairwise clearances would be the slow step; the prices below decide).
296
+ const ISOLATION = { round: 1.5, other: 2 }; // × the ceiling
297
+ const PLAN_RING_CAP = 2000;
298
+ const partsCache = new WeakMap();
299
+ const partsOf = (ring) => { let p = partsCache.get(ring); if (!p) { p = ringParts(ring); partsCache.set(ring, p); } return p; };
300
+ function ringPoints(ring) {
301
+ const parts = partsOf(ring);
302
+ return parts.length ? [parts[0].from, ...parts.flatMap((pt) => pt.pts).slice(0, -1)] : [];
303
+ }
304
+ const boxOf = (pts) => pts.reduce((b, q) => [Math.min(b[0], q[0]), Math.min(b[1], q[1]), Math.max(b[2], q[0]), Math.max(b[3], q[1])],
305
+ [Infinity, Infinity, -Infinity, -Infinity]);
306
+ const boxGap = (a, b) => Math.hypot(Math.max(0, b[0] - a[2], a[0] - b[2]), Math.max(0, b[1] - a[3], a[1] - b[3]));
307
+ const boxWithin = (inner, outer) => inner[0] >= outer[0] && inner[1] >= outer[1] && inner[2] <= outer[2] && inner[3] <= outer[3];
308
+ // Every edge sampled to FLAT_TOL turns one way, and the ring turns once.
309
+ function convexPoly(pts) {
310
+ const n = pts.length, s = Math.sign(polyArea(pts));
311
+ if (n < 3 || !s) return false;
312
+ let total = 0;
313
+ for (let i = 0; i < n; i++) {
314
+ const e1 = sub2(pts[i], pts[(i - 1 + n) % n]), e2 = sub2(pts[(i + 1) % n], pts[i]);
315
+ const x = cross2(e1, e2);
316
+ if (x * s < -1e-9 * Math.hypot(e1[0], e1[1]) * Math.hypot(e2[0], e2[1])) return false;
317
+ total += Math.atan2(x, dot2(e1, e2));
318
+ }
319
+ return Math.abs(Math.abs(total) - 2 * Math.PI) < 1e-6;
320
+ }
321
+ // A convex polygon's narrowest extent: it lies flush against one of its edges.
322
+ function minWidth(pts) {
323
+ if (pts.length > 2048) return 0;
324
+ let best = Infinity;
325
+ pts.forEach((a, i) => {
326
+ const e = unit(sub2(pts[(i + 1) % pts.length], a));
327
+ if (e) best = Math.min(best, pts.reduce((far, q) => Math.max(far, Math.abs(cross2(e, sub2(q, a)))), 0));
328
+ });
329
+ return best;
330
+ }
331
+ // A corner sharper than 60° (a turn past 120°), where the sharp join bevels (MITER_LIMIT 2).
332
+ const ACUTE_TURN = (2 * Math.PI) / 3;
333
+ const sharpCorner = (ring) => {
334
+ const parts = partsOf(ring);
335
+ return parts.some((pt, i) => { const a = parts[(i - 1 + parts.length) % parts.length].t1; return Math.abs(Math.atan2(cross2(a, pt.t0), dot2(a, pt.t0))) > ACUTE_TURN; });
336
+ };
337
+ const segsCross = (a, b, c, d) => {
338
+ const o = (p, q, r) => Math.sign(cross2(sub2(q, p), sub2(r, p)));
339
+ return o(a, b, c) * o(a, b, d) < 0 && o(c, d, a) * o(c, d, b) < 0;
340
+ };
341
+ const segSegDist = (a, b, c, d) => (segsCross(a, b, c, d) ? 0
342
+ : Math.min(pointSegDist(a, c, d), pointSegDist(b, c, d), pointSegDist(c, a, b), pointSegDist(d, a, b)));
343
+ // Whether any edge of A comes within `reach` of any edge of B: B's edges are filtered once
344
+ // against A's box grown by `reach`, then each of A's edges is measured against those.
345
+ function polyNear(A, boxA, B, reach) {
346
+ const edgeBox = (a, b) => [Math.min(a[0], b[0]), Math.min(a[1], b[1]), Math.max(a[0], b[0]), Math.max(a[1], b[1])];
347
+ const near = [];
348
+ for (let j = 0; j < B.length; j++) {
349
+ const c = B[j], d = B[(j + 1) % B.length], box = edgeBox(c, d);
350
+ if (boxGap(boxA, box) < reach) near.push([c, d, box]);
351
+ }
352
+ if (!near.length) return false;
353
+ for (let i = 0; i < A.length; i++) {
354
+ const a = A[i], b = A[(i + 1) % A.length], ea = edgeBox(a, b);
355
+ for (const [c, d, box] of near) if (boxGap(ea, box) < reach && segSegDist(a, b, c, d) < reach) return true;
356
+ }
357
+ return false;
358
+ }
359
+ // ── boundaries that face each other ──────────────────────────────────────────────
360
+ // Two pieces of a profile's boundary FACE each other when they run within 60° of opposite
361
+ // directions — the two sides of a web, a slot, a finger, a narrow hole — and come within
362
+ // `reach` of each other. facingPairs calls visit(i, j, d) once for every such pair of
363
+ // pieces, with their distance: each ring cut along its flattened boundary (ringParts) into
364
+ // pieces no longer than `step` (a segment whole, by default) — only its lines' pieces with
365
+ // `linesOnly` — a piece's neighbours on its own ring skipped (they meet it at a vertex). The
366
+ // pieces are bucketed in a grid of cells at least `reach` and a mean piece wide, so the work
367
+ // is linear in them and in the pairs found.
368
+ // → the pieces, ring by ring in boundary order: { a, b, dir, len, ring, k, line }.
369
+ function facingPairs(contours, { reach, step = Infinity, linesOnly = false }, visit) {
370
+ const pieces = [], rings = [];
371
+ for (const rg of contours) for (const ring of [rg.outer, ...rg.holes]) {
372
+ const r = rings.length;
373
+ let k = 0;
374
+ for (const part of partsOf(ring)) {
375
+ let from = part.from;
376
+ for (const to of part.pts) {
377
+ const len = dist(from, to), n = Math.max(1, Math.ceil(len / step));
378
+ if (len > 1e-12) for (let i = 0; i < n; i++, k++) {
379
+ if (linesOnly && !part.line) continue;
380
+ const a = [from[0] + ((to[0] - from[0]) * i) / n, from[1] + ((to[1] - from[1]) * i) / n];
381
+ const b = [from[0] + ((to[0] - from[0]) * (i + 1)) / n, from[1] + ((to[1] - from[1]) * (i + 1)) / n];
382
+ pieces.push({ a, b, dir: [(to[0] - from[0]) / len, (to[1] - from[1]) / len], len: len / n, ring: r, k, line: !!part.line });
383
+ }
384
+ from = to;
385
+ }
386
+ }
387
+ rings.push(k);
388
+ }
389
+ const mean = pieces.length ? pieces.reduce((t, pc) => t + pc.len, 0) / pieces.length : 0;
390
+ const cell = Math.max(reach, Number.isFinite(step) ? step : 0, mean), cells = new Map();
391
+ const range = (lo, hi) => [Math.floor(lo / cell), Math.floor(hi / cell)];
392
+ pieces.forEach((pc, i) => {
393
+ const [x0, x1] = range(Math.min(pc.a[0], pc.b[0]) - reach, Math.max(pc.a[0], pc.b[0]) + reach);
394
+ const [y0, y1] = range(Math.min(pc.a[1], pc.b[1]) - reach, Math.max(pc.a[1], pc.b[1]) + reach);
395
+ for (let x = x0; x <= x1; x++) for (let y = y0; y <= y1; y++) {
396
+ const key = `${x},${y}`;
397
+ if (!cells.has(key)) cells.set(key, []);
398
+ cells.get(key).push(i);
399
+ }
400
+ });
401
+ pieces.forEach((pc, i) => {
402
+ const seen = new Set();
403
+ const [x0, x1] = range(Math.min(pc.a[0], pc.b[0]), Math.max(pc.a[0], pc.b[0]));
404
+ const [y0, y1] = range(Math.min(pc.a[1], pc.b[1]), Math.max(pc.a[1], pc.b[1]));
405
+ for (let x = x0; x <= x1; x++) for (let y = y0; y <= y1; y++) for (const j of cells.get(`${x},${y}`) ?? []) {
406
+ if (j <= i || seen.has(j)) continue;
407
+ seen.add(j);
408
+ const o = pieces[j];
409
+ if (o.ring === pc.ring) { const n = rings[pc.ring], d = Math.abs(o.k - pc.k); if (Math.min(d, n - d) <= 1) continue; }
410
+ if (dot2(pc.dir, o.dir) >= -0.5) continue;
411
+ const d = segSegDist(pc.a, pc.b, o.a, o.b);
412
+ if (d < reach) visit(i, j, d);
413
+ }
414
+ });
415
+ return pieces;
416
+ }
417
+ // How many of a shape's lines face another of its lines within `reach`.
418
+ function facingLines(contours, reach) {
419
+ const hit = new Set();
420
+ facingPairs(contours, { reach, linesOnly: true }, (i, j) => { hit.add(i); hit.add(j); });
421
+ return hit.size;
422
+ }
423
+ // What facingLines will cost, in one pass over the lines before it runs: its grid hands
424
+ // every line each line bucketed within `reach` of it, on short, dense lines thousands. The
425
+ // lines' midpoints binned in cells as wide as the grid's own (the reach, or the mean line
426
+ // if longer), each cell's count times the count of it and the eight around it: within 15%
427
+ // of the probes the grid makes on the calibration grilles.
428
+ function facingWork(contours, reach) {
429
+ const mids = [];
430
+ let total = 0;
431
+ for (const rg of contours) for (const ring of [rg.outer, ...rg.holes]) for (const part of partsOf(ring)) {
432
+ if (!part.line) continue;
433
+ let from = part.from;
434
+ for (const to of part.pts) {
435
+ const len = dist(from, to);
436
+ if (len > 1e-12) { mids.push((from[0] + to[0]) / 2, (from[1] + to[1]) / 2); total += len; }
437
+ from = to;
438
+ }
439
+ }
440
+ const n = mids.length / 2;
441
+ if (!n) return 0;
442
+ const cell = Math.max(reach, total / n), cells = new Map();
443
+ for (let i = 0; i < mids.length; i += 2) {
444
+ const key = `${Math.floor(mids[i] / cell)},${Math.floor(mids[i + 1] / cell)}`;
445
+ cells.set(key, (cells.get(key) ?? 0) + 1);
446
+ }
447
+ let work = 0;
448
+ for (const [key, count] of cells) {
449
+ const [x, y] = key.split(",").map(Number);
450
+ for (let dx = -1; dx <= 1; dx++) for (let dy = -1; dy <= 1; dy++) work += count * (cells.get(`${x + dx},${y + dy}`) ?? 0);
451
+ }
452
+ return work;
453
+ }
454
+
455
+ // → { open, close, round }: the contours each search runs on (null: the whole profile),
456
+ // and the round holes the closing left out, whose diameters are read instead. The
457
+ // clearances are measured hole against outer (few) and against the holes a sweep over
458
+ // their left edges can bring within reach — never every pair.
459
+ function holePlan(contours, ceiling) {
460
+ const rings = [];
461
+ contours.forEach((rg, ri) => [rg.outer, ...rg.holes].forEach((ring, j) => {
462
+ const pts = ringPoints(ring);
463
+ rings.push({ ri, hi: j - 1, ring, pts, box: boxOf(pts) });
464
+ }));
465
+ const none = { open: null, close: null, round: [] };
466
+ if (rings.length > PLAN_RING_CAP) return none;
467
+ const outers = rings.filter((o) => o.hi < 0);
468
+ const holes = rings.filter((o) => o.hi >= 0).sort((a, b) => a.box[0] - b.box[0]);
469
+ const widest = holes.reduce((w, h) => Math.max(w, h.box[2] - h.box[0]), 0);
470
+ const firstFrom = (x) => { let lo = 0, hi = holes.length; while (lo < hi) { const m = (lo + hi) >> 1; if (holes[m].box[0] < x) lo = m + 1; else hi = m; } return lo; };
471
+ const clear = (r, reach) => {
472
+ const close = (o) => o !== r && boxGap(r.box, o.box) < reach && polyNear(r.pts, r.box, o.pts, reach);
473
+ if (outers.some(close)) return false;
474
+ for (let i = firstFrom(r.box[0] - reach - widest); i < holes.length && holes[i].box[0] < r.box[2] + reach; i++)
475
+ if (close(holes[i])) return false;
476
+ return true;
477
+ };
478
+ const open = new Set(), close = new Set(), round = [];
479
+ for (const r of holes) {
480
+ if (r.pts.length < 3) continue;
481
+ if (outers.some((o) => o.ri !== r.ri && boxWithin(o.box, r.box))) continue;
482
+ const circle = roundHole(r.ring);
483
+ if (!circle && !convexPoly(r.pts)) continue;
484
+ const key = `${r.ri}:${r.hi}`;
485
+ if (circle) { round.push(circle); close.add(key); }
486
+ else if (minWidth(r.pts) >= ceiling + WIDTH_RESOLUTION && !sharpCorner(r.ring)) close.add(key);
487
+ if (clear(r, (circle ? ISOLATION.round : ISOLATION.other) * ceiling + 2 * FLAT_TOL)) open.add(key);
488
+ }
489
+ const without = (keys) => (keys.size
490
+ ? contours.map((rg, ri) => ({ outer: rg.outer, holes: rg.holes.filter((_, hi) => !keys.has(`${ri}:${hi}`)) }))
491
+ : null);
492
+ return { open: without(open), close: without(close), round };
493
+ }
494
+ // The plan on the profile's arcs, and each search's shape (null: the profile itself) —
495
+ // the plan's contours less the holes it leaves out, flattened for that search's direction.
496
+ function searchPlan(contours, ceiling, recover) {
497
+ const arcs = recover ? withArcs(contours, recover) : contours;
498
+ const p = holePlan(arcs, ceiling);
499
+ const own = (reduced, shrinks) => {
500
+ const flat = searchable(reduced ?? arcs, ceiling, shrinks);
501
+ return flat === contours ? null : flat;
502
+ };
503
+ return { open: own(p.open, 1), close: own(p.close, -1), round: p.round };
504
+ }
505
+
506
+ // ── what a step costs ────────────────────────────────────────────────────────
507
+ // Nothing interrupts a step once it has started: the deadline is checked between them.
508
+ // So under a deadline every step is PRICED before it starts, and a step whose price
509
+ // does not fit what is left of the budget is not started — the reading stays out
510
+ // (evaluated: false, verify's notice), exactly as if the budget had run out, without a
511
+ // multi-second offset or boolean running past it inside partforge-cloud's one 8 s
512
+ // report. That covers every test — the first one too, so a profile too complex to read
513
+ // at all is not started — and every test's one-sided difference, priced from the
514
+ // test's own result before it starts. With no deadline the caller asked for the whole
515
+ // reading, however long, and gets it.
516
+ // Prices are in units of about one desktop-Node millisecond, fitted to main-thread CPU
517
+ // time on the searched shapes as they are now — circles as arcs, slow cubics as lines
518
+ // (searchable), every difference taken a margin clear (MARGIN) — on 2–6 mm stock
519
+ // (docs/research/sheet-inspect-timing.md, "What a profile costs"):
520
+ // a test (two sharp offsets), per segment of the shape tested:
521
+ // line 1.2 (1,028: 1.1 s; 884 of a polyline-rounded tab panel: 0.92 s), plus
522
+ // 0.002 × (the lines facing another piece of the boundary within the ceiling)²
523
+ // (facingLines): where a test brings two polylines nearly tangent — the webs of a
524
+ // grille of flattened ovals, a stroke of text — the winding resolver splits them into
525
+ // many pieces and probes each against every edge, many times over (24 flattened
526
+ // 2 × 1 mm ovals on 2 mm stock, 964 lines all facing: 2.4 s, priced 3.0 s; six
527
+ // 2 × 8 mm ovals on 6 mm, 436 lines, 384 facing: 0.72 s, priced 0.82 s). Linear alone,
528
+ // a grille of drawn polylines ran 2.3 s in one step priced 1.4 s;
529
+ // arc 2.5 plus 0.0015 × (the arcs)²: the same resolver on a grille of round holes whose
530
+ // webs a test collapses (200 arcs 0.2 s, 578 1.3 s, 1,058 4.1 s);
531
+ // cubic 3 — a cubic left as one is out of the slow band, at every width tested;
532
+ // the one-sided difference, per segment of the shape tested and of its result:
533
+ // line 0.1 (a grille of 50 drawn 32-facet circles: 0.28 s, priced 0.34 s), arc 0.5 (a
534
+ // grille of 196 round holes: 0.55 s, priced 0.94 s), cubic 0.15 (200 booleaned
535
+ // ellipses, 8,900 cubics and lines: 1.2 s, priced 1.5 s);
536
+ // the margin shape, once per search: one offset, half a test at no width;
537
+ // each search's setup (setupSearch) and the boundary pass (nearContacts), below.
538
+ // They were fitted before no flattened piece could turn more than SEARCH_TURN, which cuts
539
+ // grilles of flattened ovals and ellipses into more lines, nearly all facing; only the
540
+ // setup was re-fitted for that (setupSearch). Over the 151 calibration panels read under
541
+ // the budget on the CPU clock, the worst step costs 0.95 of its price (a test on a row of
542
+ // three flattened ovals) and the largest runs about 0.9 s. Read with no deadline every test
543
+ // starts, however long, and tests on those grilles and on fine polyline waves cost up to
544
+ // 1.8 × (a 30-period wave, 6.2–6.4 s priced 3.6 s); differences at most 0.78, setups 0.60,
545
+ // the boundary pass 0.52 (steps of 6 ms and more).
546
+ // The meter scales every price by how much slower than that this device has run the
547
+ // steps it already took (never below 1), so a phone prices its own steps.
548
+ const PRICE = {
549
+ test: { line: 1.2, facingPair: 0.002, arc: 2.5, arcPair: 0.0015, cubic: 3 },
550
+ diff: { line: 0.1, arc: 0.5, cubic: 0.15 },
551
+ };
552
+ const countsOf = (contours) => {
553
+ const c = { line: 0, arc: 0, cubic: 0 };
554
+ for (const rg of contours) for (const ring of [rg.outer, ...rg.holes]) {
555
+ if (Array.isArray(ring)) { c.line += ring.length; continue; }
556
+ for (const seg of ring.segments) c[seg.c1 ? "cubic" : seg.via ? "arc" : "line"]++;
557
+ }
558
+ return c;
559
+ };
560
+ // How many cubics of these rings a search moving the material `shrinks` could carry into
561
+ // the slow band: none in a shape searchable made.
562
+ const slowCubics = (contours, ceiling, shrinks) => contours.flatMap((rg) => [rg.outer, ...rg.holes]).reduce((n, ring) => {
563
+ if (Array.isArray(ring)) return n;
564
+ let from = ring.start;
565
+ for (const g of ring.segments) { if (g.c1 && isSlow(from, g.c1, g.c2, g.to, ceiling, shrinks)) n++; from = g.to; }
566
+ return n;
567
+ }, 0);
568
+ // The per-segment part of a test's price: what one offset of the shape costs, twice.
569
+ const linearTestPrice = ({ line, arc, cubic }) => line * PRICE.test.line + arc * PRICE.test.arc + cubic * PRICE.test.cubic;
570
+ // Past this, a test's price needs no facing count: it is past any budget as it is.
571
+ const FACING_CAP = 10000;
572
+ // One test on a search's shape. A shape holds a slow cubic only where the kernel has no
573
+ // trusted lift to rebuild the profile with (the profile itself is searched): no price
574
+ // follows it, so that test is never started under a deadline (Infinity; with none, it runs).
575
+ function testPrice(search) {
576
+ const P = PRICE.test, { arc } = search.counts;
577
+ if (search.slow) return Infinity;
578
+ return linearTestPrice(search.counts) + arc * arc * P.arcPair + search.facing * search.facing * P.facingPair;
579
+ }
580
+ // A search's own setup — the profile rebuilt from its rings (the lift) and its facing lines
581
+ // counted — is a step too, priced before it runs: per segment, plus 40 ns for every probe
582
+ // the facing count's grid will make (facingWork; the count is skipped, and so unpriced,
583
+ // where the rest of the test's price is already FACING_CAP past any budget). Per segment
584
+ // alone it ran short wherever lines are dense: every line probes every line within the
585
+ // ceiling, so a grille of 36 flattened 1 × 2 mm ellipses 0.6 mm apart, 4,756 lines, took
586
+ // 90 ms to count on 2 mm stock and 440 ms on 6 mm, both priced 95. The probes cost 37–42
587
+ // ns each on those grilles, and the same setups are now priced 171 and 545; a grille of 36
588
+ // flattened ovals packed 1 mm apart, 2,300 lines, 43 ms, was priced 46.
589
+ const SETUP_PRICE = { line: 0.02, other: 0.002, probe: 0.00004 };
590
+ function setupSearch(shapeOf, contours, ceiling, slow, spend) {
591
+ const counts = countsOf(contours);
592
+ const facing = !slow && linearTestPrice(counts) + counts.arc * counts.arc * PRICE.test.arcPair <= FACING_CAP;
593
+ spend(counts.line * SETUP_PRICE.line + (counts.arc + counts.cubic) * SETUP_PRICE.other
594
+ + (facing ? facingWork(contours, ceiling) * SETUP_PRICE.probe : 0));
595
+ const search = { shape: shapeOf(), counts, slow, facing: 0 };
596
+ if (facing) search.facing = facingLines(contours, ceiling);
597
+ return search;
598
+ }
599
+ function diffPrice(a, b) {
600
+ const P = PRICE.diff;
601
+ return (a.line + b.line) * P.line + (a.arc + b.arc) * P.arc + (a.cubic + b.cubic) * P.cubic;
602
+ }
603
+ // spend(price): not started unless `price` fits before the deadline, scaled by this
604
+ // device's measured pace. Each call settles the step the last one priced, and
605
+ // spend.settle() the last one; `onStep` hears each settled step as { price, ms }.
606
+ // Exported for its unit test only.
607
+ export function _meter(deadline, now, onStep) {
608
+ let priced = 0, took = 0, open = null;
609
+ const settle = (t) => {
610
+ if (!open) return;
611
+ priced += open.price; took += t - open.at;
612
+ onStep?.({ price: open.price, ms: t - open.at });
613
+ open = null;
614
+ };
615
+ const spend = (price = 0) => {
616
+ const t = now();
617
+ settle(t);
618
+ const pace = priced >= 50 ? Math.max(1, took / priced) : 1;
619
+ if (deadline !== Infinity && t + price * pace >= deadline) throw OUT_OF_TIME;
620
+ open = { price, at: t };
621
+ };
622
+ spend.settle = () => settle(now());
623
+ return spend;
624
+ }
625
+
626
+ // ── one width ─────────────────────────────────────────────────────────────────
627
+ // A test at width w asks whether material narrower than w leaves the profile (an
628
+ // OPENING: shrink by w/2, regrow) or empty space narrower than w fills in (a CLOSING:
629
+ // grow by w/2, shrink back), and returns what left (or entered) it, or null.
630
+ //
631
+ // ONE-SIDED, never a net area change, and computed on every test. With sharp offsets a
632
+ // corner narrower than w does not come back as itself: a convex fillet on the outline —
633
+ // an arc, a cubic, a chamfer or a run of short straight segments alike — loses its short
634
+ // edges and regrows as a square corner OUTSIDE the profile, and a hole's rounded or
635
+ // chamfered corner closes back square INSIDE the hole. Each lands on the side opposite
636
+ // the one being measured, so a net area change let an ordinary fillet elsewhere cancel
637
+ // a real narrow web or slot (capped, "nothing narrower found"), and so did any shortcut
638
+ // that read "nothing changed" off the net change — near a coincident dimension the
639
+ // artifacts cancel the finding to zero. Counting only what left the profile (opening)
640
+ // or only what entered it (closing) puts every such artifact on the uncounted side.
641
+ // And a loss is ONE region above LOSS_TOL_MM2, never a sum: the cubic offset's own
642
+ // approximation error (OFFSET_TOL) leaves hundreds of sub-tolerance slivers along
643
+ // curved edges, and summed they read three 8 mm rounded holes as a 2.91 mm gap — the
644
+ // same rule marksFacts counts stray marks by.
645
+ // That difference is a boolean between the profile and its near-copy — on curves
646
+ // paper's worst case — so it is priced from the test's own result before it starts,
647
+ // like every step. And it has no fallback: when paper refuses it, the refusal ends the
648
+ // reading (narrowest), which verify reports as a check it could not take.
649
+ //
650
+ // It is measured MARGIN clear of the searched shape: what left the profile is what the
651
+ // test lost of the profile shrunk by MARGIN, and what entered it is what the test gained
652
+ // beyond the profile grown by MARGIN. Against the shape itself, every boundary the test
653
+ // did not change runs within a hair of its twin, split at other points, and paper's
654
+ // boolean on such a pair fails two ways: it refuses ("curve-fill: resolved hole has no
655
+ // containing outer" — a clean keyhole read no gap at all), or it returns nothing — a
656
+ // keyhole's 4 mm slot, filled by a closing at 4.3 and 4.5 mm, came back as an empty
657
+ // difference and the gap read 4.55. MARGIN apart, the boolean meets curves only where
658
+ // they really cross. The offset engine's approximation slivers along curves (OFFSET_TOL,
659
+ // 1e-3 mm) go with the margin — and so would a web or slot under 2·MARGIN (0.02 mm), which
660
+ // is exactly what a kerf burns away: those are read from the boundary itself instead
661
+ // (nearContacts, below). The margin shape is one offset of the searched shape, made once
662
+ // per search when its first difference runs and priced into that difference.
663
+ // ── no change ─────────────────────────────────────────────────────────────────
664
+ // A test that finds nothing on straight edges and on arcs it does not cut short hands
665
+ // back the rings it was given, cut at extra points: a miter join meets an edge again at
666
+ // a vertex of its own, and a sharp join extends an arc along its own circle as a second
667
+ // arc. Ring for ring — collinear line runs merged, co-circular arc runs merged, each ring
668
+ // matched cyclically in the same orientation, every vertex and every arc's centre, radius
669
+ // and ends within SAME_EPS of its twin — that is no loss and no gain, and the priced
670
+ // difference is not run for it. It proves exactly what the difference would: the two
671
+ // shapes differ only inside a SAME_EPS band along the boundary, whose regions hold at most
672
+ // 2·SAME_EPS × their length, far under LOSS_TOL_MM2 and inside MARGIN besides. It compares
673
+ // geometry, never a number artifacts could cancel. Anything else falls through to the
674
+ // difference: a ring more or less, a corner regrown square, any cubic (an offset cubic
675
+ // never comes back the same), a vertex a hair off — including every ring the offset's
676
+ // winding resolver rebuilt, since it places arcs only as well as paper's cubic circle.
677
+ const SAME_EPS = 1e-9;
678
+ // A ring as its edges — { from, line } or { from, arc: { c, r, ccw } } — with runs merged,
679
+ // or [{ circle }] for a ring that is one whole circle; null when it holds a cubic.
680
+ function canonRing(ring) {
681
+ const c = asContour(ring), edges = [];
682
+ let from = c.start;
683
+ for (const seg of c.segments) {
684
+ if (seg.c1) return null;
685
+ const a = seg.via ? arcCircle(from, seg.via, seg.to) : null;
686
+ if (a) edges.push({ from, arc: { c: a.c, r: a.r, ccw: a.sweep > 0 }, sweep: Math.abs(a.sweep) });
687
+ else if (dist(from, seg.to) > SAME_EPS) edges.push({ from, line: true });
688
+ from = seg.to;
689
+ }
690
+ const sameCircle = (x, y) => x.arc && y.arc && dist(x.arc.c, y.arc.c) <= SAME_EPS && Math.abs(x.arc.r - y.arc.r) <= SAME_EPS && x.arc.ccw === y.arc.ccw;
691
+ if (edges.length && edges.every((e) => sameCircle(e, edges[0])) && Math.abs(edges.reduce((t, e) => t + e.sweep, 0) - 2 * Math.PI) < 1e-6)
692
+ return [{ circle: edges[0].arc }];
693
+ // Merge edge i+1 into edge i while it continues it: a line along the same line (its start
694
+ // within SAME_EPS of the chord from i's start to its end), an arc on the same circle.
695
+ for (let i = 0; edges.length > 1 && i < edges.length;) {
696
+ const j = (i + 1) % edges.length, e = edges[i], n = edges[j], end = edges[(j + 1) % edges.length].from;
697
+ const collinear = e.line && n.line && dist(e.from, end) > SAME_EPS
698
+ && Math.abs(cross2(sub2(n.from, e.from), sub2(end, e.from))) <= SAME_EPS * dist(e.from, end)
699
+ && dot2(sub2(n.from, e.from), sub2(end, n.from)) > 0;
700
+ if (collinear || (sameCircle(e, n) && e.sweep + n.sweep < 2 * Math.PI - 1e-6)) {
701
+ if (e.arc) e.sweep += n.sweep;
702
+ edges.splice(j, 1);
703
+ if (j < i) i--; // the ring's first edge merged into its last
704
+ } else i++;
705
+ }
706
+ return edges;
707
+ }
708
+ const sameEdge = (x, y) => dist(x.from, y.from) <= SAME_EPS && !!x.line === !!y.line
709
+ && (x.line || (dist(x.arc.c, y.arc.c) <= SAME_EPS && Math.abs(x.arc.r - y.arc.r) <= SAME_EPS && x.arc.ccw === y.arc.ccw));
710
+ function sameRing(x, y) {
711
+ if (x.length !== y.length) return false;
712
+ if (x[0].circle || y[0].circle) return !!(x[0].circle && y[0].circle) && dist(x[0].circle.c, y[0].circle.c) <= SAME_EPS
713
+ && Math.abs(x[0].circle.r - y[0].circle.r) <= SAME_EPS && x[0].circle.ccw === y[0].circle.ccw;
714
+ for (let k = 0; k < y.length; k++) {
715
+ if (dist(y[k].from, x[0].from) > SAME_EPS) continue;
716
+ if (x.every((e, i) => sameEdge(e, y[(k + i) % y.length]))) return true;
717
+ }
718
+ return false;
719
+ }
720
+ // A shape's rings in canonical form, bucketed by a key two equal rings share (their edge
721
+ // count and first vertex rounded to 1e-6 mm, a circle's centre): a lookup that misses
722
+ // only costs the difference. Null when a ring holds a cubic.
723
+ const ringKey = (e) => {
724
+ const q = (v) => Math.round(v * 1e6);
725
+ if (e[0].circle) return `o${q(e[0].circle.c[0])},${q(e[0].circle.c[1])}`;
726
+ const v = e.reduce((m, x) => (x.from[0] < m[0] || (x.from[0] === m[0] && x.from[1] < m[1]) ? x.from : m), e[0].from);
727
+ return `${e.length}:${q(v[0])},${q(v[1])}`;
728
+ };
729
+ function canonShape(contours) {
730
+ const rings = new Map();
731
+ let count = 0;
732
+ for (const rg of contours) for (const ring of [rg.outer, ...rg.holes]) {
733
+ const e = canonRing(ring);
734
+ if (!e) return null;
735
+ const key = ringKey(e);
736
+ if (!rings.has(key)) rings.set(key, []);
737
+ rings.get(key).push(e);
738
+ count++;
739
+ }
740
+ return { regions: contours.length, count, rings };
741
+ }
742
+ // Whether `shape` is the search's own shape, ring for ring.
743
+ function unchanged(search, shape) {
744
+ const mine = (search.canon ??= canonShape(search.shape.toContours()) ?? false);
745
+ if (!mine) return false;
746
+ const theirs = canonShape(shape.toContours());
747
+ if (!theirs || theirs.regions !== mine.regions || theirs.count !== mine.count) return false;
748
+ for (const [key, list] of theirs.rings) {
749
+ const pool = [...(mine.rings.get(key) ?? [])];
750
+ for (const e of list) {
751
+ const i = pool.findIndex((m) => sameRing(e, m));
752
+ if (i < 0) return false;
753
+ pool.splice(i, 1);
754
+ }
755
+ }
756
+ return true;
757
+ }
758
+
759
+ const MARGIN = 0.01;
760
+ function oneSided(difference, spend, price) {
761
+ spend(price);
762
+ const d = difference();
763
+ return !d.isEmpty() && d.regions().some((r) => r.area() > LOSS_TOL_MM2) ? d : null;
764
+ }
765
+ // The searched shape moved `side` × MARGIN (−1 shrunk, +1 grown), kept on the search, and
766
+ // what making it costs while it is not made yet: one offset, half a test at no width.
767
+ function margined(search, side) {
768
+ return (search.margin ??= {})[side] ??= search.shape.offset(side * MARGIN, SHARP);
769
+ }
770
+ const marginPrice = (search, side) => (search.margin?.[side] ? 0 : linearTestPrice(search.counts) / 2);
771
+
772
+ function openingLoss(search, w, spend) {
773
+ let opened;
774
+ try { opened = search.shape.offset(-w / 2, SHARP).offset(w / 2, SHARP); }
775
+ catch (e) { if (COLLAPSES.test(e?.message ?? "")) return search.shape; throw e; }
776
+ if (unchanged(search, opened)) return null;
777
+ return oneSided(() => margined(search, -1).cut(opened), spend,
778
+ marginPrice(search, -1) + diffPrice(search.counts, countsOf(opened.toContours())));
779
+ }
780
+
781
+ function closingGain(search, w, spend) {
782
+ const closed = search.shape.offset(w / 2, SHARP).offset(-w / 2, SHARP);
783
+ if (unchanged(search, closed)) return null;
784
+ return oneSided(() => closed.cut(margined(search, 1)), spend,
785
+ marginPrice(search, 1) + diffPrice(search.counts, countsOf(closed.toContours())));
786
+ }
787
+
788
+ // ── what the margin hides ─────────────────────────────────────────────────────────
789
+ // A web or a slot narrower than 2·MARGIN fits inside the margin, so the difference cannot
790
+ // see it — a slot a hair inside the plate's edge, a hairline slit: exactly what a 0.1 mm
791
+ // kerf burns away. The margin cannot shrink to let it through: the winding resolver merges
792
+ // crossings CLUSTER_TOL (0.005 mm) apart, and at a 0.005 margin the corners of large arcs
793
+ // read false webs of 4.7–5.7 mm on 6 mm stock (sector panels of radius 100–800). So what
794
+ // it hides is found directly, from the profile's own boundary: two faces of it within
795
+ // NEAR of each other (facingPairs), walked in NEAR_STEP pieces. A run of such pieces along
796
+ // one ring, all with the material between the faces (a web: the bridge) or all with it
797
+ // outside (a slot: the gap), counts once it is NEAR_RUN long — LOSS_TOL_MM2 of area at that
798
+ // width, the rule the difference counts a loss by — so the two sides of a sharp tip, which
799
+ // part at once, never do. It reads the narrowest such run's width, rounded up to 0.01 mm,
800
+ // at the middle of its closest pair. Whole segments are checked first — most profiles
801
+ // have no two faces that close and are done there — and the 0.1 mm walk runs only when
802
+ // they do. Linear in the pieces; priced as the walk, like a step.
803
+ const NEAR = 2 * MARGIN, NEAR_STEP = 0.1, NEAR_RUN = LOSS_TOL_MM2 / NEAR;
804
+ const NEAR_PRICE = 0.003; // per piece — see nearPrice
805
+ // The boundary pass's price, per piece of the 0.1 mm walk: a 900-hole grille's 91,000 took
806
+ // 0.15 s walked, a sign's worth of text 0.03 s (its pieces crowd the grid's cells).
807
+ const nearPrice = (contours) => {
808
+ let pieces = 0;
809
+ for (const rg of contours) for (const ring of [rg.outer, ...rg.holes]) for (const part of partsOf(ring)) {
810
+ let from = part.from;
811
+ for (const to of part.pts) { pieces += Math.max(1, Math.ceil(dist(from, to) / NEAR_STEP)); from = to; }
812
+ }
813
+ return NEAR_PRICE * pieces;
814
+ };
815
+ // → { bridge, gap }: each { value, at } or null.
816
+ function nearContacts(contours) {
817
+ // Whole segments first: most profiles have no two faces that close, and are done here.
818
+ let any = false;
819
+ facingPairs(contours, { reach: NEAR }, () => { any = true; });
820
+ if (!any) return { bridge: null, gap: null };
821
+ const hits = [];
822
+ const pieces = facingPairs(contours, { reach: NEAR, step: NEAR_STEP }, (i, j, d) => hits.push([i, j, d]));
823
+ const mid = (pc) => [(pc.a[0] + pc.b[0]) / 2, (pc.a[1] + pc.b[1]) / 2];
824
+ const best = new Map();
825
+ for (const [i, j, d] of hits) for (const [x, y] of [[i, j], [j, i]]) {
826
+ const px = pieces[x], side = cross2(px.dir, sub2(mid(pieces[y]), px.a)) > 0 ? "bridge" : "gap";
827
+ const cur = best.get(x);
828
+ if (!cur || d < cur.d) best.set(x, { d, side, other: y });
829
+ }
830
+ const out = { bridge: null, gap: null };
831
+ // A run is read at the middle of its narrowest stretch (within 1e-4 mm of its least width).
832
+ const found = (side, run) => {
833
+ const d = Math.min(...run.map((idx) => best.get(idx).d));
834
+ const narrowest = run.filter((idx) => best.get(idx).d <= d + 1e-4), i = narrowest[Math.floor(narrowest.length / 2)];
835
+ const at = [0, 1].map((c) => (mid(pieces[i])[c] + mid(pieces[best.get(i).other])[c]) / 2);
836
+ const value = Math.max(0.01, Math.ceil(d * 100 - 1e-9) / 100);
837
+ if (!out[side] || value < out[side].value) out[side] = { value, at };
838
+ };
839
+ for (let s0 = 0; s0 < pieces.length;) {
840
+ let n = 1;
841
+ while (s0 + n < pieces.length && pieces[s0 + n].ring === pieces[s0].ring) n++;
842
+ const sideOf = (k) => best.get(s0 + (k % n))?.side ?? null;
843
+ // Walk the ring from a piece whose side differs from its predecessor's, so that no run
844
+ // wraps across the walk's start; a ring whose pieces all agree is one run.
845
+ let first = 0;
846
+ while (first < n && sideOf(first) === sideOf(first + n - 1)) first++;
847
+ const stop = first === n ? n : first + n;
848
+ for (let k = first === n ? 0 : first; k < stop;) {
849
+ const side = sideOf(k), run = [];
850
+ let len = 0;
851
+ for (; k < stop && sideOf(k) === side; k++) { run.push(s0 + (k % n)); len += pieces[s0 + (k % n)].len; }
852
+ if (side && len >= NEAR_RUN) found(side, run);
853
+ }
854
+ s0 += n;
855
+ }
856
+ return out;
857
+ }
858
+ // The reading with what the margin hides beside it: the narrower of the two.
859
+ const withNear = (reading, near) => (near && (reading.capped || near.value < reading.value)
860
+ ? { value: near.value, capped: false, at: near.at }
861
+ : reading);
862
+
863
+ // The geometry engine refuses with a plain Error ("contour-winding: could not chain…",
864
+ // "curve-fill: …"); a TypeError, RangeError or the like is a bug, never a refusal, and
865
+ // is left to propagate so reading() can report it.
866
+ const isRefusal = (e) => e instanceof Error && e.name === "Error";
867
+
868
+ // The narrowest width at which `test` finds something, bisected over [0, ceiling] to
869
+ // WIDTH_RESOLUTION: the upper end of the last bracket (the first width that fails),
870
+ // rounded to 0.01 mm. The ceiling is tried first — most panels have nothing that
871
+ // narrow, and one test settles them — and then reads as the ceiling itself, `capped`.
872
+ // The finding is located by the last shape actually found.
873
+ // An engine refusal at any width ends the reading: reading() records why, with the width
874
+ // it came at, and the value is null, so verify says the check could not be taken.
875
+ // Counting a refusal as found read a refusal between a web's true width and the floor as
876
+ // a sub-floor web (a false warning placed at the real web); counting it as not found
877
+ // would pass a check on nothing.
878
+ function narrowest(test, ceiling, spend, price) {
879
+ const at = (w) => {
880
+ spend(price(w));
881
+ try { return test(w); } catch (e) {
882
+ if (isRefusal(e)) throw new Error(`${e.message} (width search at ${fmtMm(w)} mm)`, { cause: e });
883
+ throw e;
884
+ }
885
+ };
886
+ let found = at(ceiling);
887
+ if (!found) return { value: ceiling, capped: true, at: null };
888
+ let lo = 0, hi = ceiling;
889
+ while (hi - lo > WIDTH_RESOLUTION) {
890
+ const mid = (lo + hi) / 2, hit = at(mid);
891
+ if (hit) { hi = mid; found = hit; } else lo = mid;
892
+ }
893
+ return { value: round2(hi), capped: false, at: centreOfLargest(found) };
894
+ }
895
+
896
+ // The narrowest opening: the closing's reading on the profile less the holes it can
897
+ // leave out (holePlan), against the narrowest round hole it left out, read directly —
898
+ // a booleaned screw hole never enters the bisection (4.5 s for one M2.5 hole, before).
899
+ // The narrower of the two wins.
900
+ function narrowestGap(search, round, ceiling, spend) {
901
+ const gap = narrowest((w) => closingGain(search, w, spend), ceiling, spend, () => testPrice(search));
902
+ const smallest = round.length ? round.reduce((a, b) => (b.d < a.d - 1e-9 ? b : a)) : null; // the first of equals
903
+ return smallest && smallest.d < ceiling && (gap.capped || smallest.d < gap.value)
904
+ ? { value: round2(smallest.d), capped: false, at: smallest.at }
905
+ : gap;
906
+ }
907
+
908
+ // One budget-gated reading, or null when it could not be taken — the geometry engine
909
+ // refused this profile (an offset it could not chain), or anything else threw — a
910
+ // missing reading, never a failed report. Never a silent one either: the reason lands in
911
+ // `errors[key]` (SheetFacts.readErrors), and verify reports it (oracle/verify.js).
912
+ // Running out of time is not caught here: it ends every gated reading at once (facts()).
913
+ const READ_ERROR_CHARS = 200;
914
+ function reading(errors, key, fn) {
915
+ try {
916
+ return fn();
917
+ } catch (e) {
918
+ if (e === OUT_OF_TIME) throw e;
919
+ errors[key] = String(e?.message || e).slice(0, READ_ERROR_CHARS);
920
+ return null;
921
+ }
922
+ }
923
+
924
+ // Engrave ∪ score grooves, and how many regions of it lie outside the cut.
925
+ function marksFacts(s, spend) {
926
+ const marks = [s.engrave, ...s.grooves].filter(Boolean).reduce((acc, m) => (acc ? acc.union(m) : m), null);
927
+ if (!marks) return { marks: null, outside: 0, at: null };
928
+ spend();
929
+ const off = marks.cut(s.profile);
930
+ const outside = off.isEmpty() ? 0 : off.regions().filter((r) => r.area() > LOSS_TOL_MM2).length;
931
+ return { marks, outside, at: outside ? centreOfLargest(off) : null };
932
+ }
933
+
934
+ // ── verify metrics ─────────────────────────────────────────────────────────────
935
+ // SUBPART_METRICS-shaped (verify-metrics.js spreads SHEET_METRICS in). All warnings.
936
+ // `doc` names the guide section a failing check points at — oracle/verify.js turns
937
+ // it into the check's `pattern`; it is deliberately not `pattern`, which must name a
938
+ // docs/ERROR-PATTERNS.md entry. `budgeted` marks the readings the 2-D deadline can
939
+ // withhold, which verify reports as not evaluated rather than unavailable; `readError`
940
+ // returns why a reading could not be taken, which verify reports instead of a bare skip.
941
+ const cappedNote = (key) => (s) => (s.sheet?.[`${key}Capped`]
942
+ ? `nothing narrower than ${fmtMm(s.sheet[key])} mm found — the value is the search ceiling`
943
+ : null);
944
+
945
+ const METRICS = {
946
+ sheetBridge: { kind: "warn", doc: SHEET_DOC_ID, budgeted: true,
947
+ extract: (s) => (s.sheet?.evaluated ? s.sheet.bridge : null),
948
+ locate: (s) => s.sheet?.at?.bridge ?? null,
949
+ readError: (s) => s.sheet?.readErrors?.bridge ?? null,
950
+ note: cappedNote("bridge"),
951
+ hint: "a web or finger of this sheet part is narrower than a laser can leave standing (the floor is half the sheet thickness, at least 0.5 mm) — widen the material between cuts at the reported location, or use fewer, wider fingers" },
952
+ sheetGap: { kind: "warn", doc: SHEET_DOC_ID, budgeted: true,
953
+ extract: (s) => (s.sheet?.evaluated ? s.sheet.gap : null),
954
+ locate: (s) => s.sheet?.at?.gap ?? null,
955
+ readError: (s) => s.sheet?.readErrors?.gap ?? null,
956
+ note: cappedNote("gap"),
957
+ hint: "a hole, slot or notch in this sheet part is narrower than a laser can reliably cut (half the sheet thickness, at least 0.5 mm) — widen it at the reported location" },
958
+ sheetMarks: { kind: "warn", doc: SHEET_DOC_ID, budgeted: true,
959
+ extract: (s) => (s.sheet?.evaluated ? s.sheet.marksOutside : null),
960
+ locate: (s) => s.sheet?.at?.marks ?? null,
961
+ readError: (s) => s.sheet?.readErrors?.marks ?? null,
962
+ hint: "an engrave or score mark lies outside the cut outline, so it would burn scrap or empty air — move it inside the profile" },
963
+ sheetPieces: { kind: "warn", doc: SHEET_DOC_ID,
964
+ extract: (s) => s.sheet?.pieces ?? null,
965
+ hint: "the profile is not exactly one piece — a sheet part must cut out as one region; join the pieces or split them into separate sheetPart sub-parts" },
966
+ sheetSolidMatch: { kind: "warn", doc: SHEET_DOC_ID, budgeted: true,
967
+ extract: (s) => s.sheet?.solidMatchPct ?? null,
968
+ readError: (s) => s.sheet?.readErrors?.marksArea ?? null,
969
+ hint: "this sheet part's custom build drifts from its profile (volume vs. profile area × thickness) — the cut file comes from the profile, so fix the profile or drop the custom build" },
970
+ };
971
+
972
+ // ── lint ───────────────────────────────────────────────────────────────────────
973
+ // The laser's own rule; lint/rules-sheet.js appends every process's
974
+ // (PROCESS_LINT_RULES). Kernel-free, and a warning: the part still builds. A
975
+ // thickness that is not a finite number above 0 is sheet-thickness-invalid's error,
976
+ // not also this warning — one cause, one finding.
977
+ const LASER_RULES = [{
978
+ id: "laser-thickness-range",
979
+ run: ({ part, p, d, deriveError }) => {
980
+ if (deriveError || !part?.parts || typeof part.parts !== "object") return [];
981
+ const [lo, hi] = LASER_THICKNESS_RANGE;
982
+ return Object.entries(part.parts).flatMap(([name, sp]) => {
983
+ if (!isSheetPart(sp) || sp.sheet.process !== "laser" || typeof sp.build !== "function") return [];
984
+ const meta = sheetMeta(sp, p, d);
985
+ if (!meta || typeof meta.thickness !== "number" || !Number.isFinite(meta.thickness) || meta.thickness <= 0) return [];
986
+ if (meta.thickness >= lo && meta.thickness <= hi) return [];
987
+ return [warn("laser-thickness-range",
988
+ `sub-part "${name}": laser sheet thickness ${fmtMm(meta.thickness)} mm is outside ${fmtMm(lo)}–${fmtMm(hi)} mm`,
989
+ "Hobby lasers and cutting services handle roughly 0.5–12 mm stock; check that thickness is the measured value in mm (not inches or a stock code), or make this part another way.",
990
+ `parts.${name}.sheet.thickness`, SHEET_DOC_ID)];
991
+ });
992
+ },
993
+ }];
994
+
995
+ export const LASER = {
996
+ id: "laser",
997
+ label: "Laser cutting",
998
+ stock: "sheet",
999
+ docId: SHEET_DOC_ID,
1000
+ // profile = the CUT layer (filled regions: outline plus holes), score = vector lines,
1001
+ // engrave = filled regions burned into the laser face.
1002
+ layers: { profile: "region", score: "line", engrave: "region" },
1003
+ // Keys only this process reserves (sheet/constants.js RESERVED_KEYS covers the
1004
+ // global ones: folds, bends, grain).
1005
+ reservedKeys: [],
1006
+ // No preview hook: the default extrusion-plus-marks build (sheet/resolve.js
1007
+ // sheetPreview) is the laser preview.
1008
+ preview: undefined,
1009
+ destinations: [{ id: "own-laser", cutFormat: "svg" }, { id: "service", cutFormat: "dxf" }],
1010
+ // The sheet's 2-D facts (SheetFacts). Budget-gated readings — bridge, gap, marks —
1011
+ // run only until `deadline` (an absolute time in ms on `now`'s clock, Date.now by
1012
+ // default), and a step priced past it is not started (meter); then `evaluated` stays
1013
+ // false and each is null. The rest is cheap and always read. `at` and `solidMatchPct`
1014
+ // need the 3-D part and are the oracle's to fill (oracle/measure.js). `onStep`, a
1015
+ // diagnostic for the timing bench and the tests, hears every priced step that ran as
1016
+ // { price, ms } on `now`'s clock.
1017
+ facts(s, { deadline = Infinity, now = Date.now, onStep } = {}) {
1018
+ const { profile, thickness: t } = s;
1019
+ const contours = profile.toContours();
1020
+ const pieces = contours.length;
1021
+ const area = pieces ? profile.area() : 0;
1022
+ const bb = pieces ? profile.boundingBox() : null;
1023
+ const f = {
1024
+ process: s.process, material: s.material, thickness: t, group: s.group,
1025
+ flat: bb ? [bb.max[0] - bb.min[0], bb.max[1] - bb.min[1]] : [0, 0],
1026
+ area, pieces, customBuild: s.customBuild,
1027
+ marksArea: null, bridge: null, bridgeCapped: false, gap: null, gapCapped: false,
1028
+ marksOutside: null, solidMatchPct: null,
1029
+ at2d: { bridge: null, gap: null, marks: null },
1030
+ at: { bridge: null, gap: null, marks: null },
1031
+ readErrors: { bridge: null, gap: null, marks: null, marksArea: null },
1032
+ evaluated: false,
1033
+ };
1034
+ const spend = _meter(deadline, now, onStep);
1035
+ const ceiling = 2 * widthFloor(t);
1036
+ // Each search's shape: the profile with its circular cubics read as arcs and its slow
1037
+ // cubics as lines (searchPlan), less the holes it can leave out, rebuilt from its own
1038
+ // rings — or the profile itself, when none of that changes it or the kernel has no
1039
+ // trusted lift. The plan is made inside the readings that use it (and kept once made),
1040
+ // so whatever goes wrong in it costs those readings (readErrors), never these facts.
1041
+ const lift = s.trustedShape2d;
1042
+ let planned = null;
1043
+ const plan = () => (planned ??= lift && pieces ? searchPlan(contours, ceiling, s.recoverArcs) : { open: null, close: null, round: [] });
1044
+ const searchOn = (reduced, shrinks) => setupSearch(() => (reduced ? lift(reduced) : profile), reduced ?? contours, ceiling,
1045
+ reduced ? 0 : slowCubics(contours, ceiling, shrinks), spend);
1046
+ // What the difference's margin hides (nearContacts), read once, for both searches.
1047
+ let near = null;
1048
+ const nearOf = () => {
1049
+ if (!near) { spend(nearPrice(contours)); near = nearContacts(contours); }
1050
+ return near;
1051
+ };
1052
+ try {
1053
+ const errors = { bridge: null, gap: null, marks: null, marksArea: null };
1054
+ const bridge = reading(errors, "bridge", () => {
1055
+ const search = searchOn(plan().open, 1);
1056
+ return withNear(narrowest((w) => openingLoss(search, w, spend), ceiling, spend, () => testPrice(search)), nearOf().bridge);
1057
+ });
1058
+ const gap = reading(errors, "gap", () => withNear(narrowestGap(searchOn(plan().close, -1), plan().round, ceiling, spend), nearOf().gap));
1059
+ const m = reading(errors, "marks", () => marksFacts(s, spend));
1060
+ // A custom build is compared with profile area × thickness minus the marks'
1061
+ // removed volume (oracle/measure.js), so it needs the marks inside the cut.
1062
+ const marksArea = !s.customBuild || m === null ? null
1063
+ : reading(errors, "marksArea", () => { spend(); return m.marks ? m.marks.intersect(profile).area() : 0; });
1064
+ Object.assign(f, {
1065
+ readErrors: errors,
1066
+ bridge: bridge?.value ?? null, bridgeCapped: bridge?.capped ?? false,
1067
+ gap: gap?.value ?? null, gapCapped: gap?.capped ?? false,
1068
+ marksOutside: m?.outside ?? null, marksArea,
1069
+ at2d: { bridge: bridge?.at ?? null, gap: gap?.at ?? null, marks: m?.at ?? null },
1070
+ evaluated: true,
1071
+ });
1072
+ } catch (e) {
1073
+ if (e !== OUT_OF_TIME) throw e;
1074
+ }
1075
+ if (onStep) spend.settle();
1076
+ return f;
1077
+ },
1078
+ // The process's own expectations, VOLUNTEERED by verify on every sheet and never
1079
+ // counted toward declared/evaluated (oracle/verify.js). Thresholds follow thickness.
1080
+ checks(f) {
1081
+ const floor = `>=${round3(widthFloor(f.thickness))}`;
1082
+ return {
1083
+ sheetBridge: floor, sheetGap: floor, sheetMarks: "0", sheetPieces: "1",
1084
+ ...(f.customBuild ? { sheetSolidMatch: `<=${SOLID_MATCH_PCT}` } : {}),
1085
+ };
1086
+ },
1087
+ metrics: METRICS,
1088
+ lintRules: LASER_RULES,
1089
+ };