partforge 0.133.3 → 0.134.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/index.js +4 -0
- package/types/index.d.ts +72 -5
- package/types/testing.d.ts +7 -3
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
30
|
+
.replace(/"/g, """).replace(/'/g, "'");
|
|
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
|
-
|
|
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
|
|
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 }));
|
package/src/framework/jobs.js
CHANGED
|
@@ -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
|
package/src/framework/mount.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
1393
|
-
|
|
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 };
|
package/src/index.js
CHANGED
|
@@ -4,3 +4,7 @@
|
|
|
4
4
|
// Part build functions import geometry helpers from "partforge/geometry" instead.
|
|
5
5
|
export { mount } from "./framework/index.js";
|
|
6
6
|
export { viewSubParts } from "./framework/part-model.js";
|
|
7
|
+
// The cut & print kit's options surface, for a host that draws its own options screen
|
|
8
|
+
// (partforge-cloud's coupling test reads KIT_OPTIONS_ERROR from here). export/formats.js
|
|
9
|
+
// is import-free, so this adds nothing to the app bundle but a few constants.
|
|
10
|
+
export { EXPORT_FORMATS, KIT_OPTIONS_ERROR, KIT_DEFAULTS, validateKitOptions } from "./framework/export/formats.js";
|