partforge 0.132.0 → 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/materials/environments.js +1 -1
- package/src/framework/materials/presets.js +2 -2
- 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/src/parts/material-swatches.js +23 -1
- 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
|
@@ -11,21 +11,42 @@
|
|
|
11
11
|
// cubic's ENDPOINTS to the true circle and only the interior deviates, so a
|
|
12
12
|
// three-point fit through endpoints recovers the original centre and radius to
|
|
13
13
|
// float precision. The tolerances are therefore an acceptance test on the
|
|
14
|
-
// interiors, not the accuracy of the result — see
|
|
15
|
-
// of them and why a radius-relative one alone is not enough
|
|
14
|
+
// interiors and joints, not the accuracy of the result — see probeRun for why
|
|
15
|
+
// there are two of them and why a radius-relative one alone is not enough, and
|
|
16
|
+
// fitCircle for why a fit through three nearly collinear points is refused. What
|
|
17
|
+
// it accepts lies within min(1e-3·r, 2e-3·chord) of each cubic at every probe: a
|
|
18
|
+
// non-circle closer than that to a circle is read as that circle.
|
|
16
19
|
//
|
|
17
|
-
// Pure leaf: DOM-free, node-free
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
// Pure leaf: DOM-free, node-free and paper-free — the laser checks' resolver hands it to
|
|
21
|
+
// the width searches (sheet/resolve.js), and the oracle and lint may not load paper.
|
|
22
|
+
// Those run it before the first step they price, so its cost matters — cheap on real
|
|
23
|
+
// outlines (4,000 cubics in 10–35 ms; growth is closer to n^1.6 than linear on a smooth
|
|
24
|
+
// traced blob — docs/research/sheet-inspect-timing.md) — and its linear-time search
|
|
25
|
+
// (recoverArcs) returns exactly what an exhaustive search of every run length would,
|
|
26
|
+
// identity-checked over 280,000 synthetic rings.
|
|
27
|
+
import { circumcircle } from "./arc-math.js";
|
|
28
|
+
import { cubicAt } from "./contour-corners.js";
|
|
20
29
|
|
|
21
30
|
const ARC_TOL = 1e-3; // relative to the FITTED radius
|
|
22
|
-
const CHORD_TOL = 2e-3; // relative to each cubic's OWN chord — see
|
|
23
|
-
|
|
31
|
+
const CHORD_TOL = 2e-3; // relative to each cubic's OWN chord — see probeRun
|
|
32
|
+
// Seven interior points of each cubic and its END — every joint of the run as well as
|
|
33
|
+
// its middles (the run's first point is a fit point, on the circle by construction).
|
|
34
|
+
const PROBE_TS = [0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 1];
|
|
24
35
|
const MAX_SWEEP = Math.PI; // split arcs at 180° so the 3-point form stays unambiguous
|
|
36
|
+
const MAX_SPAN_RATIO = 1e4; // a fitted radius past this × the fit points' span — see fitCircle
|
|
37
|
+
// Rounding, per unit of the numbers' own scale. A three-point fit in absolute coordinates
|
|
38
|
+
// moves a probe by up to about 40·EPS·M²/span (M the largest coordinate; measured over
|
|
39
|
+
// 190,000 fits), and a probe's computed distance from a circle is good to a few EPS of
|
|
40
|
+
// the radius and coordinates; both are taken at this, with headroom.
|
|
41
|
+
const ROUND = 64 * Number.EPSILON;
|
|
25
42
|
|
|
26
43
|
const dist = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]);
|
|
27
44
|
|
|
28
|
-
// Does every probed interior point of every cubic
|
|
45
|
+
// Does every probed interior point of every cubic of the run segs[i..j] lie on the circle?
|
|
46
|
+
// → { miss: { dev, bound, at } } for a probe that does not (its distance, the loosest
|
|
47
|
+
// bound any fit can give it — its chord's — and where it is), else { devs, bounds }: how
|
|
48
|
+
// far each cubic's probes lie from the circle at most, and each one's chord bound. With
|
|
49
|
+
// `all`, every probe is measured and none refused.
|
|
29
50
|
//
|
|
30
51
|
// TWO bounds, and the tighter one wins. `ARC_TOL * r` alone is not a usable
|
|
31
52
|
// acceptance test, because r is the FITTED radius: the flatter a curve is, the
|
|
@@ -50,17 +71,37 @@ const dist = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]);
|
|
|
50
71
|
// from 0.5 to 1e5, the worst genuine deviation is 1.83e-4 of chord — so 2e-3
|
|
51
72
|
// leaves an 11x margin. The two shallow non-circular cubics that used to be
|
|
52
73
|
// accepted deviate by 3.1e-3 and 6.8e-3 of chord, comfortably outside it.
|
|
53
|
-
|
|
74
|
+
//
|
|
75
|
+
// A probe's distance from the circle is taken without cancellation (offCircle): as
|
|
76
|
+
// `|q − centre| − r` it is the difference of two numbers the size of r, and where r
|
|
77
|
+
// is huge it loses every digit the probe's own offset lives in.
|
|
78
|
+
function probeRun(segs, i, j, from, fit, all = false) {
|
|
79
|
+
const devs = [], bounds = [];
|
|
54
80
|
let p = from;
|
|
55
|
-
for (
|
|
56
|
-
const
|
|
81
|
+
for (let k = i; k <= j; k++) {
|
|
82
|
+
const s = segs[k], bound = CHORD_TOL * dist(p, s.to);
|
|
83
|
+
const tol = Math.min(ARC_TOL * fit.r, bound);
|
|
84
|
+
let most = 0;
|
|
57
85
|
for (const t of PROBE_TS) {
|
|
58
|
-
const
|
|
59
|
-
if (
|
|
86
|
+
const at = cubicAt(p, s.c1, s.c2, s.to, t), d = Math.abs(offCircle(fit, at));
|
|
87
|
+
if (d > tol && !all) return { miss: { dev: d, bound, at } };
|
|
88
|
+
if (d > most) most = d;
|
|
60
89
|
}
|
|
90
|
+
devs.push(most); bounds.push(bound);
|
|
61
91
|
p = s.to;
|
|
62
92
|
}
|
|
63
|
-
return
|
|
93
|
+
return { devs, bounds };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// The signed distance of q from the fitted circle, computed in the frame of the fit's
|
|
97
|
+
// first point `a`: with κ = 1/r and u the unit vector from a toward the centre, the
|
|
98
|
+
// circle is κ|q − a|² − 2(q − a)·u = 0, and that left side is f = (ρ² − r²)/r for q at
|
|
99
|
+
// ρ from the centre. So ρ − r = f / (1 + √(1 + κf)) — no difference of r-sized numbers,
|
|
100
|
+
// and finite (the distance from the chord line) as κ goes to zero.
|
|
101
|
+
function offCircle({ a, kappa, u }, q) {
|
|
102
|
+
const v = [q[0] - a[0], q[1] - a[1]];
|
|
103
|
+
const f = kappa * (v[0] * v[0] + v[1] * v[1]) - 2 * (v[0] * u[0] + v[1] * u[1]);
|
|
104
|
+
return f / (1 + Math.sqrt(Math.max(0, 1 + kappa * f)));
|
|
64
105
|
}
|
|
65
106
|
|
|
66
107
|
// Fit through the run's first, middle and last ENDPOINT — all exact on the
|
|
@@ -70,16 +111,98 @@ function runFits(run, from, center, r) {
|
|
|
70
111
|
// cubic's own midpoint instead. That point is NOT exact on the circle (it's
|
|
71
112
|
// off by paper's kappa error), so the resulting centre/radius are only
|
|
72
113
|
// approximate for a still-unextended single-cubic run — but they're accurate
|
|
73
|
-
// enough to pass
|
|
114
|
+
// enough to pass probeRun's tolerance, and any later join with a second
|
|
74
115
|
// segment re-fits through three real endpoints and recovers the exact circle.
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
116
|
+
//
|
|
117
|
+
// A fit through three points that are collinear to float noise is refused: its
|
|
118
|
+
// radius is astronomical (a sine-wave edge fitted across whole periods came out near
|
|
119
|
+
// 1e17), and an arc that size is a straight chord drawn through the curve. It is
|
|
120
|
+
// refused past MAX_SPAN_RATIO × the three points' span, where a real arc's sagitta is
|
|
121
|
+
// under span/80,000 — a line, to every tolerance here — and a centre and `via` built
|
|
122
|
+
// that far out would lose their digits (arcsBetween) besides.
|
|
123
|
+
//
|
|
124
|
+
// The run is read in place, by index — never copied: recoverArcs tries a fit for many
|
|
125
|
+
// lengths of a run. `noise` is how far rounding can move a probe off the fitted circle
|
|
126
|
+
// (ROUND), `scale` how large the numbers its probes are measured with are.
|
|
127
|
+
function fitCircle(segs, i, j, from) {
|
|
128
|
+
const n = j - i + 1; // its endpoints are from, segs[i].to … segs[j].to
|
|
129
|
+
const mid = n >= 2
|
|
130
|
+
? segs[i + Math.floor((n + 1) / 2) - 1].to
|
|
131
|
+
: cubicAt(from, segs[i].c1, segs[i].c2, segs[i].to, 0.5);
|
|
132
|
+
const a = from, b = segs[j].to;
|
|
133
|
+
const c = circumcircle(a, mid, b);
|
|
81
134
|
if (!c || !Number.isFinite(c.r) || c.r <= 0) return null;
|
|
82
|
-
|
|
135
|
+
const span = Math.max(dist(a, mid), dist(mid, b), dist(a, b));
|
|
136
|
+
if (!(c.r <= MAX_SPAN_RATIO * span)) return null;
|
|
137
|
+
const u = [(c.center[0] - a[0]) / c.r, (c.center[1] - a[1]) / c.r];
|
|
138
|
+
const M = Math.max(Math.abs(a[0]), Math.abs(a[1]), Math.abs(mid[0]), Math.abs(mid[1]), Math.abs(b[0]), Math.abs(b[1]));
|
|
139
|
+
return { center: c.center, r: c.r, a, kappa: 1 / c.r, u, noise: (ROUND * M * M) / span, scale: 3 * c.r + M };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Does the run segs[i..j] fit the circle c? `known` is the last fit a run from i was
|
|
143
|
+
// probed whole against: its fit, the last cubic it covers (upTo), and how far each covered
|
|
144
|
+
// cubic's probes lay from its fit (devs, with their chord bounds). A probe lies at most
|
|
145
|
+
// |Δcentre| + |Δr| farther from c than from that fit, so a covered cubic whose probes the
|
|
146
|
+
// shift cannot carry past their bound under c is not probed again (the shift is taken with
|
|
147
|
+
// both fits' rounding on top); the rest are. Where more than a quarter of them would be,
|
|
148
|
+
// the run is probed whole and becomes the new `known`.
|
|
149
|
+
// → { miss } as probeRun's, or { known } for the run that fitted.
|
|
150
|
+
function fitsRun(segs, i, j, runFrom, c, known) {
|
|
151
|
+
const start = (k) => (k === i ? runFrom : segs[k - 1].to);
|
|
152
|
+
if (known) {
|
|
153
|
+
const tolR = ARC_TOL * c.r, covered = known.upTo - i + 1;
|
|
154
|
+
const shift = dist(c.center, known.fit.center) + Math.abs(c.r - known.fit.r) + ROUND * Math.max(c.scale, known.fit.scale);
|
|
155
|
+
const again = [];
|
|
156
|
+
if (!(known.dev + shift <= tolR && shift <= known.slack)) {
|
|
157
|
+
for (let k = 0; k < covered && 4 * again.length <= covered; k++) {
|
|
158
|
+
if (!(known.devs[k] + shift <= Math.min(tolR, known.bounds[k]))) again.push(i + k);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (4 * again.length <= covered) {
|
|
162
|
+
for (const k of again) { const m = probeRun(segs, k, k, start(k), c); if (m.miss) return m; }
|
|
163
|
+
const m = probeRun(segs, known.upTo + 1, j, start(known.upTo + 1), c);
|
|
164
|
+
if (m.miss) return m;
|
|
165
|
+
const x = probeRun(segs, known.upTo + 1, j, start(known.upTo + 1), known.fit, true);
|
|
166
|
+
return { known: cover(known, x, j) };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const m = probeRun(segs, i, j, runFrom, c);
|
|
170
|
+
return m.miss ? m : { known: cover({ fit: c, devs: [], bounds: [], dev: 0, slack: Infinity }, m, j) };
|
|
171
|
+
}
|
|
172
|
+
// `known` with the cubics measured in `x` (devs against known's own fit) covered, to `upTo`.
|
|
173
|
+
function cover(known, x, upTo) {
|
|
174
|
+
x.devs.forEach((d, k) => {
|
|
175
|
+
known.devs.push(d); known.bounds.push(x.bounds[k]);
|
|
176
|
+
known.dev = Math.max(known.dev, d); known.slack = Math.min(known.slack, x.bounds[k] - d);
|
|
177
|
+
});
|
|
178
|
+
known.upTo = upTo;
|
|
179
|
+
return known;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Whether no longer run from the same start can fit where the fit `c` of the run segs[i..j]
|
|
183
|
+
// missed. Every longer fit passes through the run's start as c does, and must come within
|
|
184
|
+
// its bound of c's other two fit points — the ends of the cubic at the run's middle and of
|
|
185
|
+
// its last cubic — and of the missed probe. Two circles through one point differ, to first
|
|
186
|
+
// order, by α + β·cos θ + γ·sin θ along the circle, so where they differ by e1 and e2 at the
|
|
187
|
+
// other two fit points they differ by at most |L1|·e1 + |L2|·e2 at the probe (L the
|
|
188
|
+
// trigonometric Lagrange basis on the three fit points' angles about c's centre). Those two
|
|
189
|
+
// fit points sit exactly on c only by construction: for an ill-conditioned fit — two of the
|
|
190
|
+
// three fit points close enough together that rounding swamps their geometry — c's own
|
|
191
|
+
// circle can miss them by more than `noise` allows, and a longer circle is free to differ
|
|
192
|
+
// from c by that same slop before the Lagrange bound above even starts to apply. e1 and e2
|
|
193
|
+
// are that residual — c's own deviation at its middle and last fit points — added into reach
|
|
194
|
+
// so a longer fit isn't cut off by headroom the rounding already spent. A miss farther than
|
|
195
|
+
// the result from any circle a longer run could fit, with a quarter's headroom and the fits'
|
|
196
|
+
// rounding on top, is final.
|
|
197
|
+
function final(segs, i, j, runFrom, c, miss) {
|
|
198
|
+
const n = j - i + 1, m = i + Math.floor((n + 1) / 2) - 1; // the cubic ending at the middle fit point
|
|
199
|
+
const bound = (k) => CHORD_TOL * dist(k === i ? runFrom : segs[k - 1].to, segs[k].to);
|
|
200
|
+
const angle = (p) => Math.atan2(p[1] - c.center[1], p[0] - c.center[0]);
|
|
201
|
+
const t0 = angle(runFrom), t1 = angle(segs[m].to), t2 = angle(segs[j].to), tq = angle(miss.at);
|
|
202
|
+
const basis = (x, y, z) => Math.abs((Math.sin((tq - x) / 2) * Math.sin((tq - y) / 2)) / (Math.sin((z - x) / 2) * Math.sin((z - y) / 2)));
|
|
203
|
+
const e1 = Math.abs(offCircle(c, segs[m].to)), e2 = Math.abs(offCircle(c, segs[j].to));
|
|
204
|
+
const reach = basis(t0, t2, t1) * (bound(m) + c.noise + e1) + basis(t0, t1, t2) * (bound(j) + c.noise + e2);
|
|
205
|
+
return miss.dev > miss.bound + 1.25 * reach + 2 * c.noise;
|
|
83
206
|
}
|
|
84
207
|
|
|
85
208
|
// One arc from `from` to `to` about `center`, split so no piece exceeds 180°.
|
|
@@ -105,10 +228,10 @@ function arcsBetween(from, to, center, r, sweepSign) {
|
|
|
105
228
|
return out;
|
|
106
229
|
}
|
|
107
230
|
|
|
108
|
-
// The direction the run actually travels, from
|
|
109
|
-
function sweepSignOf(
|
|
231
|
+
// The direction the run actually travels, from its first cubic's own geometry.
|
|
232
|
+
function sweepSignOf(first, from, center) {
|
|
110
233
|
const a = [from[0] - center[0], from[1] - center[1]];
|
|
111
|
-
const q = cubicAt(from,
|
|
234
|
+
const q = cubicAt(from, first.c1, first.c2, first.to, 0.5);
|
|
112
235
|
const b = [q[0] - center[0], q[1] - center[1]];
|
|
113
236
|
return a[0] * b[1] - a[1] * b[0] >= 0 ? 1 : -1;
|
|
114
237
|
}
|
|
@@ -122,22 +245,32 @@ export function recoverArcs(contour) {
|
|
|
122
245
|
while (i < segs.length) {
|
|
123
246
|
if (!segs[i].c1) { out.push(segs[i]); from = segs[i].to; i++; continue; }
|
|
124
247
|
|
|
125
|
-
// Greedy:
|
|
248
|
+
// Greedy: the longest run from i that fits one circle. A shorter run failing does not
|
|
249
|
+
// rule out a longer one — its fit points are closer together, so it is refused as too
|
|
250
|
+
// flat for its span, or rounding swamps it, or a small cubic near no fit point misses
|
|
251
|
+
// its small bound where a longer fit lands on it — and a search that stopped at the first
|
|
252
|
+
// failure changed 117 of 6,000 genuine arcs and near-circles split into up to 8 pieces.
|
|
253
|
+
// But trying every length made the search cubic in a run of cubics that are not a circle
|
|
254
|
+
// (1,600 of them: 3.4 s), and re-probing every length of a run that fits, quadratic in a
|
|
255
|
+
// circle of many cubics (4,000: 3.6 s). So the search ends at a miss no longer run can
|
|
256
|
+
// make up (final), and a longer fit re-probes only what it could change (fitsRun).
|
|
126
257
|
const runFrom = from;
|
|
127
|
-
let best = null, bestEnd = i;
|
|
128
|
-
let j = i;
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
if (
|
|
133
|
-
|
|
258
|
+
let best = null, bestEnd = i, known = null;
|
|
259
|
+
for (let j = i; j < segs.length && segs[j].c1; j++) {
|
|
260
|
+
const c = fitCircle(segs, i, j, runFrom);
|
|
261
|
+
if (!c) continue;
|
|
262
|
+
const r = fitsRun(segs, i, j, runFrom, c, known);
|
|
263
|
+
if (r.miss) {
|
|
264
|
+
if (j > i && final(segs, i, j, runFrom, c, r.miss)) break;
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
best = c; bestEnd = j; known = r.known;
|
|
134
268
|
}
|
|
135
269
|
|
|
136
270
|
if (!best) { out.push(segs[i]); from = segs[i].to; i++; continue; }
|
|
137
271
|
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
out.push(...arcsBetween(runFrom, end, best.center, best.r, sweepSignOf(run, runFrom, best.center)));
|
|
272
|
+
const end = segs[bestEnd].to;
|
|
273
|
+
out.push(...arcsBetween(runFrom, end, best.center, best.r, sweepSignOf(segs[i], runFrom, best.center)));
|
|
141
274
|
from = end;
|
|
142
275
|
i = bestEnd + 1;
|
|
143
276
|
}
|
|
@@ -9,13 +9,9 @@
|
|
|
9
9
|
// sampleArc. Returns null for a collinear (degenerate) triple. Shared by arcToCubicSegments
|
|
10
10
|
// (paper-bridge.js) and contour-ops.js's jointTangents (arc tangents are ⊥ radius, oriented by dA's sign).
|
|
11
11
|
export function arcCenterAndSweep(p0, via, to) {
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const sa = ax*ax + ay*ay, sb = bx*bx + by*by, sc = cx*cx + cy*cy;
|
|
16
|
-
const ux = (sa * (by - cy) + sb * (cy - ay) + sc * (ay - by)) / d;
|
|
17
|
-
const uy = (sa * (cx - bx) + sb * (ax - cx) + sc * (bx - ax)) / d;
|
|
18
|
-
const r = Math.hypot(ax - ux, ay - uy);
|
|
12
|
+
const circle = circumcircle(p0, via, to);
|
|
13
|
+
if (!circle) return null;
|
|
14
|
+
const { center: [ux, uy], r } = circle, [ax, ay] = p0, [bx, by] = via, [cx, cy] = to;
|
|
19
15
|
const a0 = Math.atan2(ay - uy, ax - ux);
|
|
20
16
|
const av = Math.atan2(by - uy, bx - ux);
|
|
21
17
|
const a1 = Math.atan2(cy - uy, cx - ux);
|
|
@@ -25,3 +21,15 @@ export function arcCenterAndSweep(p0, via, to) {
|
|
|
25
21
|
const dA = vCCW <= dCCW ? dCCW : dCCW - twoPi;
|
|
26
22
|
return { center: [ux, uy], r, dA };
|
|
27
23
|
}
|
|
24
|
+
|
|
25
|
+
// The circle through three points as { center, r } — arcCenterAndSweep's own, without the
|
|
26
|
+
// sweep (arc-fit.js tries one for every length of a run). Null for a collinear triple.
|
|
27
|
+
export function circumcircle(p0, via, to) {
|
|
28
|
+
const [ax, ay] = p0, [bx, by] = via, [cx, cy] = to;
|
|
29
|
+
const d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));
|
|
30
|
+
if (Math.abs(d) < 1e-12) return null;
|
|
31
|
+
const sa = ax*ax + ay*ay, sb = bx*bx + by*by, sc = cx*cx + cy*cy;
|
|
32
|
+
const ux = (sa * (by - cy) + sb * (cy - ay) + sc * (ay - by)) / d;
|
|
33
|
+
const uy = (sa * (cx - bx) + sb * (ax - cx) + sc * (bx - ax)) / d;
|
|
34
|
+
return { center: [ux, uy], r: Math.hypot(ax - ux, ay - uy) };
|
|
35
|
+
}
|
|
@@ -104,12 +104,82 @@ function lineIntersect(P, u, Q, v) {
|
|
|
104
104
|
return add(P, scl(u, cross(w, v) / d));
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
// A sharp join's miter apex, or null where the miter would pass MITER_LIMIT·|delta| and the
|
|
108
|
+
// join falls back to the bevel — a turn past 120°, i.e. a spike of material under 60°. One
|
|
109
|
+
// spelling for the join itself and for the source-hole disk gate (sharpHoleTakesMiters),
|
|
110
|
+
// which is only sound while no join on the hole bevels.
|
|
111
|
+
function sharpMiter(corner, aEnd, bStart, inTan, outTan, delta, limit = MITER_LIMIT) {
|
|
112
|
+
const X = lineIntersect(aEnd, inTan, bStart, outTan);
|
|
113
|
+
return X && dist(X, corner) <= limit * Math.abs(delta) ? X : null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// A SHARP join where an ARC meets the corner: the two pieces are extended until they meet,
|
|
117
|
+
// the arc along its own circle and the other piece (a line, or a cubic by its end tangent)
|
|
118
|
+
// along itself — OCCT's intersection join. A line–line corner is joinSegs' miter, which is
|
|
119
|
+
// the same thing. Extended along its end TANGENT instead (the plain miter), an arc leaves its
|
|
120
|
+
// circle at once, and the join cuts the corner short wherever the arc curves away from the
|
|
121
|
+
// gap: shrinking a shape and growing it back then lost a sliver beside every such corner (a
|
|
122
|
+
// ring sector's inner corners, a keyhole's, a thumb notch's), which the laser checks read as
|
|
123
|
+
// narrow material. Extended along the circle, a shrink-and-regrow returns the corner exactly.
|
|
124
|
+
// Returns the join's segments, or null where no meeting point lies ahead of both pieces within
|
|
125
|
+
// MITER_LIMIT·|delta| of the corner (the caller then takes the miter, or the bevel).
|
|
126
|
+
const EXTEND_MAX_SWEEP = Math.PI / 2; // how far along its circle an arc may extend
|
|
127
|
+
function arcCarrier(from, seg) {
|
|
128
|
+
if (!seg.via) return null;
|
|
129
|
+
const c = arcCenterAndSweep(from, seg.via, seg.to);
|
|
130
|
+
return c && c.r > JOIN_EPS ? { c: c.center, r: c.r, s: c.dA >= 0 ? 1 : -1 } : null;
|
|
131
|
+
}
|
|
132
|
+
const angleOf = (c, p) => Math.atan2(p[1] - c[1], p[0] - c[0]);
|
|
133
|
+
// The angle swept from p to q around a carrier, in its own direction, in [0, 2π).
|
|
134
|
+
function sweepAlong({ c, s }, p, q) {
|
|
135
|
+
const TAU = 2 * Math.PI;
|
|
136
|
+
const a = (s * (angleOf(c, q) - angleOf(c, p))) % TAU;
|
|
137
|
+
return a < 0 ? a + TAU : a;
|
|
138
|
+
}
|
|
139
|
+
const pointAlong = ({ c, r, s }, p, ang) => {
|
|
140
|
+
const t = angleOf(c, p) + s * ang;
|
|
141
|
+
return [c[0] + r * Math.cos(t), c[1] + r * Math.sin(t)];
|
|
142
|
+
};
|
|
143
|
+
function lineCircle(P, u, { c, r }) {
|
|
144
|
+
const w = sub(P, c), b = dot(w, u), disc = b * b - (dot(w, w) - r * r);
|
|
145
|
+
if (disc < 0) return [];
|
|
146
|
+
const q = Math.sqrt(disc);
|
|
147
|
+
return [add(P, scl(u, -b - q)), add(P, scl(u, -b + q))];
|
|
148
|
+
}
|
|
149
|
+
function circleCircle(A, B) {
|
|
150
|
+
const d = dist(A.c, B.c);
|
|
151
|
+
if (d < 1e-12 || d > A.r + B.r || d < Math.abs(A.r - B.r)) return [];
|
|
152
|
+
const a = (A.r * A.r - B.r * B.r + d * d) / (2 * d), hh = Math.sqrt(Math.max(0, A.r * A.r - a * a));
|
|
153
|
+
const e = scl(sub(B.c, A.c), 1 / d), m = add(A.c, scl(e, a)), nrm = [-e[1], e[0]];
|
|
154
|
+
return [add(m, scl(nrm, hh)), add(m, scl(nrm, -hh))];
|
|
155
|
+
}
|
|
156
|
+
function extendedJoin(corner, aFrom, aSeg, bStart, bSeg, inTan, outTan, delta) {
|
|
157
|
+
const A = arcCarrier(aFrom, aSeg), B = arcCarrier(bStart, bSeg);
|
|
158
|
+
if (!A && !B) return null;
|
|
159
|
+
const aEnd = aSeg.to;
|
|
160
|
+
const ahead = (X) => (A ? dist(X, aEnd) <= JOIN_EPS || sweepAlong(A, aEnd, X) <= EXTEND_MAX_SWEEP : dot(sub(X, aEnd), inTan) >= -JOIN_EPS);
|
|
161
|
+
const behind = (X) => (B ? dist(X, bStart) <= JOIN_EPS || sweepAlong(B, X, bStart) <= EXTEND_MAX_SWEEP : dot(sub(bStart, X), outTan) >= -JOIN_EPS);
|
|
162
|
+
let X = null, best = MITER_LIMIT * Math.abs(delta);
|
|
163
|
+
for (const P of A && B ? circleCircle(A, B) : A ? lineCircle(bStart, outTan, A) : lineCircle(aEnd, inTan, B)) {
|
|
164
|
+
const d = dist(P, corner);
|
|
165
|
+
if (d <= best && ahead(P) && behind(P)) { X = P; best = d; }
|
|
166
|
+
}
|
|
167
|
+
if (!X) return null;
|
|
168
|
+
// An extension too short to bend is a line; its end is snapped onto the next piece.
|
|
169
|
+
const along = (C, p, q) => { const ang = sweepAlong(C, p, q); return ang > 1e-9 ? { via: pointAlong(C, p, ang / 2), to: q } : { to: q }; };
|
|
170
|
+
const out = [];
|
|
171
|
+
if (dist(X, aEnd) > JOIN_EPS) out.push(A ? along(A, aEnd, X) : { to: X });
|
|
172
|
+
if (dist(X, bStart) > JOIN_EPS) out.push(B ? along(B, X, bStart) : { to: bStart });
|
|
173
|
+
else if (out.length) out[out.length - 1] = A ? along(A, aEnd, bStart) : { to: bStart };
|
|
174
|
+
return out.length ? out : [{ to: bStart }];
|
|
175
|
+
}
|
|
176
|
+
|
|
107
177
|
// Segments bridging aEnd → bStart around `corner` on the gap side.
|
|
108
178
|
function joinSegs(corner, aEnd, bStart, inTan, outTan, delta, corners) {
|
|
109
179
|
if (corners === "chamfer") return [{ to: bStart }];
|
|
110
180
|
if (corners === "sharp") {
|
|
111
|
-
const X =
|
|
112
|
-
if (X
|
|
181
|
+
const X = sharpMiter(corner, aEnd, bStart, inTan, outTan, delta);
|
|
182
|
+
if (X) return [{ to: X }, { to: bStart }];
|
|
113
183
|
return [{ to: bStart }]; // miter-limit fallback = bevel
|
|
114
184
|
}
|
|
115
185
|
// round: exact arc about the corner, via on the displacement bisector
|
|
@@ -191,7 +261,15 @@ export function _offsetContour(contour, delta, corners) {
|
|
|
191
261
|
// through to the overlap branch, flat-capping a requested round end; treat it as gap
|
|
192
262
|
// side so the cap is honored (an inward spike's join makes an inverted loop the
|
|
193
263
|
// winding rule cancels, so the choice is safe for either delta sign).
|
|
194
|
-
if (turn * delta > 0 || turn === 0) {
|
|
264
|
+
if (turn * delta > 0 || turn === 0) {
|
|
265
|
+
// Sharp, where an arc meets the corner: extend it along its circle (extendedJoin). Not
|
|
266
|
+
// for a piece the offset inverted or collapsed (dirty): cleanup removes those.
|
|
267
|
+
const ext = corners === "sharp" && !prev.dirty && !next.dirty
|
|
268
|
+
? extendedJoin(point, prev.segments.length > 1 ? prev.segments.at(-2).to : prev.start, prev.segments.at(-1), bStart, next.segments[0], inTan, outTan, delta)
|
|
269
|
+
: null;
|
|
270
|
+
joins[i] = ext ?? joinSegs(point, aEnd, bStart, inTan, outTan, delta, corners);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
195
273
|
// Overlap side: the two offset pieces run into each other instead of leaving a gap.
|
|
196
274
|
// When both neighbors are plain lines and the two offset LINES cross WITHIN both
|
|
197
275
|
// segments' own extents, that crossing is the true corner of the offset outline: trim
|
|
@@ -517,10 +595,59 @@ export function validateRawOffset(regions) {
|
|
|
517
595
|
// before constructing the raw offset avoids the inverted pockets that fully-eroded curved
|
|
518
596
|
// counters can otherwise leave behind. This deliberately examines the SOURCE contour, not
|
|
519
597
|
// the self-tangled raw output — see the removed-prune history above.
|
|
598
|
+
//
|
|
599
|
+
// A SHARP offset erodes a hole at least as deeply — WHILE EVERY JOIN ON IT TAKES THE MITER.
|
|
600
|
+
// At the hole's convex corners the inset lines meet exactly as the disk's would, and at
|
|
601
|
+
// its reflex corners (spikes of material pointing into it) a miter reaches past the arc,
|
|
602
|
+
// so a hole with no room for the disk is gone under sharp corners too. It gets the same
|
|
603
|
+
// question. Without it the inverted ring survived: past twice a hole's half-width a
|
|
604
|
+
// square came back as a small diamond hole and a circle as a mirrored one, so a sharp
|
|
605
|
+
// closing (grow, shrink back) GREW a small hole — the laser gap check never flagged a
|
|
606
|
+
// hole below its floor — and an inverted cubic counter subdivided to MAX_DEPTH and cost
|
|
607
|
+
// the winding resolver seconds per hole.
|
|
608
|
+
// The argument fails at the miter limit. A spike of material under 60° would miter past
|
|
609
|
+
// MITER_LIMIT·delta, so joinSegs bevels it, and the bevel chord adds LESS than the arc:
|
|
610
|
+
// the hole keeps a real pocket beyond the chord that the disk would have filled — a
|
|
611
|
+
// 12-ray star hole (rays 5, spikes to 1.2) in a plate keeps 0.9–1.9 mm² at +1.3…+2, the
|
|
612
|
+
// Minkowski oracle agrees (test/offset-oracle-manifold.test.js), and the gate must not
|
|
613
|
+
// drop it. So a sharp hole is asked the disk question only when no join on it bevels
|
|
614
|
+
// (sharpHoleTakesMiters); one that does takes the ordinary path, as before the gate ran
|
|
615
|
+
// for sharp. Chamfer is left alone for the same reason everywhere: its bevel removes LESS
|
|
616
|
+
// than the arc at every reflex corner, so an empty disk test does not prove an empty inset.
|
|
520
617
|
const SOURCE_DISK_TOL = 5 * OFFSET_TOL;
|
|
521
618
|
const SOURCE_DISK_FLAT_TOL = OFFSET_TOL / 2;
|
|
522
619
|
const SOURCE_DISK_POINT_CAP = 16384;
|
|
523
620
|
|
|
621
|
+
// Whether a sharp offset of `contour` by `delta` joins every gap-side corner with its miter
|
|
622
|
+
// — the condition under which the disk gate above is sound for sharp. Mirrors
|
|
623
|
+
// _offsetContour's own join decision (zero-length segments dropped, smooth joints skipped,
|
|
624
|
+
// the gap side by the sign of the turn) and joinSegs' through sharpMiter, from the source
|
|
625
|
+
// geometry alone: an offset piece starts and ends exactly one delta along its endpoint
|
|
626
|
+
// normal. The limit is held a hair tighter so a spike sitting exactly on it reads as a
|
|
627
|
+
// bevel and keeps the hole on the ordinary path — never the other way round.
|
|
628
|
+
// An arc's corner may take extendedJoin instead of the miter; that join never bevels where
|
|
629
|
+
// the miter would not (it falls back to the miter first), and its curves stay at least
|
|
630
|
+
// delta from the corner, so it too reaches past the arc a disk would leave — the argument
|
|
631
|
+
// above holds for it unchanged.
|
|
632
|
+
const MITER_GATE_LIMIT = MITER_LIMIT * (1 - 1e-9);
|
|
633
|
+
function sharpHoleTakesMiters(contour, delta) {
|
|
634
|
+
const froms = [];
|
|
635
|
+
{ let p = contour.start; for (const s of contour.segments) { froms.push(p); p = s.to; } }
|
|
636
|
+
const keep = contour.segments.map((s, i) => s.c1 || s.via || dist(froms[i], s.to) > 1e-9);
|
|
637
|
+
const segs = contour.segments.filter((_, i) => keep[i]);
|
|
638
|
+
if (!segs.length) return true;
|
|
639
|
+
const start = froms.find((_, i) => keep[i]);
|
|
640
|
+
for (const { point, inTan, outTan } of jointTangents({ start, segments: segs })) {
|
|
641
|
+
const turn = cross(inTan, outTan);
|
|
642
|
+
const turnDeg = (Math.atan2(Math.abs(turn), Math.max(-1, Math.min(1, dot(inTan, outTan)))) * 180) / Math.PI;
|
|
643
|
+
if (turnDeg < SMOOTH_JOINT_DEG || !(turn * delta > 0 || turn === 0)) continue; // smooth, or no join
|
|
644
|
+
const aEnd = add(point, scl(rightOf(inTan), delta)), bStart = add(point, scl(rightOf(outTan), delta));
|
|
645
|
+
if (dist(aEnd, bStart) <= JOIN_EPS) continue;
|
|
646
|
+
if (!sharpMiter(point, aEnd, bStart, inTan, outTan, delta, MITER_GATE_LIMIT)) return false;
|
|
647
|
+
}
|
|
648
|
+
return true;
|
|
649
|
+
}
|
|
650
|
+
|
|
524
651
|
function pointSegmentDistance(p, a, b) {
|
|
525
652
|
const ab = sub(b, a);
|
|
526
653
|
const d2 = dot(ab, ab);
|
|
@@ -661,8 +788,8 @@ function rawOffset(regions, delta, corners) {
|
|
|
661
788
|
if (!o.contour) continue;
|
|
662
789
|
const hs = rg.holes.map((h) => {
|
|
663
790
|
const hole = closeContourGap(h);
|
|
664
|
-
|
|
665
|
-
|
|
791
|
+
const diskGate = delta > 0 && (corners === "round" || (corners === "sharp" && sharpHoleTakesMiters(hole, delta)));
|
|
792
|
+
if (diskGate && !_sourceHoleContainsDisk(hole, delta)) return { contour: null, dirty: false };
|
|
666
793
|
return _offsetContour(hole, delta, corners);
|
|
667
794
|
});
|
|
668
795
|
// Only a SURVIVING hole's dirtiness can dirty the result. A hole that collapsed
|
|
@@ -620,3 +620,10 @@ export { translateProfile, rotateProfile, scaleProfile, mirrorProfile,
|
|
|
620
620
|
profileLength, profilePointAt, profileTangentAt, profileNearestPoint,
|
|
621
621
|
profileBounds, profileArea, profileContains,
|
|
622
622
|
simplifyProfile, validateProfile } from "./contour-ops.js";
|
|
623
|
+
|
|
624
|
+
// Sheet parts (laser-cut flat stock): the sheetPart() wrapper, the joinery helpers and
|
|
625
|
+
// the drawing ↔ world converters. Plain JS over the contour IR — not kernel ops, so
|
|
626
|
+
// they add nothing to the kernel contract's op lists.
|
|
627
|
+
export { sheetPart } from "../sheet/part.js";
|
|
628
|
+
export { fingers, tabs, tSlots, sheetPanel, matchingSlots, fingerBox, printedTab, sheetHole, JOINERY_SCREWS } from "../sheet/joinery.js";
|
|
629
|
+
export { sheetToWorld, worldToSheet } from "../sheet/pose.js";
|
|
@@ -21,8 +21,9 @@ import { IMAGE_RULES } from "./rules-images.js";
|
|
|
21
21
|
import { SOURCE_RULES } from "./rules-source.js";
|
|
22
22
|
import { VECTOR_RULES } from "./rules-vector.js";
|
|
23
23
|
import { MATERIAL_RULES } from "./rules-materials.js";
|
|
24
|
+
import { SHEET_RULES } from "./rules-sheet.js";
|
|
24
25
|
|
|
25
|
-
export const RULES = [...SHAPE_RULES, ...SCHEMA_RULES, ...BUILD_RULES, ...VERIFY_RULES, ...ANIMATION_RULES, ...PLACE_RULES, ...IMPORT_RULES, ...FONT_RULES, ...IMAGE_RULES, ...SOURCE_RULES, ...VECTOR_RULES, ...MATERIAL_RULES];
|
|
26
|
+
export const RULES = [...SHAPE_RULES, ...SCHEMA_RULES, ...BUILD_RULES, ...VERIFY_RULES, ...ANIMATION_RULES, ...PLACE_RULES, ...IMPORT_RULES, ...FONT_RULES, ...IMAGE_RULES, ...SOURCE_RULES, ...VECTOR_RULES, ...MATERIAL_RULES, ...SHEET_RULES];
|
|
26
27
|
|
|
27
28
|
// A usable sources input, or null. Deliberately forgiving: lintPart's callers
|
|
28
29
|
// include hosted paths handing over user/LLM-authored trees, so a malformed
|
|
@@ -27,7 +27,8 @@ const isPlainObject = (x) => x !== null && typeof x === "object" && !Array.isArr
|
|
|
27
27
|
// It is read only by controlBoundKeys({ excludeHidden }); every other rule
|
|
28
28
|
// ignores it, so their populations are unchanged. `when` is deliberately not
|
|
29
29
|
// hiddenness — a `when`-conditioned node can still appear.
|
|
30
|
-
|
|
30
|
+
// Exported for rules-sheet.js's sheet-kerf-control, which reads every control's key and label.
|
|
31
|
+
export function collectDescriptors(part) {
|
|
31
32
|
const out = [];
|
|
32
33
|
sections(part).forEach((sec, si) => {
|
|
33
34
|
const secHidden = sec?.hidden === true;
|
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
// defaults and build but not `views`, and had already drifted from the eval runner's
|
|
4
4
|
// separate views check. One source of truth ends that split.
|
|
5
5
|
import { err, warn } from "./finding.js";
|
|
6
|
+
import { SHEET_DOC_ID } from "../sheet/constants.js";
|
|
7
|
+
|
|
8
|
+
// A sub-part that carries a `sheet` declaration but no build was meant to be a sheet
|
|
9
|
+
// part: point it at sheetPart(), which supplies build.
|
|
10
|
+
const SHEET_NO_BUILD_HINT = "Every entry in `parts` needs a `build(k, p, d)` function that returns a solid. For a laser-cut flat part, wrap the whole sub-part in `sheetPart({ … })` from partforge/geometry — it supplies build and place.";
|
|
6
11
|
|
|
7
12
|
const isPlainObject = (x) => x !== null && typeof x === "object" && !Array.isArray(x);
|
|
8
13
|
const partEntries = (part) => (isPlainObject(part?.parts) ? Object.entries(part.parts) : []);
|
|
@@ -35,9 +40,11 @@ export const SHAPE_RULES = [
|
|
|
35
40
|
}
|
|
36
41
|
return entries
|
|
37
42
|
.filter(([, sp]) => typeof sp?.build !== "function")
|
|
38
|
-
.map(([name]) =>
|
|
39
|
-
"
|
|
40
|
-
`
|
|
43
|
+
.map(([name, sp]) => (sp?.sheet !== undefined
|
|
44
|
+
? err("no-buildable-parts", `sub-part "${name}" has no \`build\` function`, SHEET_NO_BUILD_HINT, `parts.${name}.build`, SHEET_DOC_ID)
|
|
45
|
+
: err("no-buildable-parts", `sub-part "${name}" has no \`build\` function`,
|
|
46
|
+
"Every entry in `parts` needs a `build(k, p, d)` function that returns a solid.",
|
|
47
|
+
`parts.${name}.build`)));
|
|
41
48
|
},
|
|
42
49
|
},
|
|
43
50
|
{
|