partforge 0.133.2 → 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.
@@ -1920,6 +1920,10 @@ unevaluated. In a forge mixing sheet and printed
1920
1920
  parts, the profile's bed fits each **printed** part in its print (export) pose, not
1921
1921
  the assembled view, and `minWall` and the overhang check skip sheet parts.
1922
1922
 
1923
+ ### The kit
1924
+
1925
+ A host downloads sheet parts as one ZIP, the **cut & print kit** (`format: "bundle"`): `README.txt`, `parts.csv`, laid-out `sheets/`, one `parts/` file per distinct piece (plus a `-marks.dxf` for a cutting service) and `print/` for printed parts. Identical pieces merge into one `-xN` file named from `export: { name }`, else the key. Destination (`"own-laser"` or `"service"`), kerf and stock size are chosen at download, so draw nominal and bind `thickness` to the measured control. A choice the kit cannot honour fails with `cut kit options:`.
1926
+
1923
1927
  ### Limits
1924
1928
 
1925
1929
  - One thickness per joint: fingers, tabs and T-slots join panels cut from the
@@ -2995,15 +2999,41 @@ view-bound export UI. An embedder that wants its own export UI (e.g. a "pick whi
2995
2999
  pick a format" modal) can skip those buttons and drive export off the handle `mount()`
2996
3000
  returns instead:
2997
3001
 
2998
- - `runtime.listExportableParts() → [{ name, label }]` — every exportable sub-part
3002
+ - `runtime.listExportableParts() → [{ name, label, sheet? }]` — every exportable sub-part
2999
3003
  (excludes any `exportable: false` part, respects each part's `enabled(params)`),
3000
- **independent of the active view**. Use it to populate an export checklist.
3001
- - `runtime.exportParts({ parts, format, quality?, onProgress }) → Promise<void>` — build
3002
- the given `parts` (sub-part names) in `format` (`"stl" | "step" | "3mf"`), streaming
3004
+ **independent of the active view**. Use it to populate an export checklist. A sheet
3005
+ part's row also carries `sheet: { process, material, thickness, group }`, evaluated at
3006
+ the current params (omitted when that throws) — enough to tag it in the list and to
3007
+ name its stock group in the kit's `stock` option.
3008
+ - `runtime.listExportFormats() → [{ id, label, ext, mime, needsSheet }]` — the formats
3009
+ `exportParts` writes in this version, as fresh copies of `EXPORT_FORMATS`. `needsSheet`
3010
+ marks the one that needs a sheet part checked: `"bundle"`, the cut & print kit.
3011
+ - `runtime.exportParts({ parts, format, quality?, onProgress, options? }) → Promise<void>` — build
3012
+ the given `parts` (sub-part names) in `format` (`"stl" | "step" | "3mf" | "bundle"`), streaming
3003
3013
  phase strings to `onProgress(phase)`. Resolves once the file is written (handed to your
3004
3014
  `onDownload` sink, or downloaded directly if you don't supply one); rejects on
3005
3015
  build/export failure or an empty selection. Placement uses the current
3006
- view. STEP is routed to OCCT automatically.
3016
+ view. STEP is routed to OCCT automatically. Only `"bundle"` reads `options`.
3017
+ - **The cut & print kit** (`format: "bundle"`) writes one `<title>-kit.zip`: `README.txt`
3018
+ (the thickness each material assumes, the kerf applied, a scale check per piece, the
3019
+ colour or DXF-layer legend, every 2-D check that warned), `parts.csv`,
3020
+ `sheets/<material>-<t>mm/sheet-<i>-of-<n>.svg` (the pieces laid out on the user's
3021
+ stock), `parts/<name>[-xN].svg` (one per distinct piece; identical pieces merge) and
3022
+ `print/<name>[-xN].stl` or `.3mf` (the printed parts, in their export pose). With
3023
+ `destination: "service"` each piece is a cut-only `.dxf` plus a `-marks.dxf` for its
3024
+ score and engrave lines, and the DXF sheets are for reference. Every `options` key is
3025
+ optional: `destination` (`"own-laser"`, the default, or `"service"`), `kerf` (0–0.5 mm,
3026
+ applied to the cut lines only), `stock: [{ group, size: [w, h] }]` (a row's
3027
+ `sheet.group`; a `"*"` entry sizes every group that has no entry of its own; 300 × 300
3028
+ mm when `stock` is omitted), `margin` (5 mm),
3029
+ `spacing` (3 mm), `printFormat` (`"stl"` or `"3mf"`) and `sets` (1–20). The main entry
3030
+ exports `EXPORT_FORMATS`, `KIT_DEFAULTS`, `validateKitOptions` and `KIT_OPTIONS_ERROR`
3031
+ for a host that draws its own options screen. An option the kit cannot honour — an
3032
+ unknown key, a kerf that closes a slot, a piece bigger than an own laser's sheet, a
3033
+ margin that leaves no room on the sheet, a stock entry naming no group, more than 200
3034
+ pieces or 50 sheets of one material —
3035
+ rejects with a message starting `cut kit options:`: the user's setting to change, not
3036
+ the part's code.
3007
3037
  - `runtime.warmExportKernel() → Promise<boolean>` — pay OCCT's cold boot *before* an
3008
3038
  export needs it. Because STEP is pinned to OCCT and OCCT's ~11 MB WASM loads on its
3009
3039
  first job, a part whose preview ran on Manifold pays that whole boot inside its first
@@ -852,7 +852,7 @@ maps that protocol onto `setPart` itself.
852
852
  readiness exactly as it would on a freshly spawned worker.
853
853
 
854
854
  **Epoch guard.** Generates supersede each other; exports (`export-stl`/`export-step`/
855
- `export-3mf`), `inspect`, and `lint` are **never** epoch-guarded — cancelling a user's
855
+ `export-3mf`/`export-bundle`), `inspect`, and `lint` are **never** epoch-guarded — cancelling a user's
856
856
  export because an edit landed would be wrong. A generate that is stale by the time the
857
857
  job pump reaches it is skipped and never builds at all. A generate already running
858
858
  re-checks staleness at each sub-part boundary and, if it has been superseded, stops
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.133.2",
3
+ "version": "0.134.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,413 @@
1
+ // The cut & print kit (spec D): one ZIP per forge — every checked sheet part drawn flat
2
+ // and laid out on the user's stock, every checked printed part as STL or 3MF, a README
3
+ // and a parts list.
4
+ //
5
+ // jobs.js loads this module LAZILY, on the first export-bundle job (`loadBundle`), never
6
+ // at worker boot (test/worker-layering.test.js). And nothing here may reach ./oracle/*
7
+ // (test/kit-layering.test.js mocks every oracle module to throw): the mesh oracle has
8
+ // OOM-killed iOS Safari, and the kit needs none of it — a sheet is drawn from its 2-D
9
+ // declaration and checked by its own process's facts()/checks().
10
+ import { zipSync, strToU8 } from "fflate";
11
+ import { safeName } from "../safe-name.js";
12
+ import { meshTo3MF } from "../geometry/threemf.js";
13
+ import { h } from "../geometry/solid-hash.js";
14
+ import { printColor } from "../materials/resolve.js";
15
+ import {
16
+ isSheetPart, sheetMeta, fmtMm, SHEET_CHECK_BUDGET_MS, SHEET_CHECKS_NOTICE, SHEET_READ_ERROR_HINT,
17
+ } from "../sheet/constants.js";
18
+ import { resolveSheet } from "../sheet/resolve.js";
19
+ import { processById, SHEET_METRICS } from "../process/registry.js";
20
+ import { loadExporter } from "../process/exporters.js";
21
+ import { validateKitOptions, resolveStock, KIT_OPTIONS_ERROR, KIT_LIMITS } from "./formats.js";
22
+ import { canonicalDrawingKey } from "./drawing.js";
23
+ import { renderSvg } from "./svg.js";
24
+ import { renderDxf } from "./dxf.js";
25
+ import { layoutGroup, marginFitsStock } from "./layout.js";
26
+ import { renderReadme, renderPartsCsv, cleanLabel } from "./readme.js";
27
+
28
+ // Every entry name the kit may write (spec D.10). test/kit-bundle.test.js pins every
29
+ // kit it builds against it, and buildBundle asserts it again before zipping.
30
+ export const KIT_ENTRY_RE = /^(README\.txt|parts\.csv|sheets\/[a-z0-9][a-z0-9._-]*\/sheet-\d+-of-\d+\.(svg|dxf)|parts\/[a-z0-9][a-z0-9._-]*(-x\d+)?(-marks)?\.(svg|dxf)|print\/[a-z0-9][a-z0-9._-]*(-x\d+)?\.(stl|3mf))$/;
31
+
32
+ // A path segment from an author string. safeName already lowercases, collapses runs and
33
+ // refuses a leading dot or dash; folding every run of dots on top means no entry name
34
+ // can ever carry "..". safeName keeps a leading underscore, which KIT_ENTRY_RE (copied
35
+ // verbatim by the cloud) refuses, so it comes off here too — a sub-part keyed `_left`
36
+ // is `left`, never a kit that fails at its last step — and a name that was nothing but
37
+ // separators falls back to "part".
38
+ export const kitName = (s) => safeName(s, "part").replace(/\.{2,}/g, ".").replace(/^[._-]+/, "") || "part";
39
+
40
+ // The first of base, base-2, base-3, … whose files are all free, claimed in `taken`.
41
+ // `filesOf(name)` lists every path a name produces — a piece's cut file and its -marks
42
+ // file — so a name is free only when all of them are, and "front" ×2 can never land on
43
+ // the file of a piece literally named "front-x2". Called in definition order, so a
44
+ // forge always gets the same names.
45
+ export function uniqueName(base, taken, filesOf = (name) => [name]) {
46
+ let name = base;
47
+ for (let i = 2; filesOf(name).some((f) => taken.has(f)); i++) name = `${base}-${i}`;
48
+ for (const f of filesOf(name)) taken.add(f);
49
+ return name;
50
+ }
51
+
52
+ // Merge equal items, first-seen order kept. `hashOf` only NOMINATES candidates — the kit
53
+ // hashes with solid-hash.js's 32-bit FNV, which can collide — and `same(a, b)` confirms.
54
+ // Returns [{ item, members }], `item` being the first member.
55
+ export function dedupe(items, hashOf, same) {
56
+ const groups = [];
57
+ const byHash = new Map();
58
+ for (const item of items) {
59
+ const key = hashOf(item);
60
+ const candidates = byHash.get(key) ?? [];
61
+ const hit = candidates.find((g) => same(g.item, item));
62
+ if (hit) { hit.members.push(item); continue; }
63
+ const group = { item, members: [item] };
64
+ candidates.push(group);
65
+ byHash.set(key, candidates);
66
+ groups.push(group);
67
+ }
68
+ return groups;
69
+ }
70
+
71
+ // verify's wording for the three expression forms a process's checks() emits — ">=N",
72
+ // "<=N" and a bare number (equality) — restated from oracle/assert-dsl.js, because the
73
+ // kit must never load ./oracle/*. test/kit-bundle-helpers.test.js holds the two equal.
74
+ // Any other form throws: it needs a line here, not a silent pass.
75
+ const EPS = 1e-6;
76
+ export function checkMessage(expr, actual) {
77
+ const m = /^(>=|<=)?\s*([-+]?[0-9]*\.?[0-9]+)$/.exec(String(expr).trim());
78
+ if (!m) throw new Error(`cut kit: unsupported sheet check "${expr}"`);
79
+ const value = Number(m[2]);
80
+ if (m[1] === ">=") {
81
+ const pass = actual >= value - EPS;
82
+ return { pass, message: `${actual} ${pass ? ">=" : "not >="} ${value}` };
83
+ }
84
+ if (m[1] === "<=") {
85
+ const pass = actual <= value + EPS;
86
+ return { pass, message: `${actual} ${pass ? "<=" : "not <="} ${value}` };
87
+ }
88
+ const pass = Math.abs(actual - value) <= EPS + EPS * Math.abs(value);
89
+ return { pass, message: `${actual} ${pass ? "==" : "!="} ${value}` };
90
+ }
91
+
92
+ // SHEET_CHECKS_NOTICE.message ("2-D sheet checks not evaluated (time budget)") is
93
+ // oracle/verify.js's own wording for the same state, imported from sheet/constants.js —
94
+ // the one place it lives, since the kit must never load ./oracle/*. The kit appends its
95
+ // own reason to the shared prefix rather than restating the whole line.
96
+ const NOT_EVALUATED = `${SHEET_CHECKS_NOTICE.message} — the kit ran out of time checking this piece for narrow webs, narrow gaps and stray marks; look it over before cutting`;
97
+
98
+ // The README's CHECKS lines for one piece: every volunteered check its process runs
99
+ // that fails, as `${label}: ${message} — ${hint}` with the metric's own hint. A metric
100
+ // with no reading (budget-gated and not evaluated, or a custom build's volume match,
101
+ // which needs a solid the kit never builds) is skipped; an unevaluated piece gets one
102
+ // line saying so instead. A metric the geometry engine refused to read (readErrors) gets
103
+ // its own line, worded like verify's: `not measured — <reason>`, with SHEET_READ_ERROR_HINT
104
+ // (sheet/constants.js, imported above — the kit must never load ./oracle/* itself, so it
105
+ // reads the one shared text rather than a copy) — never the metric's own hint, which
106
+ // describes a value that was never taken.
107
+ export function sheetWarnings(label, facts) {
108
+ const desc = facts ? processById(facts.process) : null;
109
+ if (!desc) return [];
110
+ const out = [];
111
+ for (const [metric, expr] of Object.entries(desc.checks(facts))) {
112
+ const reg = SHEET_METRICS[metric];
113
+ const actual = reg?.extract({ sheet: facts });
114
+ if (actual === null || actual === undefined) {
115
+ const why = reg?.readError?.({ sheet: facts });
116
+ if (why) out.push(`${label}: not measured — ${why} — ${SHEET_READ_ERROR_HINT}`);
117
+ continue;
118
+ }
119
+ const { pass, message } = checkMessage(expr, actual);
120
+ if (!pass) out.push(`${label}: ${message} — ${reg.hint}`);
121
+ }
122
+ if (facts.evaluated === false) out.push(`${label}: ${NOT_EVALUATED}`);
123
+ return out;
124
+ }
125
+
126
+ // ─── buildBundle ────────────────────────────────────────────────────────────
127
+
128
+ // cl:src/sandbox/protocol.js clips a progress phase at 120 characters; the kit keeps
129
+ // its own phases under that, so a long label is cut here, once, with an ellipsis.
130
+ const PHASE_MAX = 120;
131
+ const clipPhase = (s) => (s.length > PHASE_MAX ? `${s.slice(0, PHASE_MAX - 1)}…` : s);
132
+ const sizeOf = (b) => [b.max[0] - b.min[0], b.max[1] - b.min[1]];
133
+ const near = (a, b) => Math.abs(a - b) <= 1e-6 * Math.max(1, Math.abs(a), Math.abs(b));
134
+ // The words of a control's key or label: camelCase, snake_case, digits and spaces all
135
+ // split, lowercased — "fingerClearance" → finger, clearance.
136
+ const wordsOf = (s) => String(s ?? "").replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([a-zA-Z])([0-9])/g, "$1 $2")
137
+ .toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
138
+ // Controls the README lists as design fit rather than kerf (spec D.9): a WORD of the key
139
+ // or label is clearance, fit or play — so "Finger clearance", "tab_play" and "pressFit"
140
+ // are listed, and "display", "outfit" and "profit" are not.
141
+ const CLEARANCE_WORDS = new Set(["clearance", "clearances", "fit", "play"]);
142
+ const isClearance = (c) => [...wordsOf(c.key), ...wordsOf(c.label)].some((w) => CLEARANCE_WORDS.has(w));
143
+ const ALL_LAYERS = ["cut", "score", "engrave"];
144
+
145
+ // Every control that owns a parameter key, whatever the section shape: authored
146
+ // `controls` (groups nest), and the legacy `advanced`, `toggles`, `features` and a
147
+ // feature's own `sliders`. Groups and presets own no key.
148
+ function collectControls(parameters) {
149
+ const out = [];
150
+ const visit = (list) => {
151
+ for (const c of Array.isArray(list) ? list : []) {
152
+ if (!c || typeof c !== "object") continue;
153
+ if (typeof c.key === "string" && c.type !== "group" && c.type !== "preset") out.push(c);
154
+ visit(c.controls);
155
+ visit(c.sliders);
156
+ }
157
+ };
158
+ for (const sec of Array.isArray(parameters) ? parameters : []) {
159
+ visit(sec?.controls); visit(sec?.advanced); visit(sec?.toggles); visit(sec?.features);
160
+ }
161
+ return out;
162
+ }
163
+
164
+ // The control that sets a sheet part's thickness, so the README can name the setting to
165
+ // change when the user's sheet measures differently. Of the controls the thickness
166
+ // function reads, the one whose value IS the thickness it returned (a
167
+ // `p.material === "acrylic" ? p.acrylicT : p.plyT` reads Material first, and Material
168
+ // is not the thickness); failing a single such control, the one read whose key or label
169
+ // says "thick"; else null — the generic wording, never a guess. Null too for a literal
170
+ // thickness or one that reads no control.
171
+ function thicknessControlLabel(controls, sp, p, d) {
172
+ const t = sp.sheet.thickness;
173
+ if (typeof t !== "function") return null;
174
+ const reads = new Set();
175
+ const spy = new Proxy(p, { get: (target, key) => { if (typeof key === "string") reads.add(key); return target[key]; } });
176
+ let value;
177
+ try { value = t(spy, d); } catch { return null; }
178
+ const read = controls.filter((x) => reads.has(x.key));
179
+ const holds = read.filter((x) => typeof p[x.key] === "number" && Math.abs(p[x.key] - value) <= 1e-9);
180
+ const pool = holds.length > 1 ? holds : read;
181
+ const named = pool.filter((x) => [...wordsOf(x.key), ...wordsOf(x.label)].some((w) => w.startsWith("thick")));
182
+ const c = holds.length === 1 ? holds[0] : named.length === 1 ? named[0] : null;
183
+ return c ? String(c.label ?? c.key) : null;
184
+ }
185
+
186
+ // A sheet part's stock group, or the part's own named error: sheetMeta answers null
187
+ // when material or thickness cannot be evaluated, and resolveSheet is what knows how to
188
+ // say why.
189
+ function sheetMetaOrThrow(kernel, sp, p, d, name) {
190
+ const meta = sheetMeta(sp, p, d);
191
+ if (meta) return meta;
192
+ resolveSheet(kernel, sp, p, d);
193
+ throw new Error(`sheet part "${name}": its material or thickness could not be evaluated`);
194
+ }
195
+
196
+ // One export-bundle job's kit. `names` is the job's selected() — exportable, enabled, in
197
+ // definition order — and `posed`/`label`/`exportName` are jobs.js's own, so a kit's
198
+ // printed parts are built exactly the way the STL and 3MF exports build them.
199
+ export async function buildBundle({ kernel, part, msg, p, d, posed, label, exportName, names, onProgress, fileBase }) {
200
+ // Options first, before anything is built: a bad option is the user's to fix and must
201
+ // not wait behind a build (spec D.3). An absent options object means every default.
202
+ const o = validateKitOptions(msg.options ?? {});
203
+ const service = o.destination === "service";
204
+ const cutExt = service ? "dxf" : "svg";
205
+ const say = (phase) => onProgress(clipPhase(phase));
206
+ const nameOf = (name) => cleanLabel(label(name));
207
+ if (names.length === 0) throw new Error("no exportable parts selected");
208
+ const sheetNames = names.filter((name) => isSheetPart(part.parts[name]));
209
+ const printNames = names.filter((name) => !isSheetPart(part.parts[name]));
210
+ if (sheetNames.length === 0) {
211
+ throw new Error(`${KIT_OPTIONS_ERROR} none of the checked parts is a sheet part — check a sheet part or export STL/3MF instead`);
212
+ }
213
+ // The cut-piece count is sheet sub-parts × sets — exactly what the merged pieces sum
214
+ // to — so it is checked before the builds it would otherwise waste. The remedy named
215
+ // is one that can work: past the cap at a single set, only fewer parts will do.
216
+ const cutCount = sheetNames.length * o.sets;
217
+ if (cutCount > KIT_LIMITS.pieces) {
218
+ const remedy = sheetNames.length > KIT_LIMITS.pieces ? "check fewer sheet parts" : "lower sets";
219
+ throw new Error(`${KIT_OPTIONS_ERROR} the kit has ${cutCount} pieces — at most ${KIT_LIMITS.pieces}; ${remedy}`);
220
+ }
221
+
222
+ // Stock groups from each part's material and thickness alone, so a stock entry that
223
+ // names no group in the kit fails before any drawing is made.
224
+ const groups = new Map();
225
+ const groupOf = new Map();
226
+ for (const name of sheetNames) {
227
+ const meta = sheetMetaOrThrow(kernel, part.parts[name], p, d, nameOf(name));
228
+ if (!groups.has(meta.group)) {
229
+ const material = cleanLabel(meta.material);
230
+ groups.set(meta.group, { group: meta.group, material, thickness: meta.thickness,
231
+ label: `${material} ${fmtMm(meta.thickness)} mm`, first: name });
232
+ }
233
+ groupOf.set(name, meta.group);
234
+ }
235
+ const stockFor = resolveStock(o.stock, [...groups.values()].map((g) => ({ group: g.group, label: g.label })));
236
+ // A margin that leaves a group's stock with no inside at all is known right here —
237
+ // check it before any piece is drawn (spec D.3), not only once layoutGroup reaches
238
+ // that group after every sheet part has been built.
239
+ for (const g of groups.values()) marginFitsStock(stockFor.get(g.group), o.margin, g.label);
240
+
241
+ // Draw every sheet part flat, from its 2-D declaration, with its 2-D facts under ONE
242
+ // budget for the whole kit.
243
+ const deadline = Date.now() + SHEET_CHECK_BUDGET_MS;
244
+ const drawn = [];
245
+ for (const name of sheetNames) {
246
+ say(`building ${nameOf(name)}`);
247
+ const s = resolveSheet(kernel, part.parts[name], p, d);
248
+ const desc = processById(s.process);
249
+ if (!desc) throw new Error(`sheet part "${nameOf(name)}": unknown process "${s.process}"`);
250
+ const facts = desc.facts(s, { deadline });
251
+ const exporter = await loadExporter(s.process);
252
+ const drawing = exporter.drawing(s, o, kernel, { label: nameOf(name), facts });
253
+ drawn.push({ name, label: nameOf(name), fileKey: exportName(name), group: groupOf.get(name),
254
+ drawing, facts, key: canonicalDrawingKey(drawing) });
255
+ }
256
+
257
+ // Identical pieces become one file ×N (spec D.4): the hash nominates; the canonical
258
+ // drawing and the stock group confirm.
259
+ const pieces = dedupe(drawn, (x) => h(x.key), (a, b) => a.key === b.key && a.group === b.group)
260
+ .map(({ item, members }) => ({
261
+ ...item, labels: members.map((m) => m.label), qty: members.length * o.sets,
262
+ hasMarks: item.drawing.layers.some((l) => l.id === "score" || l.id === "engrave"),
263
+ }));
264
+
265
+ // Names, in definition order. A name that was taken moves to name-2; the README's
266
+ // RENAMED FILES says which file it was and who held the name (spec D.10). A piece
267
+ // claims its bare name as well as its files (the `parts/<name>/` key, which no file
268
+ // path can equal): `-x<n>` is a QUANTITY, so a second, different piece with the
269
+ // same name must be name-2 — never name.svg beside name-x2.svg, which reads as one
270
+ // piece at two quantities.
271
+ const taken = new Set();
272
+ const owners = new Map();
273
+ const renamed = [];
274
+ const claim = (base, filesOf, labels) => {
275
+ const name = uniqueName(base, taken, filesOf);
276
+ const file = filesOf(name)[0];
277
+ if (name !== base) {
278
+ const holder = filesOf(base).map((f) => owners.get(f)).find(Boolean);
279
+ renamed.push(`${file}: ${labels.join(", ")}${holder ? ` (renamed: ${holder.file} is ${holder.labels.join(", ")})` : ""}`);
280
+ }
281
+ for (const f of filesOf(name)) owners.set(f, { file, labels });
282
+ return name;
283
+ };
284
+ for (const pc of pieces) {
285
+ const sx = pc.qty > 1 ? `-x${pc.qty}` : "";
286
+ const marks = service && pc.hasMarks;
287
+ const name = claim(kitName(pc.fileKey),
288
+ (n) => [`parts/${n}${sx}.${cutExt}`, ...(marks ? [`parts/${n}${sx}-marks.dxf`] : []), `parts/${n}/`], pc.labels);
289
+ pc.file = `parts/${name}${sx}.${cutExt}`;
290
+ pc.marksFile = marks ? `parts/${name}${sx}-marks.dxf` : null;
291
+ }
292
+
293
+ // Lay out each stock group. An own laser needs every piece to fit (layoutGroup
294
+ // throws "stock too small"); for a cutting service the sheets are reference only, so
295
+ // an oversized piece is listed in the README instead (spec D.5).
296
+ const sheets = [];
297
+ const oversized = [];
298
+ for (const g of groups.values()) {
299
+ const inGroup = pieces.filter((pc) => pc.group === g.group);
300
+ const count = inGroup.reduce((n, pc) => n + pc.qty, 0);
301
+ say(`laying out ${g.material} ${fmtMm(g.thickness)} mm (${count} pieces)`);
302
+ const stock = stockFor.get(g.group);
303
+ const laid = layoutGroup({
304
+ group: g.group, label: g.label, stock, margin: o.margin, spacing: o.spacing, strict: !service,
305
+ pieces: inGroup.map((pc) => ({ key: pc.file, label: pc.labels[0], drawing: pc.drawing, qty: pc.qty })),
306
+ });
307
+ if (laid.sheets.length > KIT_LIMITS.sheetsPerGroup) {
308
+ throw new Error(`${KIT_OPTIONS_ERROR} ${g.material} ${fmtMm(g.thickness)} mm needs ${laid.sheets.length} sheets — at most ${KIT_LIMITS.sheetsPerGroup} per material; use larger stock or fewer sets`);
309
+ }
310
+ const dir = claim(kitName(`${g.material}-${fmtMm(g.thickness)}mm`), (n) => [`sheets/${n}/`], [g.label]);
311
+ laid.sheets.forEach((sh, i) => sheets.push({
312
+ file: `sheets/${dir}/sheet-${i + 1}-of-${laid.sheets.length}.${cutExt}`,
313
+ group: g, stock, placements: sh.placements, used: sh.used, index: i + 1, count: laid.sheets.length,
314
+ }));
315
+ for (const key of laid.oversized) {
316
+ const pc = inGroup.find((x) => x.file === key);
317
+ const [w, ht] = sizeOf(pc.drawing.bounds);
318
+ oversized.push(`${pc.labels.join(", ")}: ${w.toFixed(1)} × ${ht.toFixed(1)} mm does not fit a ${fmtMm(stock[0])} × ${fmtMm(stock[1])} mm sheet after its ${fmtMm(o.margin)} mm margin (${g.label}) — cut it from ${pc.file}`);
319
+ }
320
+ }
321
+
322
+ // Printed parts, posed for export exactly as the STL/3MF exports pose them. Identical
323
+ // prints merge: the solid hash nominates; volume, bounding box, triangle count and
324
+ // print colour confirm (spec D.4).
325
+ const quality = msg.quality ?? "print";
326
+ const builtPrints = [];
327
+ for (const name of printNames) {
328
+ say(`building ${nameOf(name)}`);
329
+ const solid = posed(name, "export", onProgress);
330
+ const box = solid.boundingBox();
331
+ const display = part.parts[name]?.display;
332
+ const pp = {
333
+ name, label: nameOf(name), fileKey: exportName(name), hash: String(solid._hash ?? ""),
334
+ size: [0, 1, 2].map((i) => box.max[i] - box.min[i]), volume: solid.volume(),
335
+ color: printColor(display), material: typeof display?.material === "string" ? display.material : "",
336
+ };
337
+ if (o.printFormat === "stl") {
338
+ pp.bytes = new Uint8Array(await solid.toSTL({ quality }));
339
+ pp.triangles = new DataView(pp.bytes.buffer, pp.bytes.byteOffset, pp.bytes.byteLength).getUint32(80, true);
340
+ } else {
341
+ const { positions, indices } = solid.toIndexedMesh({ quality });
342
+ pp.mesh = { positions, indices };
343
+ pp.triangles = indices.length / 3;
344
+ }
345
+ builtPrints.push(pp);
346
+ }
347
+ const prints = dedupe(builtPrints, (x) => x.hash, (a, b) => a.triangles === b.triangles && a.color === b.color
348
+ && near(a.volume, b.volume) && a.size.every((x, i) => near(x, b.size[i])))
349
+ .map(({ item, members }) => ({ ...item, labels: members.map((m) => m.label), qty: members.length * o.sets }));
350
+ for (const pp of prints) {
351
+ const sx = pp.qty > 1 ? `-x${pp.qty}` : "";
352
+ const filesOf = (n) => [`print/${n}${sx}.${o.printFormat}`, `print/${n}/`];
353
+ pp.file = filesOf(claim(kitName(pp.fileKey), filesOf, pp.labels))[0];
354
+ if (!pp.bytes) pp.bytes = new Uint8Array(meshTo3MF([{ name: pp.fileKey, ...pp.mesh, color: pp.color }]));
355
+ }
356
+
357
+ // The files (spec D.8): own laser — an SVG per sheet and per piece; service — a
358
+ // cut-only DXF per piece, a -marks.dxf where it has marks, and reference DXF sheets.
359
+ say("writing cut files");
360
+ const title = cleanLabel(part.meta?.title ?? fileBase);
361
+ const files = [];
362
+ const put = (file, text) => files.push({ file, data: strToU8(text) });
363
+ for (const sh of sheets) {
364
+ const doc = { title: `${title} — ${sh.group.label} — sheet ${sh.index} of ${sh.count}`, size: sh.stock, placements: sh.placements };
365
+ put(sh.file, service ? renderDxf(doc, { include: ALL_LAYERS }) : renderSvg(doc));
366
+ }
367
+ for (const pc of pieces) {
368
+ const doc = { title: pc.labels.join(", "), size: sizeOf(pc.drawing.bounds),
369
+ placements: [{ drawing: pc.drawing, at: [0, 0], rotated: false }] };
370
+ if (!service) { put(pc.file, renderSvg(doc)); continue; }
371
+ put(pc.file, renderDxf(doc, { include: ["cut"] }));
372
+ if (pc.marksFile) put(pc.marksFile, renderDxf(doc, { include: ["score", "engrave"] }));
373
+ }
374
+ for (const pp of prints) files.push({ file: pp.file, data: pp.bytes });
375
+
376
+ const controls = collectControls(part.parameters);
377
+ const readme = renderReadme({
378
+ title, destination: o.destination, cutFormat: o.cutFormat, kerf: o.kerf,
379
+ stockNotes: [...groups.values()].map((g) => ({ material: g.material, thickness: g.thickness,
380
+ control: thicknessControlLabel(controls, part.parts[g.first], p, d) })),
381
+ scale: pieces.map((pc) => ({ label: pc.labels.join(", "), nominal: pc.drawing.nominal, drawn: sizeOf(pc.drawing.bounds) })),
382
+ sheets: sheets.map((sh) => ({ file: sh.file, material: sh.group.material, thickness: sh.group.thickness,
383
+ size: sh.stock, pieces: sh.placements.length, used: sh.used })),
384
+ pieces: pieces.map((pc) => ({ file: pc.file, marksFile: pc.marksFile, labels: pc.labels, material: groups.get(pc.group).material,
385
+ thickness: groups.get(pc.group).thickness, size: pc.drawing.nominal, quantity: pc.qty })),
386
+ printed: prints.map((pp) => ({ file: pp.file, labels: pp.labels, quantity: pp.qty })),
387
+ clearances: controls.filter(isClearance)
388
+ .map((c) => ({ label: c.label ?? c.key, value: p[c.key], unit: c.unit ?? "" })),
389
+ settings: Object.keys(p).map((key) => ({ key, value: p[key] })),
390
+ checks: pieces.flatMap((pc) => sheetWarnings(pc.labels.join(", "), pc.facts)),
391
+ oversized, renamed,
392
+ });
393
+ const csv = renderPartsCsv([
394
+ ...pieces.map((pc) => ({ file: pc.file, kind: "sheet", labels: pc.labels, material: groups.get(pc.group).material,
395
+ thickness: groups.get(pc.group).thickness, width: pc.drawing.nominal[0], height: pc.drawing.nominal[1], quantity: pc.qty })),
396
+ ...prints.map((pp) => ({ file: pp.file, kind: "print", labels: pp.labels, material: pp.material, thickness: null,
397
+ width: pp.size[0], height: pp.size[1], quantity: pp.qty })),
398
+ ]);
399
+
400
+ // Every planned file must land under its own name: a path two files shared would
401
+ // silently overwrite one of them, so the count is asserted, and every name is held to
402
+ // the entry pattern once more on the way in.
403
+ const planned = [{ file: "README.txt", data: strToU8(readme) }, { file: "parts.csv", data: strToU8(csv) }, ...files];
404
+ const entries = {};
405
+ for (const { file, data } of planned) {
406
+ if (!KIT_ENTRY_RE.test(file) || file.includes("..")) throw new Error(`cut kit: bad entry name ${JSON.stringify(file)}`);
407
+ entries[file] = data;
408
+ }
409
+ const built = Object.keys(entries).length;
410
+ if (built !== planned.length) throw new Error(`cut kit: planned ${planned.length} files but built ${built}`);
411
+ say("zipping kit");
412
+ return { data: zipSync(entries, { level: 6 }), filename: `${fileBase}-kit.zip` };
413
+ }