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.
- package/docs/AUTHORING-PARTS.md +80 -19
- package/docs/KERNEL-CONTRACT.md +1 -1
- package/package.json +1 -1
- package/src/app-materials.js +3 -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/lint/rules-materials.js +9 -2
- package/src/framework/materials/assets/SOURCES.md +10 -4
- package/src/framework/materials/assets/pattern-plywood-color.jpg +0 -0
- package/src/framework/materials/assets/pattern-plywood-normal.jpg +0 -0
- package/src/framework/materials/assets/pattern-plywood-rough.jpg +0 -0
- package/src/framework/materials/assets.js +3 -0
- package/src/framework/materials/patterns.js +103 -0
- package/src/framework/materials/physical.js +5 -2
- package/src/framework/materials/presets.js +11 -5
- package/src/framework/materials/resolve.js +4 -2
- package/src/framework/materials/sheet-look.js +204 -0
- package/src/framework/mount.js +74 -37
- package/src/framework/process/exporters.js +18 -0
- package/src/framework/process/laser/export.js +53 -0
- package/src/framework/sheet/part.js +32 -17
- package/src/framework/viewer.js +53 -29
- package/src/index.js +4 -0
- package/src/parts/laser-box.js +1 -1
- package/src/parts/material-swatches.js +58 -2
- package/types/index.d.ts +72 -5
- package/types/part.d.ts +4 -0
- 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
|
|
@@ -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
|
|
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 (
|
|
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
|
|
80
|
-
|
|
81
|
-
(
|
|
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: `
|
|
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`.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
+
}
|