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.
- package/docs/AUTHORING-PARTS.md +35 -5
- package/docs/KERNEL-CONTRACT.md +1 -1
- package/package.json +1 -1
- package/src/framework/export/bundle.js +413 -0
- package/src/framework/export/drawing.js +356 -0
- package/src/framework/export/dxf.js +150 -0
- package/src/framework/export/formats.js +142 -0
- package/src/framework/export/layout.js +92 -0
- package/src/framework/export/placement.js +14 -0
- package/src/framework/export/readme.js +103 -0
- package/src/framework/export/svg.js +97 -0
- package/src/framework/export-controller.js +7 -2
- package/src/framework/export-rows.js +34 -0
- package/src/framework/jobs.js +16 -0
- package/src/framework/mount.js +15 -6
- package/src/framework/process/exporters.js +18 -0
- package/src/framework/process/laser/export.js +53 -0
- package/src/framework/stale-touch-guard.js +77 -0
- package/src/framework/viewer.js +5 -0
- package/src/index.js +4 -0
- package/types/index.d.ts +72 -5
- package/types/testing.d.ts +7 -3
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -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
|
-
|
|
3002
|
-
the
|
|
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
|
package/docs/KERNEL-CONTRACT.md
CHANGED
|
@@ -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
|
@@ -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
|
+
}
|