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.
- package/bin/cli.js +12 -2
- package/docs/AUTHORING-PARTS.md +304 -1
- package/docs/KERNEL-CONTRACT.md +40 -10
- package/package.json +1 -1
- package/src/framework/geometry/arc-fit.js +169 -36
- package/src/framework/geometry/arc-math.js +15 -7
- package/src/framework/geometry/contour-offset.js +132 -5
- package/src/framework/geometry/polygon.js +7 -0
- package/src/framework/lint/index.js +2 -1
- package/src/framework/lint/rules-schema.js +2 -1
- package/src/framework/lint/rules-shape.js +10 -3
- package/src/framework/lint/rules-sheet.js +172 -0
- package/src/framework/lint/rules-verify.js +10 -0
- package/src/framework/oracle/gates.js +13 -0
- package/src/framework/oracle/measure.js +129 -5
- package/src/framework/oracle/verify.js +113 -5
- package/src/framework/process/laser/descriptor.js +1089 -0
- package/src/framework/process/registry.js +24 -0
- package/src/framework/sheet/constants.js +104 -0
- package/src/framework/sheet/joinery.js +356 -0
- package/src/framework/sheet/part.js +98 -0
- package/src/framework/sheet/pose.js +114 -0
- package/src/framework/sheet/resolve.js +133 -0
- package/src/framework/verify-metrics.js +8 -1
- package/src/parts/laser-box.js +108 -0
- package/types/geometry.d.ts +115 -0
- package/types/lint.d.ts +4 -1
- package/types/oracle.d.ts +1 -1
- package/types/part.d.ts +57 -1
- package/types/testing.d.ts +81 -1
|
@@ -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
|
+
};
|