partforge 0.133.3 → 0.134.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,356 @@
1
+ // The kit's drawing stage: the process-agnostic Drawing IR every cut-file writer reads,
2
+ // plus the two contour passes that run before any writer sees a ring. A Drawing is a
3
+ // sheet piece in its CANONICAL frame — mm, y up, the laser face seen from above —
4
+ // built from the piece's resolved 2-D declaration alone. Nothing here reads a pose or a
5
+ // solid (design spec D.1): the 3-D model is a preview of the cut file, never its source.
6
+ //
7
+ // The Drawing IR (contract §5.5): `layers` in LAYER_ORDER, each `{ id, paths }`, a path
8
+ // being `{ start, segments, closed }` whose segments ARE the contour IR (profile.js:
9
+ // `{to}` line, `{to, via}` three-point arc, `{to, c1, c2}` cubic) — there is no second
10
+ // segment type. `bounds` covers every layer after kerf; `nominal` is the cut layer's
11
+ // size before kerf, which the README's scale check prints.
12
+ //
13
+ // Why two refits. Every Shape2D boolean turns arcs into cubics (paper-bridge.js), so a
14
+ // hole cut with cutAll comes back as four cubics, and so does every exact arc a *Profile
15
+ // helper drew (circleProfile's two, since partforge 0.132) once a boolean has touched
16
+ // it; recoverArcs (arc-fit.js) turns those back into exact arcs. But a point list draws
17
+ // its curves as polygons — circlePolygon is a 48-gon, the round *Polygon helpers'
18
+ // corners and ends are chords, a hand-sampled arc is its samples — and a laser traces
19
+ // every facet. refitLineRuns is the conservative second pass for those:
20
+ // a run becomes arcs only when at least `minVerts` consecutive vertices sit on one
21
+ // circle AND every step turns by at most `maxTurnDeg`, all the same way. A hexagon (60°
22
+ // turns) or a star (alternating turns) never qualifies; when the test misses something
23
+ // the output is dense, never wrong — and "never wrong" is held in millimetres: no
24
+ // authored vertex ends up more than REFIT_MAX_MOVE_MM from the output.
25
+ //
26
+ // LAZY: reachable only through process/exporters.js's dynamic import (and, from P2b,
27
+ // export/bundle.js's). It reaches paper (offsetRegions: contour-offset.js →
28
+ // paper-bridge.js — arc-fit.js is now a paper-free leaf, so recoverArcs no longer
29
+ // carries this), which is why lint, the oracle and partforge/geometry must never
30
+ // import it (test/kit-export-guards.test.js). It runs in the geometry worker, so it
31
+ // stays DOM-free and node:-free.
32
+ import { recoverArcs } from "../geometry/arc-fit.js";
33
+ import { arcCenterAndSweep } from "../geometry/arc-math.js";
34
+ import { cubicAt } from "../geometry/contour-ops.js";
35
+ import { offsetRegions } from "../geometry/contour-offset.js";
36
+ import { closeContourGap } from "../geometry/profile.js";
37
+ import { KIT_OPTIONS_ERROR } from "./formats.js";
38
+
39
+ // The cut order: marks first (the piece is still held by the sheet), holes before the
40
+ // outline that frees the piece. Writers emit layers in this order.
41
+ export const LAYER_ORDER = Object.freeze(["engrave", "score", "cut-inner", "cut-outer"]);
42
+ // engrave is a filled region; the rest are vector lines.
43
+ export const LAYER_KIND = Object.freeze({ engrave: "fill", score: "line", "cut-inner": "line", "cut-outer": "line" });
44
+
45
+ const TAU = 2 * Math.PI;
46
+ const CLOSE_EPS = 1e-9; // mm — the storage invariant's "explicitly closed"
47
+ const dist = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]);
48
+ const wrapPi = (a) => { a %= TAU; if (a > Math.PI) a -= TAU; else if (a <= -Math.PI) a += TAU; return a; };
49
+ const wrapTau = (a) => ((a % TAU) + TAU) % TAU;
50
+ const isLine = (s) => !s.via && !s.c1;
51
+
52
+ // Signed turn (radians, CCW positive) from edge a→b to edge b→c; NaN when an edge has no
53
+ // length (a duplicate vertex has no direction, so it can never sit inside a run).
54
+ function turnAt(a, b, c) {
55
+ const u = [b[0] - a[0], b[1] - a[1]], v = [c[0] - b[0], c[1] - b[1]];
56
+ if (Math.hypot(u[0], u[1]) < CLOSE_EPS || Math.hypot(v[0], v[1]) < CLOSE_EPS) return NaN;
57
+ return Math.atan2(u[0] * v[1] - u[1] * v[0], u[0] * v[0] + u[1] * v[1]);
58
+ }
59
+
60
+ // The refit's promise, in mm: no authored vertex ends up farther than this from the arcs
61
+ // that replace its run. It caps the fit band — a band relative to the radius alone grows
62
+ // with it, and the three-point circle of a gently curving stretch can be metres wide —
63
+ // and every run is measured against its real output arcs before it is kept.
64
+ export const REFIT_MAX_MOVE_MM = 0.005;
65
+ // A run's radius may be at most this many times its chord (its diameter, for a run of
66
+ // half a turn or more): past that the "arc" is a flat stretch of some other curve, which
67
+ // the laser should trace as drawn, not a faceted circle to recover.
68
+ const REFIT_MAX_FLATNESS = 50;
69
+
70
+ // Fit the circle through vertices k..e of Q (via k, the one-third and the two-thirds
71
+ // vertex — three distinct points even when the run is a whole ring and Q[e] is Q[k]),
72
+ // then require EVERY vertex within tolerance of it, stepping round the centre in the
73
+ // run's turning direction. Returns the run's circle and accumulated sweep, or null.
74
+ function fitRun(Q, k, e, sign) {
75
+ const m = e - k;
76
+ const c = arcCenterAndSweep(Q[k], Q[k + Math.round(m / 3)], Q[k + Math.round((2 * m) / 3)]);
77
+ if (!c || !(c.r > 0)) return null;
78
+ const run = { center: c.center, r: c.r, tol: Math.min(Math.max(1e-6, 1e-4 * c.r), REFIT_MAX_MOVE_MM), sign, sweep: 0 };
79
+ if (Math.abs(dist(Q[k], run.center) - run.r) > run.tol) return null;
80
+ for (let i = k + 1; i <= e; i++) if (!extendRun(run, Q[i - 1], Q[i])) return null;
81
+ return run;
82
+ }
83
+
84
+ // Does `q` continue `run` from `p` — on the circle, stepping the same way round, the
85
+ // whole run staying within one turn? Advances run.sweep when it does.
86
+ function extendRun(run, p, q) {
87
+ if (Math.abs(dist(q, run.center) - run.r) > run.tol) return false;
88
+ const ang = (x) => Math.atan2(x[1] - run.center[1], x[0] - run.center[0]);
89
+ const step = wrapPi(ang(q) - ang(p));
90
+ if (Math.sign(step) !== run.sign || Math.abs(run.sweep + step) > TAU + 1e-9) return false;
91
+ run.sweep += step;
92
+ return true;
93
+ }
94
+
95
+ // The arc from `from` to `to` about the run's circle as three-point arcs of at most 180°
96
+ // (the three-point form is ambiguous past that), `via` at each piece's angular middle,
97
+ // the final `to` pinned to the run's real last vertex.
98
+ function arcPieces(from, to, { center, r, sweep }) {
99
+ const a0 = Math.atan2(from[1] - center[1], from[0] - center[0]);
100
+ const n = Math.max(1, Math.ceil(Math.abs(sweep) / Math.PI - 1e-9));
101
+ const at = (t) => [center[0] + r * Math.cos(t), center[1] + r * Math.sin(t)];
102
+ const out = [];
103
+ for (let i = 0; i < n; i++) {
104
+ const s0 = a0 + (sweep * i) / n, s1 = a0 + (sweep * (i + 1)) / n;
105
+ out.push({ to: at(s1), via: at((s0 + s1) / 2) });
106
+ }
107
+ out[n - 1].to = [to[0], to[1]];
108
+ return out;
109
+ }
110
+
111
+ // How far q is from the three-point arc from → via → to: the radial gap when q lies
112
+ // inside the sweep, else the distance to the nearer end.
113
+ function arcGap(q, from, { via, to }) {
114
+ const g = arcCenterAndSweep(from, via, to);
115
+ if (!g) return Infinity;
116
+ const a0 = Math.atan2(from[1] - g.center[1], from[0] - g.center[0]);
117
+ const along = wrapTau(Math.sign(g.dA) * (Math.atan2(q[1] - g.center[1], q[0] - g.center[0]) - a0));
118
+ if (along <= Math.abs(g.dA) + 1e-12) return Math.abs(dist(q, g.center) - g.r);
119
+ return Math.min(dist(q, from), dist(q, to));
120
+ }
121
+
122
+ // Keep a fitted run k..e only when it is a circle to recover and its arcs are faithful:
123
+ // not far flatter than it is long, and every vertex it replaces within
124
+ // REFIT_MAX_MOVE_MM of the arcs actually written (whose ends are the run's real,
125
+ // slightly off-circle, first and last vertices — so this is measured, not assumed).
126
+ function keepRun(Q, k, e, run, pieces) {
127
+ const chord = Math.abs(run.sweep) >= Math.PI ? 2 * run.r : dist(Q[k], Q[e]);
128
+ if (run.r > REFIT_MAX_FLATNESS * chord) return false;
129
+ for (let i = k + 1; i < e; i++) {
130
+ let from = Q[k], gap = Infinity;
131
+ for (const s of pieces) { gap = Math.min(gap, arcGap(Q[i], from, s)); from = s.to; }
132
+ if (!(gap <= REFIT_MAX_MOVE_MM)) return false;
133
+ }
134
+ return true;
135
+ }
136
+
137
+ // Runs of straight segments that trace a circle → three-point arcs. Curves pass through
138
+ // untouched and break runs. A closed ring is first re-seated at a "hard break" (a vertex
139
+ // no run can pass: a curve meets it, or it turns out of band) so a run that wraps the
140
+ // ring's start is seen whole; a ring with no hard break — a faceted circle — is tried as
141
+ // one run all the way round. Orientation is preserved. When nothing is refit the input
142
+ // contour is returned as-is (same object). Linear in the vertex count.
143
+ export function refitLineRuns(contour, { minVerts = 8, maxTurnDeg = 15 } = {}) {
144
+ const segs = contour.segments;
145
+ const n = segs.length;
146
+ if (n < minVerts - 1) return contour;
147
+ const maxTurn = (maxTurnDeg * Math.PI) / 180 + 1e-9;
148
+ const inBand = (t) => Math.abs(t) > 1e-12 && Math.abs(t) <= maxTurn; // NaN fails both
149
+ const P = [contour.start, ...segs.map((s) => s.to)];
150
+ const closed = dist(P[0], P[n]) <= CLOSE_EPS;
151
+
152
+ let off = 0;
153
+ if (closed) {
154
+ for (let i = 0; i < n; i++) {
155
+ const prev = (i + n - 1) % n;
156
+ if (!isLine(segs[prev]) || !isLine(segs[i]) || !inBand(turnAt(P[prev], P[i], P[i + 1]))) { off = i; break; }
157
+ }
158
+ }
159
+ const Q = closed ? [...P.slice(off, n), ...P.slice(0, off), P[off]] : P;
160
+ const S = closed ? [...segs.slice(off), ...segs.slice(0, off)] : segs;
161
+
162
+ const out = [];
163
+ let changed = false;
164
+ let k = 0, j = 0, sign = 0;
165
+ while (k < n) {
166
+ // Segments k..j-1 are straight, and every joint between them turns in band, one way.
167
+ // Scanned once per run, not once per vertex: while a joint of the run lies ahead of
168
+ // k (k + 1 < j), the run from k is the same run — same end, same turning sign.
169
+ if (k + 1 >= j) {
170
+ j = k; sign = 0;
171
+ while (j < n && isLine(S[j])) {
172
+ if (j > k) {
173
+ const t = turnAt(Q[j - 1], Q[j], Q[j + 1]);
174
+ if (!inBand(t) || (sign !== 0 && Math.sign(t) !== sign)) break;
175
+ sign = Math.sign(t);
176
+ }
177
+ j++;
178
+ }
179
+ }
180
+ let e = k + minVerts - 1;
181
+ const run = sign !== 0 && e <= j ? fitRun(Q, k, e, sign) : null;
182
+ if (!run) { out.push(S[k]); k++; continue; }
183
+ while (e < j && extendRun(run, Q[e], Q[e + 1])) e++;
184
+ const pieces = arcPieces(Q[k], Q[e], run);
185
+ // A refused run stays as drawn, whole: any run inside it lies on the same circle.
186
+ if (keepRun(Q, k, e, run, pieces)) { out.push(...pieces); changed = true; }
187
+ else for (let i = k; i < e; i++) out.push(S[i]);
188
+ k = e;
189
+ }
190
+ return changed ? { start: [Q[0][0], Q[0][1]], segments: out } : contour;
191
+ }
192
+
193
+ // Both refits, in order: cubic runs → arcs (recoverArcs), then line runs → arcs.
194
+ export const refitRing = (ring) => refitLineRuns(recoverArcs(ring));
195
+
196
+ // Split regions (Shape2D.toContours() form) into outer rings and hole rings, region by
197
+ // region — the cut-outer / cut-inner split, and the ring list a region layer draws.
198
+ export function ringsOf(regions) {
199
+ return {
200
+ outer: regions.map((rg) => rg.outer),
201
+ holes: regions.flatMap((rg) => rg.holes),
202
+ };
203
+ }
204
+
205
+ // Is point p inside `ring` (either winding)? The winding number, exact for lines and
206
+ // arcs — an arc sweeps its chord's angle, plus a whole turn when p lies in the circular
207
+ // segment between chord and arc — with cubics flattened. Exact matters: an island can
208
+ // sit a fraction of a millimetre inside the hole around it, where any chorded stand-in
209
+ // for the hole's arcs would put it outside.
210
+ function encloses(ring, p) {
211
+ const turn = (a, b) => {
212
+ const ux = a[0] - p[0], uy = a[1] - p[1], vx = b[0] - p[0], vy = b[1] - p[1];
213
+ return Math.atan2(ux * vy - uy * vx, ux * vx + uy * vy);
214
+ };
215
+ const side = (a, b, q) => (b[0] - a[0]) * (q[1] - a[1]) - (b[1] - a[1]) * (q[0] - a[0]);
216
+ let w = 0, from = ring.start;
217
+ for (const s of ring.segments) {
218
+ if (s.c1) {
219
+ let a = from;
220
+ for (let i = 1; i <= 32; i++) { const b = i === 32 ? s.to : cubicAt(from, s.c1, s.c2, s.to, i / 32); w += turn(a, b); a = b; }
221
+ } else {
222
+ w += turn(from, s.to);
223
+ const g = s.via ? arcCenterAndSweep(from, s.via, s.to) : null;
224
+ if (g && dist(p, g.center) < g.r && Math.sign(side(from, s.to, p)) === Math.sign(side(from, s.to, s.via)))
225
+ w += Math.sign(g.dA) * TAU;
226
+ }
227
+ from = s.to;
228
+ }
229
+ return Math.abs(w) > Math.PI;
230
+ }
231
+
232
+ // The cut layer's rings in CUT ORDER: every ring before any ring that encloses it, so no
233
+ // outline is cut on a piece that something else has already freed. A ring's depth is how
234
+ // many other rings enclose it (rings of a valid profile never cross, so its start point
235
+ // answers for all of it); deepest first, region order within a depth. `inner` is every
236
+ // enclosed ring — holes, and islands standing in a hole with their own holes — and
237
+ // `outer` the top-level outlines, the cuts that free the pieces. A piece of one region
238
+ // comes back as its holes then its outline, exactly as ringsOf splits it.
239
+ export function cutRings(regions) {
240
+ const rings = regions.flatMap((rg) => [rg.outer, ...rg.holes]);
241
+ let depth;
242
+ if (regions.length === 1) depth = rings.map((_, i) => (i === 0 ? 0 : 1));
243
+ else {
244
+ // a ring can only enclose a point inside its own bounding box
245
+ const box = rings.map((r) => drawingBounds([{ paths: [r] }]));
246
+ const inBox = (i, [x, y]) => x >= box[i].min[0] && x <= box[i].max[0] && y >= box[i].min[1] && y <= box[i].max[1];
247
+ depth = rings.map((r, i) => rings.reduce((n, q, j) => n + (j !== i && inBox(j, r.start) && encloses(q, r.start) ? 1 : 0), 0));
248
+ }
249
+ const order = rings.map((_, i) => i).sort((a, b) => depth[b] - depth[a] || a - b);
250
+ return {
251
+ inner: order.filter((i) => depth[i] > 0).map((i) => rings[i]),
252
+ outer: order.filter((i) => depth[i] === 0).map((i) => rings[i]),
253
+ };
254
+ }
255
+
256
+ // Exact bounding box of every path in `layers` — arcs by their axis extremes inside the
257
+ // sweep, cubics by the roots of their derivative — or null when there is no path at all.
258
+ export function drawingBounds(layers) {
259
+ let min = null, max = null;
260
+ const add = ([x, y]) => {
261
+ if (!min) { min = [x, y]; max = [x, y]; return; }
262
+ if (x < min[0]) min[0] = x; if (y < min[1]) min[1] = y;
263
+ if (x > max[0]) max[0] = x; if (y > max[1]) max[1] = y;
264
+ };
265
+ for (const layer of layers) for (const path of layer.paths) {
266
+ add(path.start);
267
+ let from = path.start;
268
+ for (const s of path.segments) {
269
+ add(s.to);
270
+ if (s.via) addArcExtremes(add, from, s);
271
+ else if (s.c1) addCubicExtremes(add, from, s);
272
+ from = s.to;
273
+ }
274
+ }
275
+ return min ? { min, max } : null;
276
+ }
277
+
278
+ function addArcExtremes(add, from, s) {
279
+ const g = arcCenterAndSweep(from, s.via, s.to);
280
+ if (!g) return; // collinear: the endpoints bound it
281
+ const a0 = Math.atan2(from[1] - g.center[1], from[0] - g.center[0]);
282
+ for (let q = 0; q < 4; q++) {
283
+ const theta = (q * Math.PI) / 2;
284
+ const along = g.dA >= 0 ? wrapTau(theta - a0) : wrapTau(a0 - theta);
285
+ if (along <= Math.abs(g.dA)) add([g.center[0] + g.r * Math.cos(theta), g.center[1] + g.r * Math.sin(theta)]);
286
+ }
287
+ }
288
+
289
+ function addCubicExtremes(add, from, s) {
290
+ for (const axis of [0, 1]) {
291
+ const a = from[axis], b = s.c1[axis], c = s.c2[axis], d = s.to[axis];
292
+ // B'(t) / 3 = A t² + B t + C
293
+ const A = -a + 3 * b - 3 * c + d, B = 2 * (a - 2 * b + c), C = b - a;
294
+ const roots = [];
295
+ if (Math.abs(A) < 1e-12) { if (Math.abs(B) > 1e-12) roots.push(-C / B); }
296
+ else {
297
+ const disc = B * B - 4 * A * C;
298
+ if (disc >= 0) { const q = Math.sqrt(disc); roots.push((-B + q) / (2 * A), (-B - q) / (2 * A)); }
299
+ }
300
+ for (const t of roots) if (t > 0 && t < 1) add(cubicAt(from, s.c1, s.c2, s.to, t));
301
+ }
302
+ }
303
+
304
+ // The identity P2b's dedupe confirms with: the drawing translated so bounds.min is the
305
+ // origin, every number rounded to 1e-4 mm, as JSON. A hash of this only NOMINATES a
306
+ // duplicate; string equality of the key confirms it (design spec D.4).
307
+ export function canonicalDrawingKey(drawing) {
308
+ const [ox, oy] = drawing.bounds.min;
309
+ const r = (v) => { const x = Math.round(v * 1e4) / 1e4; return x === 0 ? 0 : x; };
310
+ const pt = (p) => [r(p[0] - ox), r(p[1] - oy)];
311
+ const seg = (s) => (s.c1 ? { to: pt(s.to), c1: pt(s.c1), c2: pt(s.c2) } : s.via ? { to: pt(s.to), via: pt(s.via) } : { to: pt(s.to) });
312
+ return JSON.stringify({
313
+ kerf: r(drawing.kerf),
314
+ layers: drawing.layers.map((l) => ({
315
+ id: l.id,
316
+ paths: l.paths.map((p) => ({ closed: p.closed, start: pt(p.start), segments: p.segments.map(seg) })),
317
+ })),
318
+ });
319
+ }
320
+
321
+ const arcCount = (regions) => regions.reduce(
322
+ (n, rg) => n + [rg.outer, ...rg.holes].reduce((m, ring) => m + ring.segments.filter((s) => s.via).length, 0), 0);
323
+ const holeCounts = (regions) => regions.map((rg) => rg.holes.length).sort((a, b) => a - b).join(",");
324
+
325
+ // Offset the CUT regions (already refit) by +kerf/2 with round corners, so outlines grow
326
+ // and holes shrink — the storage winding invariant (outer CCW, holes CW) makes one signed
327
+ // offset do both (contour-offset.js). Lines and arcs offset exactly, so recovered arcs
328
+ // stay arcs; the result is refit again for any cubic the offset produced.
329
+ //
330
+ // The offset engine silently DROPS a hole narrower than the offset, and merges regions
331
+ // that grow into each other, so the before/after topology is compared and any change is
332
+ // an options error naming the piece (design spec D.2): region count (fewer → pieces
333
+ // joined), per-region hole counts sorted (any change → a slot closed or a mouth sealed),
334
+ // arc count (fewer → an arc feature was consumed). `gap`, when known, is the piece's
335
+ // narrowest opening (SheetFacts.gap) — the likeliest slot to have closed. kerf 0 returns
336
+ // the SAME array: a zero kerf must be byte-identical to no kerf.
337
+ export function applyKerf(regions, kerf, { label = "sheet part", gap = null } = {}) {
338
+ if (!(kerf > 0)) return regions;
339
+ const head = `${KIT_OPTIONS_ERROR} kerf ${kerf.toFixed(2)} mm`;
340
+ const intact = `${head} could not keep the outline of "${label}" intact — lower kerf, or set it to 0 and compensate in your laser software`;
341
+ let grown;
342
+ try {
343
+ grown = offsetRegions(regions, kerf / 2, { corners: "round" });
344
+ } catch (err) {
345
+ throw new Error(intact, { cause: err });
346
+ }
347
+ const out = grown.map((rg) => ({ outer: refitRing(closeContourGap(rg.outer)), holes: rg.holes.map((h) => refitRing(closeContourGap(h))) }));
348
+ if (out.length < regions.length) throw new Error(`${head} joins separate pieces of "${label}" — space them apart or lower kerf`);
349
+ if (out.length > regions.length) throw new Error(intact);
350
+ if (holeCounts(out) !== holeCounts(regions)) {
351
+ const slot = Number.isFinite(gap) ? `a ${gap.toFixed(2)} mm slot` : "a slot";
352
+ throw new Error(`${head} closes ${slot} in "${label}" — widen it or lower kerf`);
353
+ }
354
+ if (arcCount(out) < arcCount(regions)) throw new Error(intact);
355
+ return out;
356
+ }
@@ -0,0 +1,150 @@
1
+ // The kit's DXF writer: a SheetDoc → one AutoCAD R12 (AC1009) DXF string, the dialect
2
+ // every cutting service and laser package reads. A paper-free leaf (arc-math.js only).
3
+ //
4
+ // R12 has LINE, CIRCLE and the old two-part POLYLINE / VERTEX / SEQEND; it has no
5
+ // LWPOLYLINE, no SPLINE and no HATCH, which fixes the whole mapping:
6
+ // - A closed ring is a closed POLYLINE. Its arcs ride as BULGES, tan(θ/4) with θ the
7
+ // signed sweep (positive = CCW), stored on the vertex that STARTS the segment — so
8
+ // the vertices are the start plus every segment's `to` except the last (which is
9
+ // the start again), and the closing segment's bulge sits on the final vertex.
10
+ // - A ring whose segments are ALL arcs on one centre and radius (relative 1e-6),
11
+ // sweeping ±360° in total, is a CIRCLE, whatever the arc count: recoverArcs hands a
12
+ // hole cut with cutAll back as three arcs, and a service quotes a CIRCLE as a hole.
13
+ // - A two-point open path (a score line) is a LINE.
14
+ // - A cubic is flattened to lines within FLATTEN_MM of the curve.
15
+ // - ENGRAVE carries outlines only (no HATCH in R12); the README says to set it to fill.
16
+ // Units are mm ($INSUNITS 4 — an R12 reader may ignore it, which is why the README
17
+ // prints a scale check per piece); y is NOT flipped, DXF is y-up like the Drawing.
18
+ import { arcCenterAndSweep } from "../geometry/arc-math.js";
19
+ import { placer } from "./placement.js";
20
+
21
+ export const DXF_LAYERS = Object.freeze({ CUT: 1, SCORE: 5, ENGRAVE: 7 }); // layer → ACI colour
22
+ const LAYER_FOR = Object.freeze({ engrave: "ENGRAVE", score: "SCORE", "cut-inner": "CUT", "cut-outer": "CUT" });
23
+ const INCLUDE_FOR = Object.freeze({ engrave: "engrave", score: "score", "cut-inner": "cut", "cut-outer": "cut" });
24
+ // The entity order is the cut order — drawing.js's LAYER_ORDER, which this leaf cannot
25
+ // import (drawing.js reaches paper); test/kit-dxf.test.js pins them equal.
26
+ const ORDER = Object.keys(LAYER_FOR);
27
+ const INCLUDES = ["cut", "score", "engrave"];
28
+ const TAU = 2 * Math.PI;
29
+ const FLATTEN_MM = 0.01;
30
+ const CIRCLE_REL = 1e-6;
31
+
32
+ // DXF reals: fixed precision, -0 → 0, and always a decimal point (a bare "5" is legal,
33
+ // but a strict reader of real-valued group codes may want "5.0").
34
+ const fixed = (x, digits) => { const v = Number(x.toFixed(digits)); const s = String(v === 0 ? 0 : v); return s.includes(".") ? s : `${s}.0`; };
35
+ const coord = (x) => fixed(x, 6);
36
+ const dist = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]);
37
+
38
+ // Distance from q to the SEGMENT a–b (not the infinite line: a cubic that overshoots an
39
+ // endpoint is not flat).
40
+ function segDist(q, a, b) {
41
+ const dx = b[0] - a[0], dy = b[1] - a[1], L2 = dx * dx + dy * dy;
42
+ const t = L2 < 1e-24 ? 0 : Math.max(0, Math.min(1, ((q[0] - a[0]) * dx + (q[1] - a[1]) * dy) / L2));
43
+ return Math.hypot(q[0] - (a[0] + t * dx), q[1] - (a[1] + t * dy));
44
+ }
45
+
46
+ // Points p1…pN (excluding p0) of a polyline within FLATTEN_MM of the cubic. The curve
47
+ // lies inside its control polygon's convex hull, so it is within max(segDist(c1),
48
+ // segDist(c2)) of the chord; split at t = ½ until that is small enough.
49
+ function flattenCubic(p0, c1, c2, p1, depth = 0, out = []) {
50
+ if (depth >= 16 || Math.max(segDist(c1, p0, p1), segDist(c2, p0, p1)) <= FLATTEN_MM) { out.push(p1); return out; }
51
+ const mid = (a, b) => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
52
+ const a = mid(p0, c1), b = mid(c1, c2), c = mid(c2, p1), d = mid(a, b), e = mid(b, c), m = mid(d, e);
53
+ flattenCubic(p0, a, d, m, depth + 1, out);
54
+ flattenCubic(m, e, c, p1, depth + 1, out);
55
+ return out;
56
+ }
57
+
58
+ // A path's segments as lines and arcs in its drawing frame: { to, dA } (dA 0 = a line),
59
+ // arcs also carrying their centre and radius.
60
+ function primitives(path) {
61
+ const out = [];
62
+ let from = path.start;
63
+ for (const s of path.segments) {
64
+ if (s.c1) for (const q of flattenCubic(from, s.c1, s.c2, s.to)) out.push({ to: q, dA: 0 });
65
+ else if (s.via) {
66
+ const g = arcCenterAndSweep(from, s.via, s.to);
67
+ out.push(g ? { to: s.to, dA: g.dA, center: g.center, r: g.r } : { to: s.to, dA: 0 });
68
+ } else out.push({ to: s.to, dA: 0 });
69
+ from = s.to;
70
+ }
71
+ return out;
72
+ }
73
+
74
+ function asCircle(prims) {
75
+ if (prims.length === 0 || prims.some((p) => !p.center)) return null;
76
+ const { center, r, dA } = prims[0];
77
+ const tol = CIRCLE_REL * r;
78
+ let sweep = 0;
79
+ for (const p of prims) {
80
+ if (Math.abs(p.r - r) > tol || dist(p.center, center) > tol || Math.sign(p.dA) !== Math.sign(dA)) return null;
81
+ sweep += p.dA;
82
+ }
83
+ return Math.abs(Math.abs(sweep) - TAU) <= 1e-6 ? { center, r } : null;
84
+ }
85
+
86
+ function writeEntity(g, layer, path, place) {
87
+ const prims = primitives(path);
88
+ if (!path.closed && prims.length === 1 && prims[0].dA === 0) {
89
+ const [x1, y1] = place(path.start), [x2, y2] = place(prims[0].to);
90
+ g(0, "LINE"); g(8, layer);
91
+ g(10, coord(x1)); g(20, coord(y1)); g(30, "0.0");
92
+ g(11, coord(x2)); g(21, coord(y2)); g(31, "0.0");
93
+ return;
94
+ }
95
+ const circle = path.closed ? asCircle(prims) : null;
96
+ if (circle) {
97
+ const [cx, cy] = place(circle.center);
98
+ g(0, "CIRCLE"); g(8, layer); g(10, coord(cx)); g(20, coord(cy)); g(30, "0.0"); g(40, coord(circle.r));
99
+ return;
100
+ }
101
+ const verts = [path.start, ...prims.map((p) => p.to)];
102
+ if (path.closed) verts.pop(); // the last `to` is the start again
103
+ g(0, "POLYLINE"); g(8, layer); g(66, 1); g(10, "0.0"); g(20, "0.0"); g(30, "0.0"); g(70, path.closed ? 1 : 0);
104
+ verts.forEach((v, i) => {
105
+ const [x, y] = place(v);
106
+ g(0, "VERTEX"); g(8, layer); g(10, coord(x)); g(20, coord(y)); g(30, "0.0");
107
+ const dA = prims[i]?.dA ?? 0; // the segment this vertex starts (none after an open end)
108
+ if (dA !== 0) g(42, fixed(Math.tan(dA / 4), 10));
109
+ });
110
+ g(0, "SEQEND"); g(8, layer);
111
+ }
112
+
113
+ // `include` picks the layers: a cutting service gets one cut-only file per piece and a
114
+ // separate marks file (["score", "engrave"]). Only the included layers are declared.
115
+ export function renderDxf(doc, { include = INCLUDES } = {}) {
116
+ for (const inc of include)
117
+ if (!INCLUDES.includes(inc)) throw new Error(`renderDxf: include lists "cut", "score" and "engrave" only, got ${JSON.stringify(inc)}`);
118
+ const want = new Set(include);
119
+ const ids = ORDER.filter((id) => want.has(INCLUDE_FOR[id]));
120
+ const names = [...new Set(ids.map((id) => LAYER_FOR[id]))];
121
+ const out = [];
122
+ const g = (code, value) => { out.push(String(code).padStart(3), String(value)); };
123
+
124
+ g(0, "SECTION"); g(2, "HEADER");
125
+ g(9, "$ACADVER"); g(1, "AC1009");
126
+ g(9, "$INSUNITS"); g(70, 4);
127
+ g(0, "ENDSEC");
128
+
129
+ g(0, "SECTION"); g(2, "TABLES");
130
+ g(0, "TABLE"); g(2, "LTYPE"); g(70, 1);
131
+ g(0, "LTYPE"); g(2, "CONTINUOUS"); g(70, 0); g(3, "Solid line"); g(72, 65); g(73, 0); g(40, "0.0");
132
+ g(0, "ENDTAB");
133
+ g(0, "TABLE"); g(2, "LAYER"); g(70, names.length);
134
+ for (const name of names) { g(0, "LAYER"); g(2, name); g(70, 0); g(62, DXF_LAYERS[name]); g(6, "CONTINUOUS"); }
135
+ g(0, "ENDTAB");
136
+ g(0, "ENDSEC");
137
+
138
+ g(0, "SECTION"); g(2, "ENTITIES");
139
+ for (const id of ids) {
140
+ for (const placement of doc.placements) {
141
+ const layer = placement.drawing.layers.find((l) => l.id === id);
142
+ if (!layer) continue;
143
+ const place = placer(placement);
144
+ for (const path of layer.paths) writeEntity(g, LAYER_FOR[id], path, place);
145
+ }
146
+ }
147
+ g(0, "ENDSEC");
148
+ g(0, "EOF");
149
+ return `${out.join("\n")}\n`;
150
+ }
@@ -0,0 +1,142 @@
1
+ // The cut & print kit's option contract and the export-format table. IMPORT-FREE on
2
+ // purpose: three very different readers load this file — the kit's lazy writers in the
3
+ // worker, the runtime on the main thread (listExportFormats), and partforge-cloud, whose
4
+ // coupling test imports KIT_OPTIONS_ERROR from "partforge" — and none of them may pay
5
+ // for paper or a kernel to read a table (test/kit-export-guards.test.js holds it).
6
+ //
7
+ // validateKitOptions checks MEANING: enums, ranges, stock entries. A host's own inbound
8
+ // validator checks only shape and size; that split is deliberate (design spec E.1), so
9
+ // every refusal here is written for the person at the export dialog. Each one starts
10
+ // with KIT_OPTIONS_ERROR, the prefix cloud routes to "Back to options" instead of
11
+ // offering to have the assistant fix a part that is not broken.
12
+
13
+ export const KIT_OPTIONS_ERROR = "cut kit options:";
14
+
15
+ // Every format partforge can export. `needsSheet`: the format exists only when at least
16
+ // one checked part is a sheet part (the kit). A host intersects this with its own table.
17
+ export const EXPORT_FORMATS = Object.freeze([
18
+ Object.freeze({ id: "stl", label: "STL", ext: "stl", mime: "model/stl", needsSheet: false }),
19
+ Object.freeze({ id: "step", label: "STEP", ext: "step", mime: "application/step", needsSheet: false }),
20
+ Object.freeze({ id: "3mf", label: "3MF", ext: "3mf", mime: "model/3mf", needsSheet: false }),
21
+ Object.freeze({ id: "bundle", label: "Cut and print kit", ext: "zip", mime: "application/zip", needsSheet: true }),
22
+ ]);
23
+
24
+ // Destination → the cut-file format it writes. Pinned equal to the laser descriptor's
25
+ // `destinations` by test/kit-formats.test.js — two spellings of one fact.
26
+ export const DESTINATIONS = Object.freeze({ "own-laser": "svg", service: "dxf" });
27
+
28
+ // 300 × 300 mm until the paying user's machine is known (design spec decision 12).
29
+ export const DEFAULT_STOCK_SIZE = Object.freeze([300, 300]);
30
+
31
+ export const KIT_LIMITS = Object.freeze({
32
+ kerf: Object.freeze([0, 0.5]),
33
+ margin: Object.freeze([0, 50]),
34
+ // min 1 mm: two pieces never share a cut line (the laser would cut it twice).
35
+ spacing: Object.freeze([1, 50]),
36
+ sets: Object.freeze([1, 20]),
37
+ stockEntries: 20,
38
+ stockSide: Object.freeze([10, 3000]),
39
+ sheetsPerGroup: 50,
40
+ pieces: 200,
41
+ });
42
+
43
+ export const KIT_DEFAULTS = Object.freeze({
44
+ destination: "own-laser", kerf: 0, colors: "lightburn", stock: null,
45
+ margin: 5, spacing: 3, printFormat: "stl", sets: 1,
46
+ });
47
+
48
+ const OPTION_KEYS = ["destination", "cutFormat", "kerf", "colors", "stock", "margin", "spacing", "printFormat", "sets"];
49
+
50
+ const fail = (message) => new Error(`${KIT_OPTIONS_ERROR} ${message}`);
51
+ const isPlainObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v)
52
+ && (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null);
53
+ const inRange = (v, [lo, hi]) => typeof v === "number" && Number.isFinite(v) && v >= lo && v <= hi;
54
+
55
+ // `undefined` means "all defaults" (a caller that sent no options at all); anything else
56
+ // that is not a plain object is refused. Returns a fresh, fully populated object with
57
+ // `cutFormat` derived from the destination.
58
+ export function validateKitOptions(o) {
59
+ const opts = o === undefined ? {} : o;
60
+ if (!isPlainObject(opts)) throw fail("options must be an object");
61
+ for (const key of Object.keys(opts))
62
+ if (!OPTION_KEYS.includes(key)) throw fail(`unknown option "${key}" — the options are ${OPTION_KEYS.join(", ")}`);
63
+ const pick = (key) => (opts[key] === undefined ? KIT_DEFAULTS[key] : opts[key]);
64
+
65
+ const destination = pick("destination");
66
+ if (typeof destination !== "string" || !Object.hasOwn(DESTINATIONS, destination))
67
+ throw fail(`destination must be "own-laser" or "service", got ${JSON.stringify(destination)}`);
68
+ const cutFormat = DESTINATIONS[destination];
69
+ if (opts.cutFormat !== undefined && opts.cutFormat !== cutFormat)
70
+ throw fail(`cutFormat ${JSON.stringify(opts.cutFormat)} does not match destination "${destination}" (which writes ${cutFormat})`);
71
+
72
+ const kerf = pick("kerf");
73
+ if (!inRange(kerf, KIT_LIMITS.kerf))
74
+ throw fail(`kerf must be a number from ${KIT_LIMITS.kerf[0]} to ${KIT_LIMITS.kerf[1]} mm, got ${JSON.stringify(kerf)}`);
75
+ const colors = pick("colors");
76
+ if (colors !== "lightburn") throw fail(`colors must be "lightburn", got ${JSON.stringify(colors)}`);
77
+ const stock = normalizeStock(opts.stock);
78
+ const margin = pick("margin");
79
+ if (!inRange(margin, KIT_LIMITS.margin))
80
+ throw fail(`margin must be a number from ${KIT_LIMITS.margin[0]} to ${KIT_LIMITS.margin[1]} mm, got ${JSON.stringify(margin)}`);
81
+ const spacing = pick("spacing");
82
+ if (!inRange(spacing, KIT_LIMITS.spacing))
83
+ throw fail(`spacing must be a number from ${KIT_LIMITS.spacing[0]} to ${KIT_LIMITS.spacing[1]} mm, got ${JSON.stringify(spacing)}`);
84
+ const printFormat = pick("printFormat");
85
+ if (printFormat !== "stl" && printFormat !== "3mf")
86
+ throw fail(`printFormat must be "stl" or "3mf", got ${JSON.stringify(printFormat)}`);
87
+ const sets = pick("sets");
88
+ if (!Number.isInteger(sets) || sets < KIT_LIMITS.sets[0] || sets > KIT_LIMITS.sets[1])
89
+ throw fail(`sets must be a whole number from ${KIT_LIMITS.sets[0]} to ${KIT_LIMITS.sets[1]}, got ${JSON.stringify(sets)}`);
90
+
91
+ return { destination, cutFormat, kerf, colors, stock, margin, spacing, printFormat, sets };
92
+ }
93
+
94
+ // null/undefined → null ("every group gets DEFAULT_STOCK_SIZE"); otherwise fresh
95
+ // `{ group, size: [w, h] }` copies. A group key is sheetGroup()'s `"<material>|<t>"`, or
96
+ // "*" for an explicit default — which groups exist is only known once the parts are
97
+ // resolved, so matching happens later, in resolveStock.
98
+ function normalizeStock(stock) {
99
+ if (stock === undefined || stock === null) return null;
100
+ if (!Array.isArray(stock)) throw fail("stock must be an array of { group, size: [w, h] }");
101
+ if (stock.length > KIT_LIMITS.stockEntries)
102
+ throw fail(`stock has ${stock.length} entries — at most ${KIT_LIMITS.stockEntries}`);
103
+ const seen = new Set();
104
+ return stock.map((entry, i) => {
105
+ const shaped = isPlainObject(entry) && Object.keys(entry).every((k) => k === "group" || k === "size")
106
+ && typeof entry.group === "string" && entry.group.length > 0
107
+ && Array.isArray(entry.size) && entry.size.length === 2;
108
+ if (!shaped) throw fail(`stock entry ${i + 1} needs a group and a size [w, h] in mm`);
109
+ const { group, size } = entry;
110
+ const [lo, hi] = KIT_LIMITS.stockSide;
111
+ if (!size.every((v) => inRange(v, KIT_LIMITS.stockSide)))
112
+ throw fail(`stock "${group}" size must be two numbers from ${lo} to ${hi} mm, got ${JSON.stringify(size)}`);
113
+ if (seen.has(group)) throw fail(`stock names "${group}" twice`);
114
+ seen.add(group);
115
+ return { group, size: [size[0], size[1]] };
116
+ });
117
+ }
118
+
119
+ // Match validated stock entries to the kit's groups (`[{ group, label }]`, definition
120
+ // order; label = `${material} ${fmtMm(t)} mm`). Never a silent fallback: an entry naming
121
+ // no group, or a group with neither its own entry nor a "*" default, is an options error
122
+ // (design spec D.3). Returns group → [w, h].
123
+ export function resolveStock(stock, groups) {
124
+ const sizes = new Map();
125
+ if (stock === null || stock === undefined) {
126
+ for (const { group } of groups) sizes.set(group, [DEFAULT_STOCK_SIZE[0], DEFAULT_STOCK_SIZE[1]]);
127
+ return sizes;
128
+ }
129
+ const known = new Set(groups.map((g) => g.group));
130
+ const byGroup = new Map();
131
+ for (const { group, size } of stock) {
132
+ if (group !== "*" && !known.has(group))
133
+ throw fail(`stock names "${group}", which no checked sheet part uses — the kit's groups are ${groups.map((g) => JSON.stringify(g.group)).join(", ")}`);
134
+ byGroup.set(group, size);
135
+ }
136
+ for (const { group, label } of groups) {
137
+ const size = byGroup.get(group) ?? byGroup.get("*");
138
+ if (!size) throw fail(`no stock size for "${group}" (${label}) — add a stock entry for it or a "*" default`);
139
+ sizes.set(group, [size[0], size[1]]);
140
+ }
141
+ return sizes;
142
+ }