partforge 0.133.3 → 0.135.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.
Files changed (37) hide show
  1. package/docs/AUTHORING-PARTS.md +80 -19
  2. package/docs/KERNEL-CONTRACT.md +1 -1
  3. package/package.json +1 -1
  4. package/src/app-materials.js +3 -1
  5. package/src/framework/export/bundle.js +413 -0
  6. package/src/framework/export/drawing.js +356 -0
  7. package/src/framework/export/dxf.js +150 -0
  8. package/src/framework/export/formats.js +142 -0
  9. package/src/framework/export/layout.js +92 -0
  10. package/src/framework/export/placement.js +14 -0
  11. package/src/framework/export/readme.js +103 -0
  12. package/src/framework/export/svg.js +97 -0
  13. package/src/framework/export-controller.js +7 -2
  14. package/src/framework/export-rows.js +34 -0
  15. package/src/framework/jobs.js +16 -0
  16. package/src/framework/lint/rules-materials.js +9 -2
  17. package/src/framework/materials/assets/SOURCES.md +10 -4
  18. package/src/framework/materials/assets/pattern-plywood-color.jpg +0 -0
  19. package/src/framework/materials/assets/pattern-plywood-normal.jpg +0 -0
  20. package/src/framework/materials/assets/pattern-plywood-rough.jpg +0 -0
  21. package/src/framework/materials/assets.js +3 -0
  22. package/src/framework/materials/patterns.js +103 -0
  23. package/src/framework/materials/physical.js +5 -2
  24. package/src/framework/materials/presets.js +11 -5
  25. package/src/framework/materials/resolve.js +4 -2
  26. package/src/framework/materials/sheet-look.js +204 -0
  27. package/src/framework/mount.js +74 -37
  28. package/src/framework/process/exporters.js +18 -0
  29. package/src/framework/process/laser/export.js +53 -0
  30. package/src/framework/sheet/part.js +32 -17
  31. package/src/framework/viewer.js +53 -29
  32. package/src/index.js +4 -0
  33. package/src/parts/laser-box.js +1 -1
  34. package/src/parts/material-swatches.js +58 -2
  35. package/types/index.d.ts +72 -5
  36. package/types/part.d.ts +4 -0
  37. 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, "&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
@@ -1,12 +1,13 @@
1
1
  // src/framework/lint/rules-materials.js
2
2
  // Appearance findings. All WARNINGS by design: a misspelled material must never
3
3
  // block geometry, and the viewer already falls back (resolve.js). Pure — imports
4
- // only the three-free materials data (test/lint-purity.test.js).
4
+ // only three-free materials and sheet data (test/lint-purity.test.js).
5
5
  import { warn } from "./finding.js";
6
6
  import { PRESETS } from "../materials/presets.js";
7
7
  import { ENVIRONMENTS } from "../materials/environments.js";
8
8
  import { resolveMaterial, resolveEnvironmentId, OVERRIDE_RANGES } from "../materials/resolve.js";
9
9
  import { suggest } from "../geometry/op-options.js";
10
+ import { sheetStockLook } from "../materials/sheet-look.js";
10
11
 
11
12
  const listedPresets = () => Object.keys(PRESETS);
12
13
  const subParts = (part) => Object.entries(part?.parts ?? {}).filter(([, sp]) => sp && typeof sp === "object");
@@ -18,9 +19,15 @@ export const MATERIAL_RULES = [
18
19
  id: "unknown-material",
19
20
  run: ({ part }) => issuesOf(part, "unknown-material").map(({ name, value }) => {
20
21
  const near = typeof value === "string" ? suggest(value, listedPresets()) : null;
22
+ // A laser sheet falls back to its stock's look, not to PLA (materials/sheet-look.js) —
23
+ // read from a string label only: a label written as a function takes the default look.
24
+ const sp = part.parts[name];
25
+ const stock = sheetStockLook(sp);
26
+ const why = typeof sp.sheet?.material === "string" ? ", from its stock label" : ": its stock label is a function";
27
+ const fallback = stock ? `in realistic mode \`${stock}\`${why}` : "a PLA print in realistic mode";
21
28
  return warn("unknown-material",
22
29
  `sub-part "${name}" names material ${JSON.stringify(value)}, which is not in the library`,
23
- `${near ? `Did you mean "${near}"? ` : ""}The viewer draws it as if it named no material instead (a PLA print in realistic mode). Known materials: ${listedPresets().join(", ")}.`,
30
+ `${near ? `Did you mean "${near}"? ` : ""}The viewer draws it as if it named no material instead (${fallback}). Known materials: ${listedPresets().join(", ")}.`,
24
31
  `parts.${name}.display.material`);
25
32
  }),
26
33
  },
@@ -66,6 +66,9 @@ the sliced primary and gain-map buffers now start with SOI (`FF D8`) and end wit
66
66
  | `pattern-walnut-color.jpg` | Poly Haven "Dark Wood" (colour; API key `Diffuse`) | https://polyhaven.com/a/dark_wood — file https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/dark_wood/dark_wood_diff_1k.jpg | 2026-09-24 | 1K JPG, sRGB colour -> `node scripts/bake-environments.mjs --texture dark_wood_diff_1k.jpg pattern-walnut-color.jpg` (q82). Scanned tile 2000 mm (`api.polyhaven.com/info/dark_wood` dimensions). |
67
67
  | `pattern-walnut-normal.jpg` | Poly Haven "Dark Wood" (OpenGL normal map; API key `nor_gl`) | https://polyhaven.com/a/dark_wood — file https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/dark_wood/dark_wood_nor_gl_1k.jpg | 2026-09-24 | 1K JPG, OpenGL (+Y up) tangent normals, kept as data -> `node scripts/bake-environments.mjs --texture dark_wood_nor_gl_1k.jpg pattern-walnut-normal.jpg --data --quality 95` (4:4:4 chroma, no grayscale/tint; q95 because the map is nearly flat and the viewer amplifies it, so q90's 8x8 blocks showed). |
68
68
  | `pattern-walnut-rough.jpg` | Poly Haven "Dark Wood" (roughness; API key `Rough`) | https://polyhaven.com/a/dark_wood — file https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/dark_wood/dark_wood_rough_1k.jpg | 2026-09-24 | 1K JPG, grayscale data -> `node scripts/bake-environments.mjs --texture dark_wood_rough_1k.jpg pattern-walnut-rough.jpg --gray` (source is neutral grey; channel means unchanged). |
69
+ | `pattern-plywood-color.jpg` | Poly Haven `plywood` (colour; API key `Diffuse`) | https://polyhaven.com/a/plywood — file https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/plywood/plywood_diff_1k.jpg | 2026-09-30 | 1K JPG, sRGB colour -> `node scripts/bake-environments.mjs --texture plywood_diff_1k.jpg pattern-plywood-color.jpg --modulate 1.85,0.9 --flatten 0.55 --quality 85` (lightened 1.85x and saturation 0.9 from the scan's mid brown to a pale birch; grain contrast 0.55, since lightening doubles it). One continuous veneer, fine even grain running across the image (`grain: "u"`). Chosen 2026-09-30 from five approved CC0 candidates: ambientCG Wood090A, Wood090B, Wood091A and Wood091B (https://ambientcg.com/a/<id>) are edge-glued strip panels with visible parallel joints, not a veneer face; Wood087/088/089 (checked earlier the same day) show plywood edges. |
70
+ | `pattern-plywood-normal.jpg` | Poly Haven `plywood` (OpenGL normal map; API key `nor_gl`) | https://polyhaven.com/a/plywood — file https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/plywood/plywood_nor_gl_1k.jpg | 2026-09-30 | 1K JPG, OpenGL (+Y up) tangent normals, kept as data -> `node scripts/bake-environments.mjs --texture plywood_nor_gl_1k.jpg pattern-plywood-normal.jpg --data --quality 95` (4:4:4 chroma, no grayscale/tint). The map leans slightly (mean red 118/255, a ~4° tilt along the grain) as the source does; kept as authored. |
71
+ | `pattern-plywood-rough.jpg` | Poly Haven `plywood` (roughness; API key `Rough`) | https://polyhaven.com/a/plywood — file https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/plywood/plywood_rough_1k.jpg | 2026-09-30 | 1K JPG, grayscale data -> `node scripts/bake-environments.mjs --texture plywood_rough_1k.jpg pattern-plywood-rough.jpg --gray` (mean 191/255 = 0.749, the preset's `roughnessMean`). |
69
72
  | `ground-maple-color.jpg` | Poly Haven "Oak Veneer 01" (colour; API key `Diffuse`) | https://polyhaven.com/a/oak_veneer_01 — file https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/oak_veneer_01/oak_veneer_01_diff_1k.jpg | 2026-09-24 | Workshop floor, "unfinished maple": `node scripts/bake-environments.mjs --texture oak_veneer_01_diff_1k.jpg ground-maple-color.jpg --modulate 1.5,0.7 --quality 85` (lightened 1.5x, saturation 0.7). |
70
73
  | `ground-maple-normal.jpg` | Poly Haven "Oak Veneer 01" (OpenGL normal map; API key `nor_gl`) | https://polyhaven.com/a/oak_veneer_01 — file https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/oak_veneer_01/oak_veneer_01_nor_gl_1k.jpg | 2026-09-24 | Workshop floor relief: `node scripts/bake-environments.mjs --texture oak_veneer_01_nor_gl_1k.jpg ground-maple-normal.jpg --data --quality 92`. A separate file from `pattern-oak-normal.jpg` so the floor and the oak material never share (and dispose) one texture. |
71
74
  | `ground-pei.jpg` | None — procedural | n/a | n/a | Gold-brown fine-grain speckle, no external source. First attempt tinted ambientCG "Plastic 010"'s colour map (near-uniform, stdev < 1/channel — visibly flat, no grain, when actually viewed) gold-brown; that colour map carries no usable grain at all (its roughness map does, which is why `ground-pei-rough.jpg` below is still Plastic010's roughness). Checked ambientCG alternatives (Metal049A — also flat; Granite006A — real speckle but reads as rock, not powder-coat) and found nothing closer. Generated in-baker instead, same procedural precedent as `pattern-carbon.jpg`: per-pixel triangular-distributed noise around a `168,134,82` bronze-gold base (asymmetric per channel so flecks warm rather than grey), then a 0.5px blur to turn single-pixel noise into soft grains -> `node scripts/bake-environments.mjs --speckle ground-pei.jpg --tint 168,134,82`. (Re-baked 2026-09-24 from `196,154,82`: darker and less saturated, closer to a Bambu textured-PEI plate's muted bronze-gold.) |
@@ -76,9 +79,11 @@ the sliced primary and gain-map buffers now start with SOI (`FF D8`) and end wit
76
79
  | `pattern-carbon.jpg` | None — procedural | n/a | n/a | ambientCG has no CC0 carbon-fiber weave (searched "carbon"; only unrelated fabric hits). Generated in-baker: a 256x256 2x2 twill tile (32 threads/tile, alternating dark/light grey diagonal bands) -> `node scripts/bake-environments.mjs --carbon pattern-carbon.jpg`. No external source/licence to record. |
77
80
 
78
81
  ambientCG publishes every asset under CC0 (https://ambientcg.com/, "CC0 Public Domain
79
- license", checked 2026-09-24). The wood sets (`pattern-oak-*`, `pattern-walnut-*`,
80
- `ground-maple-*`) are Poly Haven textures, CC0 like its HDRIs above
81
- (https://polyhaven.com/license, checked 2026-09-24); the exact 1K JPG URLs came
82
+ license", checked 2026-09-24; https://ambientcg.com/license, "Creative Commons CC0 1.0
83
+ Universal License", checked again 2026-09-30 for the plywood candidates). The wood sets
84
+ (`pattern-oak-*`, `pattern-walnut-*`, `pattern-plywood-*`, `ground-maple-*`) are Poly Haven
85
+ textures, CC0 like its HDRIs above (https://polyhaven.com/license, checked 2026-09-24, and
86
+ again 2026-09-30 for `plywood`); the exact 1K JPG URLs came
82
87
  from `https://api.polyhaven.com/files/<slug>` (keys `Diffuse`, `nor_gl`, `Rough`).
83
88
  The presets tile them smaller than the scanned size — see the comment on `oak` in
84
89
  `presets.js` and on `workshop` in `environments.js`.
@@ -86,5 +91,6 @@ The presets tile them smaller than the scanned size — see the comment on `oak`
86
91
  ## Budgets
87
92
 
88
93
  Environments stay under 1.5 MB (largest: `env-workshop.jpg` at ~398 KB); ground/pattern
89
- textures stay under 400 KB (largest: `pattern-oak-rough.jpg` at ~218 KB). All committed
94
+ textures stay under 400 KB (largest: `ground-concrete-normal.jpg` at ~380 KB; the largest
95
+ pattern map is `pattern-plywood-normal.jpg` at ~337 KB). All committed
90
96
  outputs are well inside both ceilings — see `test/framework/materials-assets.test.js`.
@@ -25,6 +25,9 @@ const URLS = {
25
25
  "pattern-walnut-color.jpg": new URL("./assets/pattern-walnut-color.jpg", import.meta.url).href,
26
26
  "pattern-walnut-normal.jpg": new URL("./assets/pattern-walnut-normal.jpg", import.meta.url).href,
27
27
  "pattern-walnut-rough.jpg": new URL("./assets/pattern-walnut-rough.jpg", import.meta.url).href,
28
+ "pattern-plywood-color.jpg": new URL("./assets/pattern-plywood-color.jpg", import.meta.url).href,
29
+ "pattern-plywood-normal.jpg": new URL("./assets/pattern-plywood-normal.jpg", import.meta.url).href,
30
+ "pattern-plywood-rough.jpg": new URL("./assets/pattern-plywood-rough.jpg", import.meta.url).href,
28
31
  "pattern-carbon.jpg": new URL("./assets/pattern-carbon.jpg", import.meta.url).href,
29
32
  };
30
33
 
@@ -5,7 +5,10 @@
5
5
  // they move with the sub-part under animation and never slide when the camera
6
6
  // moves. Layer lines additionally map object space through `pfPrintFrame`
7
7
  // (print-frame.js: display→export), so they run the way the part will print.
8
+ // A laser-cut wood sheet's burn (applyBurn) composes over the wood pass in the sheet's
9
+ // canonical frame (sheet-look.js).
8
10
  import * as THREE from "three";
11
+ import { BURN, srgbToLinear } from "./sheet-look.js";
9
12
 
10
13
  const VERT_DECL = "varying vec3 vPfObjPos;\nvarying vec3 vPfObjNormal;\nvarying vec3 vPfPrintUpView;\nuniform mat4 pfPrintFrame;\n";
11
14
  // Wood's triplanar normal map builds its normal in OBJECT space; the fragment
@@ -354,3 +357,103 @@ export function applyBrushFrame(material) {
354
357
  material.needsUpdate = true;
355
358
  return material;
356
359
  }
360
+
361
+ // --- laser burns ------------------------------------------------------------------
362
+ // A laser-cut sheet in a wood shows what the laser did to it (sheet-look.js decides which
363
+ // sub-parts, and mount hands the viewer each one's canonical frame): cut walls charred,
364
+ // engrave and score floors scorched, faces untouched. Composes over the wood pass the way
365
+ // applyBrushFrame does, injecting at three points in three's meshphysical order:
366
+ // (a) colour + roughness, before <normal_fragment_begin>: after wood's body (injected
367
+ // after <roughnessmap_fragment>) and <metalnessmap_fragment>. A second replace of
368
+ // <roughnessmap_fragment> would land IN FRONT of wood's body, so it is not reused.
369
+ // pfBurnWall / pfBurnFloor are declared at main() scope, like pfLayer, for (b), (c).
370
+ // (b) the normal, before <emissivemap_fragment>: after wood's normal map and the
371
+ // clearcoat normals. Char flattens the wood's relief: the wood normal fades toward
372
+ // the geometric one (nonPerturbedNormal, set by <normal_fragment_begin>), keeping
373
+ // BURN.relief of it.
374
+ // (c) clearcoat, after <lights_physical_fragment>, which assigns material.clearcoat:
375
+ // walnut's lacquer comes off the char.
376
+ // Every snippet sits between `// pf-burn {` and `// } pf-burn` lines: that is how the tests
377
+ // prove a burning program is the plain wood program plus these and nothing else. The
378
+ // vertex stage is untouched — the pass reads the varyings every pattern already has. With
379
+ // pfSheetT = 0 (no frame delivered yet) it draws plain wood. No noise term: an unfiltered
380
+ // striation would shimmer at part scale (the layer-lines lesson above).
381
+ const glf = (x) => (Number.isInteger(x) ? x.toFixed(1) : String(x));
382
+ const CHARCOAL = srgbToLinear(BURN.charcoal);
383
+ const burnBlock = (glsl) => `// pf-burn {\n${glsl}\n// } pf-burn`;
384
+
385
+ const BURN_FRAG_DECL = burnBlock(`uniform mat4 pfSheetFrame;
386
+ uniform float pfSheetT;
387
+ uniform float pfPlies;
388
+ uniform vec3 pfFaceAvg;
389
+ // 1 on a cross ply (an odd one), 0 on a face-grain ply: the square wave box-filtered over
390
+ // the pixel (its integral, differenced across fwidth), so plies too thin to draw average
391
+ // out instead of shimmering.
392
+ float pfCrossPlyI(float x) { return floor(x * 0.5) + max(0.0, fract(x * 0.5) * 2.0 - 1.0); }
393
+ float pfCrossPly(float x) {
394
+ float w = max(fwidth(x), 1e-4);
395
+ return (pfCrossPlyI(x + 0.5 * w) - pfCrossPlyI(x - 0.5 * w)) / w;
396
+ }`);
397
+
398
+ const BURN_FRAG_BODY = burnBlock(`float pfBurnWall = 0.0;
399
+ float pfBurnFloor = 0.0;
400
+ if (pfSheetT > 0.0) {
401
+ vec3 pfC = (pfSheetFrame * vec4(vPfObjPos, 1.0)).xyz;
402
+ vec3 pfCn = normalize(mat3(pfSheetFrame) * vPfObjNormal);
403
+ pfBurnWall = 1.0 - smoothstep(${glf(BURN.wallNz[0])}, ${glf(BURN.wallNz[1])}, abs(pfCn.z));
404
+ pfBurnFloor = step(0.5, pfCn.z) * step(pfC.z, pfSheetT - ${glf(BURN.floorDepth)});
405
+ float pfK = mix(${glf(BURN.kThin)}, ${glf(BURN.kThick)}, clamp((pfSheetT - ${glf(BURN.tThin)}) / ${glf(BURN.tThick - BURN.tThin)}, 0.0, 1.0));
406
+ float pfZ = clamp(pfC.z / pfSheetT, 0.0, 1.0);
407
+ vec3 pfFace = diffuse * pfFaceAvg;
408
+ // Per channel, not the vector scaled by one luminance ratio (sheet-look.js's burnAlbedo,
409
+ // this shader's twin, explains why): a channel where charOfDark·face is already below raw
410
+ // charcoal chars toward ITS OWN darker value, so no channel can come out lighter than it
411
+ // went in.
412
+ vec3 pfChar = min(vec3(${CHARCOAL.map((c) => c.toFixed(6)).join(", ")}), ${glf(BURN.charOfDark)} * pfFace);
413
+ vec3 pfEdge = mix(pfFace, pfChar, min(1.0, pfK + ${glf(BURN.exit)} * (1.0 - pfZ)));
414
+ if (pfPlies > 0.0) pfEdge *= mix(1.0, ${glf(BURN.crossPly)}, pfCrossPly(pfZ * pfPlies));
415
+ vec3 pfMark = mix(pfFace, pfChar, pfK - ${glf(BURN.engraveLess)});
416
+ // The grain survives the char (sheet-look.js's burnTexel is the twin): pfEdge and pfMark
417
+ // are what the face AVERAGE chars to, and each texel keeps its ratio to that average,
418
+ // blended in by BURN.grain. At 1, a pure multiply, the average chars exactly as above and
419
+ // no texel comes out lighter than it went in.
420
+ vec3 pfGrain = mix(vec3(1.0), diffuseColor.rgb / max(pfFace, vec3(1e-4)), ${glf(BURN.grain)});
421
+ pfEdge *= pfGrain;
422
+ pfMark *= pfGrain;
423
+ diffuseColor.rgb = mix(diffuseColor.rgb, pfEdge, pfBurnWall);
424
+ diffuseColor.rgb = mix(diffuseColor.rgb, pfMark, pfBurnFloor);
425
+ roughnessFactor = mix(roughnessFactor, ${glf(BURN.roughness)}, max(pfBurnWall, pfBurnFloor));
426
+ }`);
427
+
428
+ const BURN_FRAG_NORMAL = burnBlock(`normal = normalize(mix(normal, nonPerturbedNormal, max(pfBurnWall, pfBurnFloor) * ${glf(1 - BURN.relief)}));`);
429
+
430
+ const BURN_FRAG_CLEARCOAT = burnBlock(`#ifdef USE_CLEARCOAT
431
+ material.clearcoat *= 1.0 - max(pfBurnWall, pfBurnFloor);
432
+ #endif`);
433
+
434
+ // `faceAvg` is the preset's own colour (0xRRGGBB) — its texture's average, never the tint:
435
+ // the tint arrives as `diffuse`, so the shader's face colour is diffuse · pfFaceAvg.
436
+ export function applyBurn(material, { faceAvg }) {
437
+ const uniforms = material.userData.patternUniforms;
438
+ if (!uniforms?.pfGrainSwap) return material; // wood only: the burn draws over the wood pass
439
+ Object.assign(uniforms, {
440
+ pfSheetFrame: { value: new THREE.Matrix4() },
441
+ pfSheetT: { value: 0 },
442
+ pfPlies: { value: 0 },
443
+ pfFaceAvg: { value: new THREE.Color(faceAvg) },
444
+ });
445
+ const prev = material.onBeforeCompile;
446
+ const prevKey = material.customProgramCacheKey;
447
+ material.onBeforeCompile = (shader, renderer) => {
448
+ prev?.call(material, shader, renderer); // applyPattern's hook copies `uniforms` — burn uniforms included
449
+ shader.fragmentShader = shader.fragmentShader
450
+ .replace("#include <common>", `#include <common>\n${BURN_FRAG_DECL}`)
451
+ .replace("#include <normal_fragment_begin>", `${BURN_FRAG_BODY}\n#include <normal_fragment_begin>`)
452
+ .replace("#include <emissivemap_fragment>", `${BURN_FRAG_NORMAL}\n#include <emissivemap_fragment>`)
453
+ .replace("#include <lights_physical_fragment>", `#include <lights_physical_fragment>\n${BURN_FRAG_CLEARCOAT}`);
454
+ };
455
+ const base = prevKey && prevKey !== THREE.Material.prototype.customProgramCacheKey ? prevKey.call(material) : "";
456
+ material.customProgramCacheKey = () => `${base}|pf-burn`;
457
+ material.needsUpdate = true;
458
+ return material;
459
+ }