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.
@@ -0,0 +1,92 @@
1
+ // The kit's sheet layout: one stock group's pieces packed onto sheets of one stock size.
2
+ // Deterministic first-fit decreasing-height SHELVES over each piece's bounding box of ALL
3
+ // its layers, kerf included — a stray engrave or score mark must never land on a
4
+ // neighbour — turned 0° or 90°, `margin` in from every sheet edge and `spacing` apart,
5
+ // so no two pieces share a cut line. v1 packs rectangles only: no nesting into holes,
6
+ // grain ignored; the README says so (design spec D.5).
7
+ //
8
+ // Coordinates are the sheet frame: mm, y up, origin at the sheet's corner. A placement's
9
+ // `at` is where the (turned) drawing's bounds.min lands (contract §5.6).
10
+ import { fmtMm } from "../sheet/constants.js";
11
+ import { KIT_OPTIONS_ERROR } from "./formats.js";
12
+
13
+ const EPS = 1e-9;
14
+ const sizeOf = ({ min, max }) => [max[0] - min[0], max[1] - min[1]];
15
+ const byKey = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
16
+
17
+ // Throws when `margin` leaves a stock sheet with no inside at all — the options' fault,
18
+ // for either destination, not a "stock too small" quoting a negative size, nor a service
19
+ // kit with every piece oversized. `label` is the group's `${material} ${fmtMm(t)} mm`.
20
+ // The options allow a 50 mm margin and a 10 mm stock side, which together can trigger
21
+ // this. Exported so buildBundle can check every stock group right after resolveStock —
22
+ // before any piece is drawn (spec D.3: a bad option must not wait behind a build) —
23
+ // rather than only once a group finally reaches layoutGroup below, which calls this too
24
+ // so a caller that skips the early check (or a future one) still gets it.
25
+ export function marginFitsStock(stock, margin, label) {
26
+ const [W, H] = stock;
27
+ const innerW = W - 2 * margin, innerH = H - 2 * margin;
28
+ if (innerW <= EPS || innerH <= EPS)
29
+ throw new Error(`${KIT_OPTIONS_ERROR} a ${fmtMm(W)} × ${fmtMm(H)} mm sheet has no room inside a ${fmtMm(margin)} mm margin on every side (${label}) — lower margin or use larger stock`);
30
+ return [innerW, innerH];
31
+ }
32
+
33
+ // `pieces`: [{ key, label, drawing, qty }] — `key` names the piece in `oversized` and
34
+ // breaks sort ties; `label` is what an error quotes. The `label` argument is the group's
35
+ // `${material} ${fmtMm(t)} mm`; `group` is accepted and not needed here. `strict` (own
36
+ // laser): a piece that fits the sheet in neither orientation is an options error;
37
+ // otherwise (a cutting service, whose sheets are reference only) its key is listed in
38
+ // `oversized` and it is left out. The sheets-per-group cap belongs to the caller — this
39
+ // only packs.
40
+ export function layoutGroup({ label, pieces, stock, margin, spacing, strict }) {
41
+ const [W, H] = stock;
42
+ const [innerW, innerH] = marginFitsStock(stock, margin, label);
43
+ const items = [];
44
+ const oversized = [];
45
+ for (const pc of pieces) {
46
+ const [w, h] = sizeOf(pc.drawing.bounds);
47
+ const fits = [];
48
+ if (w <= innerW + EPS && h <= innerH + EPS) fits.push({ rotated: false, w, h });
49
+ if (h <= innerW + EPS && w <= innerH + EPS) fits.push({ rotated: true, w: h, h: w });
50
+ if (fits.length === 0) {
51
+ if (strict)
52
+ throw new Error(`${KIT_OPTIONS_ERROR} stock too small — "${pc.label}" is ${w.toFixed(1)} × ${h.toFixed(1)} mm; a ${fmtMm(W)} × ${fmtMm(H)} mm sheet leaves ${fmtMm(innerW)} × ${fmtMm(innerH)} after its ${fmtMm(margin)} mm margin (${label})`);
53
+ oversized.push(pc.key);
54
+ continue;
55
+ }
56
+ // The orientation with the smaller height packs shelves tighter; 0° wins a tie.
57
+ const pick = fits.length === 2 && fits[1].h < fits[0].h - EPS ? fits[1] : fits[0];
58
+ for (let i = 0; i < pc.qty; i++) items.push({ key: pc.key, drawing: pc.drawing, ...pick });
59
+ }
60
+ items.sort((a, b) => b.h - a.h || b.w - a.w || byKey(a.key, b.key));
61
+
62
+ // Each sheet: shelves bottom-up ({ y, h, x: used width, n: pieces }), `top` = the last
63
+ // shelf's upper edge, `area` = placed bounding-box area.
64
+ const sheets = [];
65
+ const place = (sh, shelf, it) => {
66
+ const x = shelf.n ? shelf.x + spacing : 0;
67
+ sh.placements.push({ drawing: it.drawing, at: [margin + x, margin + shelf.y], rotated: it.rotated });
68
+ shelf.x = x + it.w; shelf.n++; sh.area += it.w * it.h;
69
+ };
70
+ for (const it of items) {
71
+ // 1. the first shelf, on any sheet, with room left along it
72
+ const onShelf = (() => {
73
+ for (const sh of sheets) for (const shelf of sh.shelves) {
74
+ const x = shelf.n ? shelf.x + spacing : 0;
75
+ if (x + it.w <= innerW + EPS && it.h <= shelf.h + EPS) return [sh, shelf];
76
+ }
77
+ return null;
78
+ })();
79
+ if (onShelf) { place(onShelf[0], onShelf[1], it); continue; }
80
+ // 2. a new shelf on the first sheet with height left; 3. else a new sheet
81
+ let sh = sheets.find((s) => s.top + spacing + it.h <= innerH + EPS);
82
+ if (!sh) { sh = { placements: [], shelves: [], top: -spacing, area: 0 }; sheets.push(sh); }
83
+ const shelf = { y: sh.top + spacing, h: it.h, x: 0, n: 0 };
84
+ sh.shelves.push(shelf);
85
+ sh.top = shelf.y + it.h;
86
+ place(sh, shelf, it);
87
+ }
88
+ return {
89
+ sheets: sheets.map((sh) => ({ placements: sh.placements, used: (100 * sh.area) / (W * H) })),
90
+ oversized,
91
+ };
92
+ }
@@ -0,0 +1,14 @@
1
+ // Shared by the kit's SVG and DXF writers: one Drawing's frame → the sheet frame for one
2
+ // placement. IMPORT-FREE, like formats.js — svg.js and dxf.js are each a "paper-free leaf
3
+ // (arc-math.js only)" on their own, and this tiny third leaf is what lets them share the
4
+ // placement math without either reaching into the other or into drawing.js (which reaches
5
+ // paper).
6
+
7
+ // Translate so the drawing's bounds.min lands on `at`, after an optional 90° CCW turn (a
8
+ // proper rotation: arcs keep their sense).
9
+ export function placer({ drawing, at, rotated }) {
10
+ const { min, max } = drawing.bounds;
11
+ if (!rotated) return ([x, y]) => [x - min[0] + at[0], y - min[1] + at[1]];
12
+ // (x, y) → (−y, x); the turned bounds' min corner is (−max.y, min.x)
13
+ return ([x, y]) => [max[1] - y + at[0], x - min[0] + at[1]];
14
+ }
@@ -0,0 +1,103 @@
1
+ // The kit's two plain-text files: README.txt, read by a person standing at the laser,
2
+ // and parts.csv, opened in a spreadsheet (spec D.9). Pure string building over a model
3
+ // that export/bundle.js assembles, so both are testable without a kernel.
4
+ //
5
+ // Neither carries a timestamp — the same forge and options always write the same bytes
6
+ // — and neither is Markdown: every label is an author string, and plain text is the one
7
+ // format in which nothing an author writes renders as anything.
8
+ import { fmtMm } from "../sheet/constants.js";
9
+
10
+ export const README_HEADINGS = Object.freeze([
11
+ "BEFORE YOU CUT", "SHEETS", "PIECES", "PRINTED PARTS", "CLEARANCES (design fit, not kerf)",
12
+ "SETTINGS", "CHECKS", "OVERSIZED PIECES", "RENAMED FILES", "LIMITS",
13
+ ]);
14
+ export const CSV_HEADER = "file,kind,labels,material,thickness_mm,width_mm,height_mm,quantity";
15
+
16
+ const SVG_LEGEND = "Colours: engrave = filled black #000000, score = blue #0000FF, cut = red #FF0000 (inner cuts come before outer cuts).";
17
+ const DXF_LEGEND = "DXF layers: CUT (colour 1), SCORE (colour 5), ENGRAVE (colour 7) — set ENGRAVE to fill; R12 has no hatch.";
18
+ const LIMITS_LINE = "Layout packs bounding boxes only: no nesting into holes, and grain direction is ignored.";
19
+
20
+ // C0 controls, DEL and C1 controls out of an author string, so a label can never ring a
21
+ // bell, move a cursor or smuggle a line break into a table row — and the Unicode line
22
+ // and paragraph separators (U+2028/U+2029, a line break in many editors) and the bidi
23
+ // embeddings, overrides and isolates (U+202A–U+202E, U+2066–U+2069), which reorder the
24
+ // text around them, for the same reason.
25
+ export const cleanLabel = (s) => String(s).replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g, "");
26
+
27
+ // One RFC 4180 cell. A cell starting with = + - or @ is a formula to Excel, Numbers and
28
+ // Sheets, so it gets a leading ' first — before quoting, so no quote can be closed
29
+ // around it.
30
+ export function csvCell(v) {
31
+ let s = v == null ? "" : String(v);
32
+ if (/^[=+\-@]/.test(s)) s = `'${s}`;
33
+ return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
34
+ }
35
+
36
+ const clip = (s, n = 200) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
37
+
38
+ // A parameter value the way one README line can hold it: numbers and booleans as
39
+ // written, strings JSON-quoted (so "" and " " stay visible), bytes by size, anything
40
+ // else as clipped JSON.
41
+ function settingValue(v) {
42
+ if (v === null || v === undefined || typeof v === "number" || typeof v === "boolean") return String(v);
43
+ if (typeof v === "string") return JSON.stringify(clip(cleanLabel(v)));
44
+ if (v instanceof ArrayBuffer || ArrayBuffer.isView(v)) return `<${v.byteLength} bytes>`;
45
+ try { return clip(JSON.stringify(v) ?? String(v)); } catch { return "<unprintable>"; }
46
+ }
47
+
48
+ const table = (header, rows) => (rows.length ? [header.join(" | "), ...rows.map((r) => r.join(" | "))] : []);
49
+ const mm2 = ([w, h]) => `${w.toFixed(2)} × ${h.toFixed(2)} mm`;
50
+ const labelsOf = (labels) => labels.map(cleanLabel).join(", ");
51
+
52
+ export function renderReadme(m) {
53
+ const k = m.kerf;
54
+ const kerfed = k > 0;
55
+ // A cutting service gets each piece's score and engrave lines in a separate
56
+ // -marks.dxf, which neither table lists — so the files are named here, or a service
57
+ // order built from the cut files alone silently leaves the marks off.
58
+ const marksFiles = m.pieces.map((pc) => pc.marksFile).filter(Boolean);
59
+ const body = {
60
+ "BEFORE YOU CUT": [
61
+ ...m.stockNotes.map((g) =>
62
+ `This kit assumes ${g.thickness.toFixed(2)} mm ${cleanLabel(g.material)} — if yours differs, change "${cleanLabel(g.control ?? "the sheet thickness setting")}" and re-download.`),
63
+ kerfed
64
+ ? `Kerf: ${k.toFixed(2)} mm applied to the cut lines only. Set kerf in ONE place — these download options, or your laser software / cutting service — never both.`
65
+ : "Kerf: none applied — your laser software or cutting service must compensate.",
66
+ ...(kerfed ? [] : ["To have the files adjusted instead, download again with a kerf set, and turn compensation off in your laser software or service."]),
67
+ "Scale check — measure one cut piece against its line:",
68
+ ...m.scale.map((s) => `${cleanLabel(s.label)}: nominal ${s.nominal[0].toFixed(2)} × ${s.nominal[1].toFixed(2)} mm; this file draws ${
69
+ kerfed ? `${s.drawn[0].toFixed(2)} × ${s.drawn[1].toFixed(2)} with ${k.toFixed(2)} mm kerf` : "the same (no kerf applied)"}`),
70
+ m.cutFormat === "dxf" ? DXF_LEGEND : SVG_LEGEND,
71
+ ...(marksFiles.length ? [`Score and engrave marks are in their own files, not in the cut files — send them with the cut files if you want the marks made: ${marksFiles.join(", ")}.`] : []),
72
+ // A service kit whose every piece is oversized writes no sheets/ at all.
73
+ ...(m.destination === "service" && m.sheets.length ? ["The sheets in sheets/ are for reference only — a cutting service lays out the pieces itself."] : []),
74
+ "Test-cut one joint first.",
75
+ ],
76
+ SHEETS: table(["file", "material", "thickness", "size", "pieces", "used"], m.sheets.map((s) => [
77
+ s.file, cleanLabel(s.material), `${s.thickness.toFixed(2)} mm`, `${fmtMm(s.size[0])} × ${fmtMm(s.size[1])} mm`,
78
+ String(s.pieces), `${Math.round(s.used)}%`])),
79
+ PIECES: table(["file", "labels", "material", "thickness", "size", "quantity"], m.pieces.map((pc) => [
80
+ pc.file, labelsOf(pc.labels), cleanLabel(pc.material), `${pc.thickness.toFixed(2)} mm`, mm2(pc.size), String(pc.quantity)])),
81
+ "PRINTED PARTS": table(["file", "labels", "quantity"], m.printed.map((pp) => [pp.file, labelsOf(pp.labels), String(pp.quantity)])),
82
+ "CLEARANCES (design fit, not kerf)": m.clearances.map((c) =>
83
+ `${cleanLabel(c.label)}: ${settingValue(c.value)}${c.unit ? ` ${cleanLabel(c.unit)}` : ""}`),
84
+ SETTINGS: m.settings.map((s) => `${cleanLabel(s.key)} = ${settingValue(s.value)}`),
85
+ CHECKS: m.checks.length ? m.checks.map(cleanLabel) : ["none"],
86
+ "OVERSIZED PIECES": m.destination === "service" ? m.oversized.map(cleanLabel) : [],
87
+ "RENAMED FILES": m.renamed.map(cleanLabel),
88
+ LIMITS: [LIMITS_LINE],
89
+ };
90
+ const out = [`${cleanLabel(m.title)} — cut & print kit`, ""];
91
+ for (const heading of README_HEADINGS) {
92
+ if (body[heading].length) out.push(heading, ...body[heading], "");
93
+ }
94
+ return out.join("\n");
95
+ }
96
+
97
+ export function renderPartsCsv(rows) {
98
+ const line = (cells) => `${cells.map(csvCell).join(",")}\r\n`;
99
+ return `${CSV_HEADER}\r\n${rows.map((r) => line([
100
+ r.file, r.kind, r.labels.map(cleanLabel).join("; "), cleanLabel(r.material ?? ""),
101
+ r.thickness == null ? "" : r.thickness.toFixed(2), r.width.toFixed(2), r.height.toFixed(2), String(r.quantity),
102
+ ])).join("")}`;
103
+ }
@@ -0,0 +1,97 @@
1
+ // The kit's SVG writer: a SheetDoc (placed Drawings on one sheet) → one SVG string,
2
+ // built by hand — paper.js cannot export SVG in a worker (there is no DOM to serialize
3
+ // through), and this module stays a paper-free leaf (arc-math.js only).
4
+ //
5
+ // Written for laser software, which reads styling far more literally than a browser:
6
+ // - Size is physical: width/height in mm, viewBox in mm, so 1 user unit = 1 mm.
7
+ // - y is flipped IN THE COORDINATES (svgY = H − y): the Drawing is y-up, SVG is y-down,
8
+ // and a transform on the root is exactly the kind of inherited state a laser importer
9
+ // may drop. The flip reverses every arc's sense, hence the sweep flag below.
10
+ // - Every <path> carries its whole style. Nothing on the root or a group: inherited
11
+ // style is where importers disagree. Cut and score paths carry fill="none" — an
12
+ // unfilled SVG path defaults to BLACK FILL, which Glowforge reads as an engrave.
13
+ // - Colours are LightBurn's convention (SVG_COLORS); groups are written in cut order.
14
+ // - The only text is the escaped <title>. Escaping is hygiene, not the security
15
+ // boundary: a hostile part shares the worker with this writer and can post its own
16
+ // bytes — the host's download allowlist is the boundary (design spec E.1).
17
+ import { arcCenterAndSweep } from "../geometry/arc-math.js";
18
+ import { placer } from "./placement.js";
19
+
20
+ export const SVG_COLORS = Object.freeze({ engrave: "#000000", score: "#0000FF", "cut-inner": "#FF0000", "cut-outer": "#FF0000" });
21
+ export const SVG_STROKE_MM = 0.025; // a hairline: laser software cuts it as a vector
22
+ // The group order is the cut order — the same list as drawing.js's LAYER_ORDER, which
23
+ // this leaf cannot import (drawing.js reaches paper); test/kit-svg.test.js pins them equal.
24
+ const ORDER = Object.keys(SVG_COLORS);
25
+
26
+ const num = (x) => { const v = Number(x.toFixed(4)); return String(v === 0 ? 0 : v); }; // -0 → "0"
27
+ const escapeXml = (s) => String(s)
28
+ .replace(/[\u0000-\u001f\u007f]/g, "")
29
+ .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
30
+ .replace(/"/g, "&quot;").replace(/'/g, "&apos;");
31
+
32
+ // Every IR arc is written as ceil(|dA| / 90°) equal pieces about the IR's own centre.
33
+ // An `A` command carries no centre: a reader rebuilds it from the radius and the two
34
+ // ends (SVG 1.1 F.6.5), at sqrt(r² − (chord/2)²) from the chord — and at 180° that
35
+ // root is of a number near zero, so the 4-decimal rounding of r and both ends moves the
36
+ // centre by ~sqrt(2·r·1e-4): a quarter of a millimetre-scale error on an ordinary hole,
37
+ // and exactly what the refit makes (a faceted circle comes back as two 180° arcs). At
38
+ // 90° or less the rebuild is well conditioned and lands within ~1e-4 mm. The pieces'
39
+ // inner ends come from the centre and angle, never from a rounded neighbour; the last
40
+ // one is the IR's own `to`.
41
+ const QUARTER = Math.PI / 2;
42
+ function arcCommands(from, s, P) {
43
+ const g = arcCenterAndSweep(from, s.via, s.to); // drawing frame: the turn keeps dA's sign
44
+ if (!g) return ` L${P(s.to)}`; // collinear "arc": a straight line
45
+ const n = Math.max(1, Math.ceil(Math.abs(g.dA) / QUARTER - 1e-9));
46
+ const a0 = Math.atan2(from[1] - g.center[1], from[0] - g.center[0]);
47
+ const step = g.dA / n;
48
+ let d = "";
49
+ for (let i = 1; i <= n; i++) {
50
+ const t = a0 + step * i;
51
+ const to = i === n ? s.to : [g.center[0] + g.r * Math.cos(t), g.center[1] + g.r * Math.sin(t)];
52
+ d += ` A${num(g.r)} ${num(g.r)} 0 ${Math.abs(step) > Math.PI ? 1 : 0} ${step > 0 ? 0 : 1} ${P(to)}`;
53
+ }
54
+ return d;
55
+ }
56
+
57
+ function pathData(path, place, H) {
58
+ const P = (p) => { const [x, y] = place(p); return `${num(x)} ${num(H - y)}`; };
59
+ let d = `M${P(path.start)}`;
60
+ let from = path.start;
61
+ for (const s of path.segments) {
62
+ if (s.c1) d += ` C${P(s.c1)} ${P(s.c2)} ${P(s.to)}`;
63
+ else if (s.via) d += arcCommands(from, s, P);
64
+ else d += ` L${P(s.to)}`;
65
+ from = s.to;
66
+ }
67
+ return path.closed ? `${d} Z` : d;
68
+ }
69
+
70
+ export function renderSvg(doc) {
71
+ const [W, H] = doc.size;
72
+ const lines = [
73
+ '<?xml version="1.0" encoding="UTF-8"?>',
74
+ `<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="${num(W)}mm" height="${num(H)}mm" viewBox="0 0 ${num(W)} ${num(H)}">`,
75
+ `<title>${escapeXml(doc.title)}</title>`,
76
+ ];
77
+ for (const id of ORDER) {
78
+ const paths = [];
79
+ for (const placement of doc.placements) {
80
+ const layer = placement.drawing.layers.find((l) => l.id === id);
81
+ if (!layer || layer.paths.length === 0) continue;
82
+ const place = placer(placement);
83
+ if (id === "engrave") {
84
+ // one filled path per piece: all its engrave rings, even-odd, so a glyph's
85
+ // counter stays open whatever the rings' winding
86
+ const d = layer.paths.map((p) => pathData(p, place, H)).join(" ");
87
+ paths.push(`<path d="${d}" fill="${SVG_COLORS.engrave}" fill-rule="evenodd" stroke="none"/>`);
88
+ } else {
89
+ for (const p of layer.paths)
90
+ paths.push(`<path d="${pathData(p, place, H)}" fill="none" stroke="${SVG_COLORS[id]}" stroke-width="${SVG_STROKE_MM}"/>`);
91
+ }
92
+ }
93
+ if (paths.length) lines.push(`<g id="${id}">`, ...paths, "</g>");
94
+ }
95
+ lines.push("</svg>");
96
+ return `${lines.join("\n")}\n`;
97
+ }
@@ -39,13 +39,18 @@ export function createExportController({ send, currentView, title, defaultBacken
39
39
  // other's Promise.
40
40
  let nextWarmId = 1;
41
41
 
42
- function exportParts({ parts, format, quality = "print", onProgress } = {}) {
42
+ // `options` are the cut & print kit's download options (format "bundle"). They ride
43
+ // the message for any format when given — the worker's other export branches never
44
+ // read them — and are left off entirely when not, so every other export's message is
45
+ // exactly what it always was.
46
+ function exportParts({ parts, format, quality = "print", onProgress, options } = {}) {
43
47
  const jobId = `export-${controller}-${nextId++}`;
44
48
  const type = `export-${format}`;
45
49
  const backend = backendForFormat(format, defaultBackend);
46
50
  return new Promise((resolve, reject) => {
47
51
  pending.set(jobId, { resolve, reject, onProgress });
48
- send({ type, jobId, parts, view: currentView(), params: currentParams(), name: title(), quality }, backend);
52
+ send({ type, jobId, parts, view: currentView(), params: currentParams(), name: title(), quality,
53
+ ...(options !== undefined ? { options } : {}) }, backend);
49
54
  });
50
55
  }
51
56
 
@@ -0,0 +1,34 @@
1
+ // The two lists an embedder's export UI is drawn from — the exportable sub-parts and
2
+ // the formats — behind the mount handle's listExportableParts()/listExportFormats().
3
+ //
4
+ // Main-thread only: mount.js imports it. Deliberately NOT part of export-select.js,
5
+ // which jobs.js imports at worker boot — export/formats.js must stay out of the
6
+ // worker's eager closure (test/worker-layering.test.js).
7
+ import { exportablePartNames, partLabel } from "./export-select.js";
8
+ import { resolveDerived } from "./derive.js";
9
+ import { sheetMeta } from "./sheet/constants.js";
10
+ import { EXPORT_FORMATS } from "./export/formats.js";
11
+
12
+ // A row for every exportable, enabled sub-part, in definition order. A sheet part's row
13
+ // adds `sheet: { process, material, thickness, group }`, evaluated here at the live
14
+ // params the way enabled() already is, so a host can tag it ("Sheet · …") and name its
15
+ // stock group before asking for a kit. The key is OMITTED, never null, when it cannot
16
+ // be evaluated — not a sheet, a material or thickness that throws, or a derive() that
17
+ // throws (nothing about any part's stock is trustworthy then). The kit recomputes its
18
+ // groups in the worker regardless, so a missing tag costs a label, never a wrong file.
19
+ export function exportableRows(part, params) {
20
+ const p = { ...part.defaults, ...params };
21
+ let d = null;
22
+ let derived = true;
23
+ try { d = resolveDerived(part, p); } catch { derived = false; }
24
+ return exportablePartNames(part, params).map((name) => {
25
+ const row = { name, label: partLabel(part, name) };
26
+ const s = derived ? sheetMeta(part.parts[name], p, d) : null;
27
+ if (s) row.sheet = { process: s.process, material: s.material, thickness: s.thickness, group: s.group };
28
+ return row;
29
+ });
30
+ }
31
+
32
+ // What exportParts can write in this version, as fresh copies — a host that annotates
33
+ // or sorts its list can never edit the table every later call reads.
34
+ export const exportFormatList = () => EXPORT_FORMATS.map((f) => ({ ...f }));
@@ -31,6 +31,12 @@ const loadInspect = () => Promise.all([
31
31
  import("./oracle/verify.js"),
32
32
  ]);
33
33
 
34
+ // The cut & print kit loads the same way, on the first export-bundle job: its writers,
35
+ // layout and README (export/*) and the process exporters behind them are dead weight to
36
+ // a session that never downloads a kit. test/worker-layering.test.js keeps every one of
37
+ // them out of worker boot, and test/kit-layering.test.js keeps the kit off ./oracle/*.
38
+ const loadBundle = () => import("./export/bundle.js");
39
+
34
40
  // HOST JOBS — the extension seam. A host registers its own job types with
35
41
  // `runWorker(part, { jobs: { <type>: handler } })`, threaded here as `opts.jobs`, and
36
42
  // a message whose type matches no built-in below is handed to that handler with the
@@ -47,6 +53,8 @@ const loadInspect = () => Promise.all([
47
53
  // { type:"generate", subparts, view, params } → { type:"meshes", meshes, ms }
48
54
  // { type:"export-stl", view, params } → { type:"download-parts", ext, mime, parts }
49
55
  // { type:"export-step", view, params } → { type:"download", data, filename, mime }
56
+ // { type:"export-bundle", view, params, parts, options }
57
+ // → { type:"download", data, filename, mime } (the cut & print kit, a ZIP)
50
58
  // A generate also accepts `opts.isStale` — a caller-supplied predicate checked at each
51
59
  // sub-part boundary — and answers { type:"superseded" } instead of `meshes` when it
52
60
  // stops early (a build that ended without meshes, not an error; see KERNEL-CONTRACT.md).
@@ -455,6 +463,14 @@ export async function handle(kernel, part, msg, post, opts = {}) {
455
463
  onProgress("writing 3MF file");
456
464
  const data = meshTo3MF(meshes);
457
465
  post({ type: "download", data, filename: `${fileBase}.3mf`, mime: "model/3mf", jobId: msg.jobId }, [bufferOf(data)]);
466
+ } else if (msg.type === "export-bundle") {
467
+ // The cut & print kit: sheet parts drawn flat from their 2-D declaration and laid
468
+ // out on the user's stock, printed parts as STL/3MF, a README — one ZIP. Every
469
+ // download-option problem throws a "cut kit options:" error, posted verbatim by the
470
+ // catch below like any other failed job.
471
+ const { buildBundle } = await loadBundle();
472
+ const { data, filename } = await buildBundle({ kernel, part, msg, p, d, posed, label, exportName, names: selected(), onProgress, fileBase });
473
+ post({ type: "download", data, filename, mime: "application/zip", jobId: msg.jobId }, [bufferOf(data)]);
458
474
  } else if (msg.type === "warm-kernel") {
459
475
  // Deliberately empty. worker.js awaits kernelFor() before calling handle(),
460
476
  // so REACHING this branch is the whole result: the backend this job was
@@ -27,7 +27,7 @@ import { createStatusUi } from "./status-ui.js";
27
27
  import { createViewTabs } from "./view-tabs.js";
28
28
  import { attachPickToggle, attachHoverLabels, attachPicker, formatSelection } from "./selection/index.js";
29
29
  import { createPickRequestClient, resolvePickServerUrl, PICK_SERVER_DEFAULT_URL } from "./pick-request/index.js";
30
- import { exportablePartNames, partLabel } from "./export-select.js";
30
+ import { exportableRows, exportFormatList } from "./export-rows.js";
31
31
  import { createExportController, backendForFormat } from "./export-controller.js";
32
32
  import { createCaptureBuild } from "./capture-build.js";
33
33
  import { attachAnimationControls } from "./animation-controls.js";
@@ -68,7 +68,7 @@ const IMPORT_MESH_BROKEN_MESSAGE = "STEP import tessellation failed to satisfy t
68
68
  // carries the worker's own error text. See the correlated "error" case below.
69
69
  const importTessellateFailedMessage = (workerMessage) => `STEP import tessellation failed — ${workerMessage}`;
70
70
 
71
- export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, warmExportKernel, setHostPane, setRailLayout, animation, getView, setView, captureView, attachTooltips, measure, annotate, projection, pickMarker, getPanelState, getPanelErrors, renderMode, environment, declaresMaterials, renderViews }) {
71
+ export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, listExportFormats, exportParts, warmExportKernel, setHostPane, setRailLayout, animation, getView, setView, captureView, attachTooltips, measure, annotate, projection, pickMarker, getPanelState, getPanelErrors, renderMode, environment, declaresMaterials, renderViews }) {
72
72
  return {
73
73
  ready, dispose, setParams,
74
74
  // Part-declared animation playback (spec 2026-08-02): animations are
@@ -141,6 +141,10 @@ export function makeHandle({ ready, dispose, viewer, setParams, listExportablePa
141
141
  // say "the 3D view ran out of memory" instead of showing a dead canvas.
142
142
  onContextLost: (listener) => viewer.onContextLost(listener),
143
143
  listExportableParts,
144
+ // What exportParts can write, as { id, label, ext, mime, needsSheet } — the cut &
145
+ // print kit ("bundle") needs a sheet part checked. A makeHandle caller that wires
146
+ // no export (a test) lists none.
147
+ listExportFormats: listExportFormats ?? (() => []),
144
148
  exportParts,
145
149
  // Pay the exact kernel's cold boot ahead of an export. STEP is pinned to
146
150
  // OCCT, whose ~11 MB WASM loads on its first job, so a Manifold-previewed
@@ -251,8 +255,11 @@ function createCleanupStack() {
251
255
  // // framing (live camera pose + viewport aspect) at the
252
256
  // // given long-edge resolution → JPEG data URL, or null
253
257
  // // when disposed / nothing built yet
254
- // runtime.listExportableParts(); // [{ name, label }] — every exportable sub-part,
255
- // // independent of the active view (for an embedder-drawn export UI)
258
+ // runtime.listExportableParts(); // [{ name, label, sheet? }] — every exportable sub-part,
259
+ // // independent of the active view (for an embedder-drawn export UI);
260
+ // // a sheet part's row adds sheet: { process, material, thickness, group }
261
+ // runtime.listExportFormats(); // [{ id, label, ext, mime, needsSheet }] — what exportParts writes;
262
+ // // format "bundle" (the cut & print kit) also takes `options`
256
263
  // await runtime.exportParts({ parts: ["base"], format: "stl", onProgress });
257
264
  // // headless export of a chosen subset; resolves when the file is
258
265
  // // written (handed to your onDownload sink, or downloaded directly
@@ -1389,8 +1396,10 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
1389
1396
  captureView,
1390
1397
  getPanelState: () => panelRef?.getState() ?? {},
1391
1398
  getPanelErrors: () => panelRef?.errors() ?? [],
1392
- listExportableParts: () =>
1393
- exportablePartNames(part, params).map((name) => ({ name, label: partLabel(part, name) })),
1399
+ // A sheet part's row carries its stock, evaluated here on the main thread at the
1400
+ // live params the way enabled() is, and omitted when it cannot be (export-rows.js).
1401
+ listExportableParts: () => exportableRows(part, params),
1402
+ listExportFormats: exportFormatList,
1394
1403
  exportParts: (opts) => exportCtl.exportParts(opts),
1395
1404
  warmExportKernel: () => exportCtl.warmKernel(),
1396
1405
  animation: animCtl?.runtime ?? null,
@@ -0,0 +1,18 @@
1
+ // One lazy exporter loader per process id, in the registry's order. Besides
2
+ // process/registry.js this is the ONLY module allowed to import process/<id>/*
3
+ // (test/sheet-layering.test.js), and it does so only through literal dynamic imports:
4
+ // an exporter reaches the drawing stage and so paper, which lint, the oracle and
5
+ // partforge/geometry must never load (test/kit-export-guards.test.js).
6
+ //
7
+ // Adding a process: one folder, one line in registry.js, one line here —
8
+ // test/kit-export-guards.test.js fails until the two id lists agree.
9
+ export const EXPORTER_LOADERS = Object.freeze({
10
+ laser: () => import("./laser/export.js"),
11
+ });
12
+
13
+ // The process's ProcessExporter ({ drawing(s, o, k, ctx) }), loaded on first use.
14
+ export async function loadExporter(id) {
15
+ const load = Object.hasOwn(EXPORTER_LOADERS, id) ? EXPORTER_LOADERS[id] : null;
16
+ if (!load) throw new Error(`no exporter for process "${id}"`);
17
+ return (await load()).default;
18
+ }
@@ -0,0 +1,53 @@
1
+ // The laser process's exporter: one resolved sheet piece → one Drawing (contract §5.5).
2
+ // Loaded only through process/exporters.js's dynamic import — it reaches the drawing
3
+ // stage and so paper, which the laser DESCRIPTOR (read by lint and the oracle) must never
4
+ // do; that is why a process is split into descriptor.js and export.js.
5
+ //
6
+ // Layers, from the piece's canonical-frame declaration (the pose is never read):
7
+ // cut-outer / cut-inner — the profile's outlines / every ring they enclose (holes, and
8
+ // an island in a hole), refit, then kerf, in cut order: every
9
+ // ring before any ring that encloses it (drawing.js's cutRings)
10
+ // score — two-point lines as open paths, score shapes' every ring closed
11
+ // engrave — the engrave shape's rings, a filled region
12
+ // Kerf offsets the cut layer ONLY: a score or engrave line is burned along its centre, so
13
+ // widening the kerf never moves it.
14
+ import { refitRing, ringsOf, cutRings, applyKerf, drawingBounds, LAYER_ORDER } from "../../export/drawing.js";
15
+ import { KIT_DEFAULTS } from "../../export/formats.js";
16
+
17
+ const closedPath = (ring) => ({ start: ring.start, segments: ring.segments, closed: true });
18
+ const refitRegions = (regions) => regions.map((rg) => ({ outer: refitRing(rg.outer), holes: rg.holes.map(refitRing) }));
19
+ const allRings = (regions) => { const r = ringsOf(regions); return [...r.outer, ...r.holes]; };
20
+
21
+ // `s` is a ResolvedSheet (sheet/resolve.js), `o` validated KitOptions (only `kerf` is
22
+ // read), `k` the geometry kernel (unused by laser: every layer is already a Shape2D or
23
+ // plain data), `ctx` = { label, facts? } — facts supplies the narrowest gap a kerf error
24
+ // names.
25
+ function drawing(s, o, k, ctx = {}) {
26
+ const label = ctx.label ?? s.label ?? "sheet part";
27
+ const kerf = o?.kerf ?? KIT_DEFAULTS.kerf;
28
+ const nominal = refitRegions(s.profile.toContours());
29
+ if (nominal.length === 0) throw new Error(`cut kit: "${label}" has an empty profile — nothing to cut`);
30
+ // A capped gap is the search ceiling, not a measured opening — don't name it.
31
+ const gap = ctx.facts && !ctx.facts.gapCapped && Number.isFinite(ctx.facts.gap) ? ctx.facts.gap : null;
32
+ const cut = cutRings(applyKerf(nominal, kerf, { label, gap }));
33
+
34
+ const byId = {
35
+ engrave: s.engrave ? allRings(refitRegions(s.engrave.toContours())).map(closedPath) : [],
36
+ score: [
37
+ ...s.score.lines.map(([a, b]) => ({ start: [a[0], a[1]], segments: [{ to: [b[0], b[1]] }], closed: false })),
38
+ ...s.score.shapes.flatMap((shape) => allRings(refitRegions(shape.toContours())).map(closedPath)),
39
+ ],
40
+ "cut-inner": cut.inner.map(closedPath),
41
+ "cut-outer": cut.outer.map(closedPath),
42
+ };
43
+ const layers = LAYER_ORDER.filter((id) => byId[id].length > 0).map((id) => ({ id, paths: byId[id] }));
44
+ const nb = drawingBounds([{ id: "cut-outer", paths: ringsOf(nominal).outer.map(closedPath) }]);
45
+ return {
46
+ layers,
47
+ bounds: drawingBounds(layers),
48
+ nominal: [nb.max[0] - nb.min[0], nb.max[1] - nb.min[1]],
49
+ kerf,
50
+ };
51
+ }
52
+
53
+ export default { drawing };
@@ -0,0 +1,77 @@
1
+ // src/framework/stale-touch-guard.js
2
+ // Keeps the camera controls from holding onto a finger that has already left
3
+ // the screen.
4
+ //
5
+ // OrbitControls tracks every pointer that goes down on the canvas and forgets
6
+ // one only on its pointerup or pointercancel. When the page is embedded in a
7
+ // host that runs its own gestures (a chat app's WKWebView, which scrolls the
8
+ // conversation on a vertical drag), the host can take a drag over and the page
9
+ // never hears that finger lift. The controls then keep it as down forever: the
10
+ // next one-finger drag reads as a two-finger pinch (it zooms), and with two
11
+ // stuck, no drag moves the camera at all.
12
+ //
13
+ // Two measures, both on the canvas's document and both passive:
14
+ //
15
+ // 1. Do-nothing touchstart/touchend/touchcancel listeners. WebKit on iOS hands
16
+ // a touch to the page only where the page listens for TOUCH events, and the
17
+ // controls listen for pointer events alone, so without these the host's
18
+ // gesture recognizers could claim a drag the page itself was handling.
19
+ //
20
+ // 2. Stale fingers are cancelled on arrival. A touch pointerdown marked
21
+ // isPrimary is the first finger of a brand-new gesture, so no other touch
22
+ // can really be down; every touch id still recorded as down on the canvas is
23
+ // sent the pointercancel it missed, from a capture listener on the document
24
+ // (which runs before the controls' own listener on the canvas). A second
25
+ // finger of the same gesture is never primary, so pinch and two-finger pan
26
+ // are untouched.
27
+ //
28
+ // The controls release pointer capture for the id they remove, and that call
29
+ // throws for an id the browser has already forgotten, aborting their cleanup
30
+ // half way (the move listeners and the gesture state would stay live). So the
31
+ // canvas's releasePointerCapture is wrapped to ignore that one failure.
32
+ const TOUCH_TYPES = ["touchstart", "touchend", "touchcancel"];
33
+
34
+ export function guardStaleTouches(element) {
35
+ const doc = element.ownerDocument;
36
+ if (!doc) return () => {};
37
+ const noop = () => {};
38
+ const touchesDown = new Set();
39
+
40
+ const release = element.releasePointerCapture;
41
+ if (typeof release === "function") {
42
+ element.releasePointerCapture = function releaseIfHeld(pointerId) {
43
+ try {
44
+ release.call(this, pointerId);
45
+ } catch {
46
+ // Already released: the finger is gone, which is all the caller wanted.
47
+ }
48
+ };
49
+ }
50
+
51
+ const onDown = (event) => {
52
+ if (event.pointerType !== "touch") return;
53
+ if (event.isPrimary && touchesDown.size) {
54
+ for (const pointerId of [...touchesDown]) {
55
+ if (pointerId === event.pointerId) continue;
56
+ touchesDown.delete(pointerId);
57
+ element.dispatchEvent(new PointerEvent("pointercancel", { pointerId, pointerType: "touch", bubbles: true }));
58
+ }
59
+ }
60
+ if (event.target === element) touchesDown.add(event.pointerId);
61
+ };
62
+ const onEnd = (event) => { touchesDown.delete(event.pointerId); };
63
+
64
+ for (const type of TOUCH_TYPES) doc.addEventListener(type, noop, { capture: true, passive: true });
65
+ doc.addEventListener("pointerdown", onDown, { capture: true, passive: true });
66
+ doc.addEventListener("pointerup", onEnd, { capture: true, passive: true });
67
+ doc.addEventListener("pointercancel", onEnd, { capture: true, passive: true });
68
+
69
+ return function dispose() {
70
+ for (const type of TOUCH_TYPES) doc.removeEventListener(type, noop, { capture: true });
71
+ doc.removeEventListener("pointerdown", onDown, { capture: true });
72
+ doc.removeEventListener("pointerup", onEnd, { capture: true });
73
+ doc.removeEventListener("pointercancel", onEnd, { capture: true });
74
+ if (typeof release === "function") element.releasePointerCapture = release;
75
+ touchesDown.clear();
76
+ };
77
+ }
@@ -1,5 +1,6 @@
1
1
  import * as THREE from "three";
2
2
  import { OrbitControls } from "three/addons/controls/OrbitControls.js";
3
+ import { guardStaleTouches } from "./stale-touch-guard.js";
3
4
  import { toCreasedNormals } from "three/addons/utils/BufferGeometryUtils.js";
4
5
  import { LineSegments2 } from "three/addons/lines/LineSegments2.js";
5
6
  import { LineSegmentsGeometry } from "three/addons/lines/LineSegmentsGeometry.js";
@@ -231,6 +232,9 @@ export function createViewer(container, part) {
231
232
 
232
233
  const controls = new OrbitControls(activeCamera, renderer.domElement);
233
234
  controls.enableDamping = true;
235
+ // A finger a host gesture swallowed would otherwise stay "down" in the
236
+ // controls forever (every later drag zooms) — see stale-touch-guard.js.
237
+ const disposeTouchGuard = guardStaleTouches(renderer.domElement);
234
238
 
235
239
  // --- render on demand -------------------------------------------------------
236
240
  // The loop ticks every animation frame (controls, tweens, frame listeners —
@@ -2608,6 +2612,7 @@ export function createViewer(container, part) {
2608
2612
  canonicalCaptureHidden.clear();
2609
2613
  camTween.cancel();
2610
2614
  controls.dispose();
2615
+ disposeTouchGuard();
2611
2616
  for (const t of flashTimers) clearTimeout(t);
2612
2617
  flashTimers.clear();
2613
2618
  // A dot whose timer was just cancelled — or one held indefinitely — still