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
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// disagree about which preset a sub-part is.
|
|
6
6
|
import * as THREE from "three";
|
|
7
7
|
import { cadAppearance, resolveMaterial } from "./resolve.js";
|
|
8
|
-
import { applyBrushFrame, applyPattern } from "./patterns.js";
|
|
8
|
+
import { applyBrushFrame, applyBurn, applyPattern } from "./patterns.js";
|
|
9
9
|
import { PATTERN_TEXTURES } from "./assets.js";
|
|
10
10
|
|
|
11
11
|
const APPEARANCE_KEYS = ["color", "opacity", "material", "roughness", "metalness", "clearcoat", "clearcoatRoughness", "anisotropy", "textureScale"];
|
|
@@ -24,8 +24,11 @@ export function buildCadMaterial(display, base) {
|
|
|
24
24
|
|
|
25
25
|
// Realistic-mode material. `loadTexture(fileName)` is injected (the viewer owns
|
|
26
26
|
// a caching TextureLoader), so this stays unit-testable without a network.
|
|
27
|
+
// `burn` is the viewer's per-sub-part decision (sheet-look.js burnsFor): it adds the laser
|
|
28
|
+
// burn pass to a wood and changes no other program.
|
|
27
29
|
export function buildPhysicalMaterial(display, opts = {}) {
|
|
28
|
-
|
|
30
|
+
let m = buildPhysical(display, opts);
|
|
31
|
+
if (opts.burn) m = applyBurn(m, { faceAvg: resolveMaterial(display).preset.color });
|
|
29
32
|
// brushed metal picks its brush direction per pixel (patterns.js applyBrushFrame)
|
|
30
33
|
return m.userData.pfAnisotropic ? applyBrushFrame(m) : m;
|
|
31
34
|
}
|
|
@@ -13,7 +13,8 @@ const P = (id, label, category, use, fields) => ({ id, label, category, use, tin
|
|
|
13
13
|
export const PRESETS = {
|
|
14
14
|
// Every record here is a documented, nameable material. A sub-part that names
|
|
15
15
|
// none is drawn by resolve.js (the blue-grey CAD look; a PLA print in that
|
|
16
|
-
// colour in realistic mode
|
|
16
|
+
// colour in realistic mode, or a laser sheet part's stock look — sheet-look.js
|
|
17
|
+
// realisticDisplay) — there is no hidden "default" record any more.
|
|
17
18
|
|
|
18
19
|
"machined-aluminum": P("machined-aluminum", "Machined aluminium", "aluminium", "Bare CNC-milled aluminium, fine tool marks, satin sheen.",
|
|
19
20
|
{ color: 0xf5f6f6, metalness: 1, roughness: 0.32 }),
|
|
@@ -73,17 +74,22 @@ export const PRESETS = {
|
|
|
73
74
|
// mode the texture carries the colour itself; an explicit `color` tints it.
|
|
74
75
|
// `roughnessMean` is the roughness map's mean, so the preset `roughness` (or an
|
|
75
76
|
// override) sets the AVERAGE roughness and the map only varies it. `normalScale`
|
|
76
|
-
// strengthens the normal map:
|
|
77
|
-
// ~1% tilt), which reads as flat under an environment map
|
|
77
|
+
// strengthens the normal map: the oak and walnut scans' tangent normals barely leave +Z (a
|
|
78
|
+
// ~1% tilt), which reads as flat under an environment map; plywood's carries about three
|
|
79
|
+
// times that, a sanded face's relief, so it keeps its own. `grain` is the
|
|
78
80
|
// texture axis the scan's figure runs along — oak's runs up the image ("v"),
|
|
79
|
-
// walnut's across it ("u") — so patterns.js can lay it along each sub-part's
|
|
80
|
-
// long axis.
|
|
81
|
+
// walnut's and plywood's across it ("u") — so patterns.js can lay it along each sub-part's
|
|
82
|
+
// long axis. `laminated` (plywood) asks the burn pass (patterns.js applyBurn) for ply
|
|
83
|
+
// bands on laser-cut walls, at sheet-look.js's plyCount(t).
|
|
81
84
|
oak: P("oak", "Oak", "natural", "Light oak with open grain.",
|
|
82
85
|
{ color: 0xa17e57, metalness: 0, roughness: 0.65, pattern: "wood", textureScale: 250,
|
|
83
86
|
textures: { color: "pattern-oak-color.jpg", normal: "pattern-oak-normal.jpg", roughness: "pattern-oak-rough.jpg", roughnessMean: 0.53, normalScale: 2.5, grain: "v" } }),
|
|
84
87
|
walnut: P("walnut", "Walnut", "natural", "Dark oiled walnut.",
|
|
85
88
|
{ color: 0x5d2a18, metalness: 0, roughness: 0.5, clearcoat: 0.3, clearcoatRoughness: 0.4, pattern: "wood", textureScale: 400,
|
|
86
89
|
textures: { color: "pattern-walnut-color.jpg", normal: "pattern-walnut-normal.jpg", roughness: "pattern-walnut-rough.jpg", roughnessMean: 0.628, normalScale: 3, grain: "u" } }),
|
|
90
|
+
plywood: P("plywood", "Birch plywood", "natural", "Birch plywood sheet: a pale, fine-grained face; its plies show on laser-cut edges.",
|
|
91
|
+
{ color: 0xe1c1a1, metalness: 0, roughness: 0.6, pattern: "wood", textureScale: 150, laminated: true,
|
|
92
|
+
textures: { color: "pattern-plywood-color.jpg", normal: "pattern-plywood-normal.jpg", roughness: "pattern-plywood-rough.jpg", roughnessMean: 0.749, normalScale: 1, grain: "u" } }),
|
|
87
93
|
"carbon-fiber": P("carbon-fiber", "Carbon fibre", "natural", "2x2 twill carbon fibre under clear coat.",
|
|
88
94
|
{ color: 0x1b1c1e, metalness: 0.2, roughness: 0.35, clearcoat: 1, clearcoatRoughness: 0.05, pattern: "carbon", textureScale: 48 }),
|
|
89
95
|
};
|
|
@@ -27,8 +27,10 @@ const isColor = (v) => Number.isInteger(v) && v >= 0 && v <= 0xffffff;
|
|
|
27
27
|
// material and threemf.js's uncoloured-object colour are the same literal).
|
|
28
28
|
// Realistic mode draws it as a PLA print — the pla-print finish, layer lines
|
|
29
29
|
// and all — in that same colour, or the part's own `color`, so a part is the
|
|
30
|
-
// same colour in both views, just printed
|
|
31
|
-
//
|
|
30
|
+
// same colour in both views, just printed — except a laser sheet part, which
|
|
31
|
+
// realistic mode draws as its stock instead (sheet-look.js realisticDisplay,
|
|
32
|
+
// applied before this resolves). `declaresMaterials` still counts only a NAMED
|
|
33
|
+
// material: this default never makes a part "declare" one.
|
|
32
34
|
export const NO_MATERIAL_COLOR = 0x9fb4cc;
|
|
33
35
|
export const NO_MATERIAL_PRESET_ID = "pla-print";
|
|
34
36
|
const NO_MATERIAL_CAD = { color: NO_MATERIAL_COLOR, metalness: 0.25, roughness: 0.55 };
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// src/framework/materials/sheet-look.js
|
|
2
|
+
// How a laser-cut sheet part looks in realistic mode — plain data and pure math, three-free
|
|
3
|
+
// and DOM-free like resolve.js, so the viewer, mount, lint and the tests all read ONE
|
|
4
|
+
// statement of it. patterns.js turns it into GLSL; nothing here knows about three.
|
|
5
|
+
//
|
|
6
|
+
// A laser sheetPart in a wood shows its burns: charred cut walls, scorched engrave and
|
|
7
|
+
// score floors, faces untouched. The shader tells those surfaces apart in the sheet's
|
|
8
|
+
// CANONICAL frame — profile in XY, material over z ∈ [0, t], laser face at z = t — but the
|
|
9
|
+
// viewer holds the DELIVERED display mesh. With no author `place`, sheetPart's generated
|
|
10
|
+
// place is exactly the rigid steps poseSteps(pose, t) (and there is no place at all with no
|
|
11
|
+
// pose), so the delivered mesh is that motion applied to the canonical solid and the frame
|
|
12
|
+
// is its inverse: computed from data, with no pose probe and no geometry. A sheet with an
|
|
13
|
+
// author place, a replaced place or a custom build is not trusted with a frame — it draws
|
|
14
|
+
// plain wood, never a frame that could char a face.
|
|
15
|
+
//
|
|
16
|
+
// It also says what a laser sheet part with no usable material looks like: not a PLA print
|
|
17
|
+
// (resolve.js's default for every other sub-part) but the sheet its stock label names —
|
|
18
|
+
// realisticDisplay, from the ONE keyword table STOCK_LOOKS. Realistic-only, like the PLA
|
|
19
|
+
// default: the CAD view, 3MF colours and declaresMaterials read sp.display itself.
|
|
20
|
+
import { isSheetPart, sheetMeta, MARK_DEPTH } from "../sheet/constants.js";
|
|
21
|
+
import { poseSteps, validatePose } from "../sheet/pose.js";
|
|
22
|
+
import { composePose, invertRigid } from "../geometry/pose.js";
|
|
23
|
+
import { resolveMaterial } from "./resolve.js";
|
|
24
|
+
import { PRESETS } from "./presets.js";
|
|
25
|
+
|
|
26
|
+
const IDENTITY = Object.freeze([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
|
|
27
|
+
|
|
28
|
+
// The burn's numbers, ONE statement: patterns.js templates the GLSL from them, and the JS
|
|
29
|
+
// twins below (classifySheetSurface, burnAlbedo) read the same object.
|
|
30
|
+
// wallNz a surface whose canonical |n.z| is below this band is a cut wall (the
|
|
31
|
+
// shader blends across the band; the JS twin splits at its middle). Creased
|
|
32
|
+
// normals are hard at a sheet's 90° arris, so real walls sit at n.z = 0.
|
|
33
|
+
// floorDepth an up-facing surface at least this far below the laser face is an engrave
|
|
34
|
+
// or score floor: half the preview's MARK_DEPTH, so the laser face (z = t) is
|
|
35
|
+
// never one and a mark's floor (z = t − MARK_DEPTH) always is.
|
|
36
|
+
// Char is RELATIVE to the face, all in linear RGB. A cut wall is the face's average colour
|
|
37
|
+
// mixed toward a char TARGET by k — kThin at ≤ tThin mm, kThick at ≥ tThick (thick stock takes
|
|
38
|
+
// a longer dwell) — plus up to `exit` more toward the exit side (z = 0); an engrave or score
|
|
39
|
+
// floor mixes `engraveLess` less. The target is PER CHANNEL: min(charcoal_i, charOfDark ·
|
|
40
|
+
// face_i) (burnAlbedo's `target`). For oak and plywood that is `charcoal` itself; charOfDark
|
|
41
|
+
// takes a channel over wherever face_i < charcoal_i / charOfDark — walnut's green and blue —
|
|
42
|
+
// so a dark or strongly tinted face still chars darker in every channel. Mixing toward
|
|
43
|
+
// `charcoal` alone would RAISE walnut's near-black blue, since charcoal's is lighter. Char and
|
|
44
|
+
// scorched floors take `roughness`, a matte finish, and shed any clearcoat; on plywood's cut
|
|
45
|
+
// walls a cross ply is `crossPly` of a face-grain ply.
|
|
46
|
+
// grain how much of the wood's grain shows through the char: each texel's ratio to
|
|
47
|
+
// the face average, blended in by `grain`, multiplies the average char (1, a
|
|
48
|
+
// pure multiply: every texel darkens by the same per-channel factor, so the
|
|
49
|
+
// average is unchanged and no texel comes out lighter; burnTexel is the twin).
|
|
50
|
+
// relief the share of the wood's surface relief (its normal map) that char and
|
|
51
|
+
// scorched floors keep; the rest fades to the geometric normal.
|
|
52
|
+
//
|
|
53
|
+
// The numbers were tuned by eye on the contact sheet in all four environments, inside the
|
|
54
|
+
// calibration floors test/framework/sheet-look.test.js holds them to. The twins see only the
|
|
55
|
+
// albedo. The render adds specular reflection — the same at every thickness — which no albedo
|
|
56
|
+
// can take away, and on walnut, whose char is near black, it is most of what a lit wall shows.
|
|
57
|
+
// So judge a retune on captures (scripts/capture-contact-sheet.mjs) compared at the SAME place
|
|
58
|
+
// on the sheet, never on the twins alone: swatches in different places reflect different
|
|
59
|
+
// light. The design spec records the measurements.
|
|
60
|
+
export const BURN = Object.freeze({
|
|
61
|
+
wallNz: Object.freeze([0.35, 0.65]),
|
|
62
|
+
floorDepth: MARK_DEPTH / 2,
|
|
63
|
+
charcoal: 0x262220,
|
|
64
|
+
kThin: 0.74,
|
|
65
|
+
kThick: 0.97,
|
|
66
|
+
tThin: 3,
|
|
67
|
+
tThick: 9,
|
|
68
|
+
exit: 0.1,
|
|
69
|
+
engraveLess: 0.1,
|
|
70
|
+
charOfDark: 0.37,
|
|
71
|
+
roughness: 0.68,
|
|
72
|
+
crossPly: 0.78,
|
|
73
|
+
grain: 1,
|
|
74
|
+
relief: 0.35,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// sRGB 0xRRGGBB → linear [r, g, b]: the conversion three applies to a colour uniform.
|
|
78
|
+
export function srgbToLinear(hex) {
|
|
79
|
+
return [(hex >> 16) & 255, (hex >> 8) & 255, hex & 255].map((c) => {
|
|
80
|
+
const s = c / 255;
|
|
81
|
+
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Plies in a birch-plywood sheet t mm thick: 3 mm → 3, 6 → 5, 9 → 7, 12 → 9. Always odd, so
|
|
86
|
+
// both outer plies run with the face grain.
|
|
87
|
+
const PLY_STEPS = Object.freeze([[4.5, 3], [7.5, 5], [10.5, 7]]);
|
|
88
|
+
export function plyCount(t) {
|
|
89
|
+
for (const [below, plies] of PLY_STEPS) if (t < below) return plies;
|
|
90
|
+
return 9;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// The material realistic mode draws this sub-part in.
|
|
94
|
+
const lookOf = (sp) => resolveMaterial(realisticDisplay(sp));
|
|
95
|
+
|
|
96
|
+
// A laser sheet with no usable material takes its look from its stock label: the first row
|
|
97
|
+
// with a stem that STARTS a word of the label (ignoring case; a hyphenated word is also read
|
|
98
|
+
// joined, so "poly-carbonate" is one word and "clear-acrylic" still has "acrylic"), else
|
|
99
|
+
// DEFAULT_STOCK_LOOK — plywood for birch, basswood, poplar, MDF, hardboard or anything
|
|
100
|
+
// unlabelled. Word starts, not substrings, so "Plexiglas" and "Acrylite" match while
|
|
101
|
+
// "perplexing" does not. A residual, named in the guide: stock that is neither wood nor
|
|
102
|
+
// acrylic (felt, leather, card, cork) takes the plywood look too, burns and all. Only a
|
|
103
|
+
// string label is read: a (p, d) function would make a sub-part's look (and its program)
|
|
104
|
+
// depend on the params it was first drawn at, so it counts as unlabelled.
|
|
105
|
+
export const STOCK_LOOKS = Object.freeze([
|
|
106
|
+
Object.freeze({ look: "clear-acrylic", words: Object.freeze([
|
|
107
|
+
"acryl", "perspex", "plexi", "pmma", "methacryl", "polycarb", "lexan", "makrolon", "lucite",
|
|
108
|
+
]) }),
|
|
109
|
+
]);
|
|
110
|
+
export const DEFAULT_STOCK_LOOK = "plywood";
|
|
111
|
+
|
|
112
|
+
const labelWords = (label) => {
|
|
113
|
+
const lower = label.toLowerCase();
|
|
114
|
+
return [...lower.split(/[^\p{L}]+/u), ...lower.replace(/-/g, "").split(/[^\p{L}]+/u)].filter(Boolean);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export function stockLook(stock) {
|
|
118
|
+
if (typeof stock !== "string") return DEFAULT_STOCK_LOOK;
|
|
119
|
+
const words = labelWords(stock);
|
|
120
|
+
return STOCK_LOOKS.find((row) => row.words.some((stem) => words.some((w) => w.startsWith(stem))))?.look ?? DEFAULT_STOCK_LOOK;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// The stock's look for a laser sheet part, else null (lint names it in its fallback hint).
|
|
124
|
+
export function sheetStockLook(sp) {
|
|
125
|
+
return isSheetPart(sp) && sp.sheet.process === "laser" ? stockLook(sp.sheet.material) : null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// The display realistic mode draws: sp.display itself, except that a laser sheet part
|
|
129
|
+
// naming no KNOWN material — none, or one the library does not know, the same "no usable
|
|
130
|
+
// material" resolve.js treats as one case — gets its stock's look. A `color` rides along,
|
|
131
|
+
// so a bare colour tints that look (stained plywood, coloured acrylic).
|
|
132
|
+
export function realisticDisplay(sp) {
|
|
133
|
+
const display = sp?.display;
|
|
134
|
+
const look = sheetStockLook(sp);
|
|
135
|
+
if (!look) return display;
|
|
136
|
+
if (typeof display?.material === "string" && Object.hasOwn(PRESETS, display.material)) return display;
|
|
137
|
+
return { ...(display && typeof display === "object" ? display : {}), material: look };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Does this sub-part carry the burn pass at all? Static — it reads no params — so the
|
|
141
|
+
// viewer decides a sub-part's PROGRAM once, and every sub-part that answers false keeps
|
|
142
|
+
// exactly the program it had before the burn existed.
|
|
143
|
+
export function burnsFor(sp) {
|
|
144
|
+
if (!isSheetPart(sp) || sp.sheet.process !== "laser") return false;
|
|
145
|
+
if (sp.build !== sp.sheet.generatedBuild) return false; // a custom build is not the canonical sheet
|
|
146
|
+
if (sp.sheet.place != null) return false; // an author place moves it off its pose
|
|
147
|
+
if ((sp.place ?? null) !== (sp.sheet.generatedPlace ?? null)) return false; // place replaced after sheetPart
|
|
148
|
+
try { return lookOf(sp).params.pattern === "wood"; } catch { return false; }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// At (p, d) — a delivery's params — the frame that maps the delivered display mesh's
|
|
152
|
+
// object space into the canonical sheet frame, the sheet's thickness, and how many plies
|
|
153
|
+
// its cut walls show (0 unless its preset is `laminated`). null when burnsFor says no or
|
|
154
|
+
// the pose or thickness cannot be read (the build reports those).
|
|
155
|
+
export function sheetFrameFor(sp, { p, d } = {}) {
|
|
156
|
+
if (!burnsFor(sp)) return null;
|
|
157
|
+
const meta = sheetMeta(sp, p, d);
|
|
158
|
+
if (!meta) return null;
|
|
159
|
+
const t = meta.thickness;
|
|
160
|
+
let pose;
|
|
161
|
+
try { pose = typeof sp.sheet.pose === "function" ? sp.sheet.pose(p, d) : sp.sheet.pose; } catch { return null; }
|
|
162
|
+
if (pose != null && validatePose(pose)) return null;
|
|
163
|
+
const frame = pose == null ? [...IDENTITY] : invertRigid(composePose(poseSteps(pose, t)));
|
|
164
|
+
return { frame, t, plies: lookOf(sp).preset.laminated ? plyCount(t) : 0 };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// The shader's surface classification, in JS: the frame tests hold real geometry to it.
|
|
168
|
+
// `pos` and `normal` are canonical (after the frame); `t` is the thickness.
|
|
169
|
+
export function classifySheetSurface(pos, normal, t) {
|
|
170
|
+
if (Math.abs(normal[2]) < (BURN.wallNz[0] + BURN.wallNz[1]) / 2) return "wall";
|
|
171
|
+
if (normal[2] < 0) return "back";
|
|
172
|
+
return pos[2] <= t - BURN.floorDepth ? "floor" : "face";
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const clamp01 = (x) => Math.min(1, Math.max(0, x));
|
|
176
|
+
|
|
177
|
+
// The shader's burnt albedo, in JS (the calibration test's twin — same numbers, same
|
|
178
|
+
// formula): `face` is the unburnt face's average colour in linear RGB (the preset's texture
|
|
179
|
+
// average times any tint), `t` the sheet thickness (mm), `kind` "edge" — a cut wall, `zFrac`
|
|
180
|
+
// of the way from the exit side (0) up to the laser face (1) — or "engrave", a mark's floor.
|
|
181
|
+
// Ply bands are left out: they only modulate an edge. It returns the AVERAGE char — what the
|
|
182
|
+
// face's average colour burns to. The shader then multiplies that by each texel's ratio to
|
|
183
|
+
// the face average (texel ÷ face, blended by BURN.grain), so the grain shows through;
|
|
184
|
+
// burnTexel below is that step's twin.
|
|
185
|
+
export function burnAlbedo(face, t, { kind = "edge", zFrac = 1 } = {}) {
|
|
186
|
+
const charcoal = srgbToLinear(BURN.charcoal);
|
|
187
|
+
// The mix target, PER CHANNEL: whichever is darker of raw charcoal or charOfDark of this
|
|
188
|
+
// channel's own face value — never one colour for the whole vector, which a channel darker
|
|
189
|
+
// than that colour would be pulled UP toward (the BURN comment above). Multiplicative in the
|
|
190
|
+
// channel that governs it, so charring a face darker than charcoal in that channel still
|
|
191
|
+
// only ever removes light from it.
|
|
192
|
+
const target = charcoal.map((c, i) => Math.min(c, BURN.charOfDark * face[i]));
|
|
193
|
+
const k = BURN.kThin + (BURN.kThick - BURN.kThin) * clamp01((t - BURN.tThin) / (BURN.tThick - BURN.tThin));
|
|
194
|
+
const amount = kind === "engrave" ? k - BURN.engraveLess : Math.min(1, k + BURN.exit * (1 - clamp01(zFrac)));
|
|
195
|
+
return face.map((f, i) => f + (target[i] - f) * amount);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// One texel's burnt albedo, in JS — the shader's pfGrain step on top of burnAlbedo: `texel`
|
|
199
|
+
// is the unburnt texel in linear RGB (the wood texture times any tint), the rest is
|
|
200
|
+
// burnAlbedo's. The average char times mix(1, texel ÷ face, BURN.grain), per channel, with
|
|
201
|
+
// the shader's 1e-4 floor under the divide. At grain 1 that is texel × (char ÷ face).
|
|
202
|
+
export function burnTexel(texel, face, t, opts) {
|
|
203
|
+
return burnAlbedo(face, t, opts).map((c, i) => c * (1 + (texel[i] / Math.max(face[i], 1e-4) - 1) * BURN.grain));
|
|
204
|
+
}
|
package/src/framework/mount.js
CHANGED
|
@@ -13,6 +13,7 @@ import { attachViewStyleControls } from "./view-style-controls.js";
|
|
|
13
13
|
import { ENVIRONMENTS } from "./materials/environments.js";
|
|
14
14
|
import { declaresMaterials, resolveMaterial } from "./materials/resolve.js";
|
|
15
15
|
import { printFrameMatrix } from "./materials/print-frame.js";
|
|
16
|
+
import { burnsFor, realisticDisplay, sheetFrameFor } from "./materials/sheet-look.js";
|
|
16
17
|
import { buildControls } from "./controls.js";
|
|
17
18
|
import { relevantParamKeys } from "./param-deps.js";
|
|
18
19
|
import { createMeshCache } from "./mesh-cache.js";
|
|
@@ -27,7 +28,7 @@ import { createStatusUi } from "./status-ui.js";
|
|
|
27
28
|
import { createViewTabs } from "./view-tabs.js";
|
|
28
29
|
import { attachPickToggle, attachHoverLabels, attachPicker, formatSelection } from "./selection/index.js";
|
|
29
30
|
import { createPickRequestClient, resolvePickServerUrl, PICK_SERVER_DEFAULT_URL } from "./pick-request/index.js";
|
|
30
|
-
import {
|
|
31
|
+
import { exportableRows, exportFormatList } from "./export-rows.js";
|
|
31
32
|
import { createExportController, backendForFormat } from "./export-controller.js";
|
|
32
33
|
import { createCaptureBuild } from "./capture-build.js";
|
|
33
34
|
import { attachAnimationControls } from "./animation-controls.js";
|
|
@@ -68,7 +69,7 @@ const IMPORT_MESH_BROKEN_MESSAGE = "STEP import tessellation failed to satisfy t
|
|
|
68
69
|
// carries the worker's own error text. See the correlated "error" case below.
|
|
69
70
|
const importTessellateFailedMessage = (workerMessage) => `STEP import tessellation failed — ${workerMessage}`;
|
|
70
71
|
|
|
71
|
-
export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, warmExportKernel, setHostPane, setRailLayout, animation, getView, setView, captureView, attachTooltips, measure, annotate, projection, pickMarker, getPanelState, getPanelErrors, renderMode, environment, declaresMaterials, renderViews }) {
|
|
72
|
+
export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, listExportFormats, exportParts, warmExportKernel, setHostPane, setRailLayout, animation, getView, setView, captureView, attachTooltips, measure, annotate, projection, pickMarker, getPanelState, getPanelErrors, renderMode, environment, declaresMaterials, renderViews }) {
|
|
72
73
|
return {
|
|
73
74
|
ready, dispose, setParams,
|
|
74
75
|
// Part-declared animation playback (spec 2026-08-02): animations are
|
|
@@ -141,6 +142,10 @@ export function makeHandle({ ready, dispose, viewer, setParams, listExportablePa
|
|
|
141
142
|
// say "the 3D view ran out of memory" instead of showing a dead canvas.
|
|
142
143
|
onContextLost: (listener) => viewer.onContextLost(listener),
|
|
143
144
|
listExportableParts,
|
|
145
|
+
// What exportParts can write, as { id, label, ext, mime, needsSheet } — the cut &
|
|
146
|
+
// print kit ("bundle") needs a sheet part checked. A makeHandle caller that wires
|
|
147
|
+
// no export (a test) lists none.
|
|
148
|
+
listExportFormats: listExportFormats ?? (() => []),
|
|
144
149
|
exportParts,
|
|
145
150
|
// Pay the exact kernel's cold boot ahead of an export. STEP is pinned to
|
|
146
151
|
// OCCT, whose ~11 MB WASM loads on its first job, so a Manifold-previewed
|
|
@@ -251,8 +256,11 @@ function createCleanupStack() {
|
|
|
251
256
|
// // framing (live camera pose + viewport aspect) at the
|
|
252
257
|
// // given long-edge resolution → JPEG data URL, or null
|
|
253
258
|
// // when disposed / nothing built yet
|
|
254
|
-
// runtime.listExportableParts(); // [{ name, label }] — every exportable sub-part,
|
|
255
|
-
// // independent of the active view (for an embedder-drawn export UI)
|
|
259
|
+
// runtime.listExportableParts(); // [{ name, label, sheet? }] — every exportable sub-part,
|
|
260
|
+
// // independent of the active view (for an embedder-drawn export UI);
|
|
261
|
+
// // a sheet part's row adds sheet: { process, material, thickness, group }
|
|
262
|
+
// runtime.listExportFormats(); // [{ id, label, ext, mime, needsSheet }] — what exportParts writes;
|
|
263
|
+
// // format "bundle" (the cut & print kit) also takes `options`
|
|
256
264
|
// await runtime.exportParts({ parts: ["base"], format: "stl", onProgress });
|
|
257
265
|
// // headless export of a chosen subset; resolves when the file is
|
|
258
266
|
// // written (handed to your onDownload sink, or downloaded directly
|
|
@@ -856,6 +864,12 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
856
864
|
// to its own worker in parallel, so a mixed part previews its plain sub-parts at
|
|
857
865
|
// Manifold speed while only the OCCT-routed ones wait. The return value is
|
|
858
866
|
// the job count — the loop holds the cycle open until every group has replied.
|
|
867
|
+
// Each dispatched sub-part remembers the { view, params } its job was sent with
|
|
868
|
+
// (`dispatched`): the frames the realistic look reads must describe the geometry
|
|
869
|
+
// that job builds, and a delivery shown during playback was built at params the
|
|
870
|
+
// live ones have already left (see recordFrames). One cycle is in flight at a time,
|
|
871
|
+
// so a sub-part's entry is its in-flight job's until the cycle's last reply.
|
|
872
|
+
const dispatched = new Map(); // sub-part -> { view, params } its latest job was sent with
|
|
859
873
|
const loop = createRegenLoop({
|
|
860
874
|
missingParts,
|
|
861
875
|
send: (missing) => {
|
|
@@ -863,6 +877,8 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
863
877
|
// for the overlay; this build reports the poses repaired in its own cycle.
|
|
864
878
|
lastGen = { skipped: needed.length - missing.length, rebuilt: missing.length, posed: pendingPosed.size };
|
|
865
879
|
pendingPosed.clear(); // consumed — never counted against a second build
|
|
880
|
+
const at = { view: view(), params: { ...params } };
|
|
881
|
+
for (const n of missing) dispatched.set(n, at);
|
|
866
882
|
ui.showBusy("generating");
|
|
867
883
|
const backends = backendPolicy.backendsFor(params);
|
|
868
884
|
let jobs = 0;
|
|
@@ -944,44 +960,58 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
944
960
|
}
|
|
945
961
|
}
|
|
946
962
|
|
|
947
|
-
//
|
|
948
|
-
//
|
|
949
|
-
// than the way it is displayed.
|
|
950
|
-
//
|
|
951
|
-
// material
|
|
952
|
-
//
|
|
953
|
-
//
|
|
954
|
-
//
|
|
963
|
+
// Frames the realistic look reads, for the geometry just delivered:
|
|
964
|
+
// - print frames for the layer-line pattern: the display → export map, so layers run
|
|
965
|
+
// the way the part is printed rather than the way it is displayed. Only sub-parts
|
|
966
|
+
// whose material draws layer lines have one — which includes every sub-part naming
|
|
967
|
+
// no material, since realistic mode shows those as PLA (resolve.js) — except a laser
|
|
968
|
+
// sheet part, which it shows as its stock (sheet-look.js realisticDisplay). One
|
|
969
|
+
// without a place() is identity at once; one with it costs two geometry-free probe
|
|
970
|
+
// builds.
|
|
971
|
+
// - sheet frames for the burn pass (materials/sheet-look.js): a laser sheet in a wood,
|
|
972
|
+
// mapped back into its canonical frame — from data, no probe.
|
|
973
|
+
// Each describes the delivered mesh (which a later pose-only repair only moves).
|
|
955
974
|
const layerLined = new Set(Object.keys(part.parts).filter((n) => {
|
|
956
|
-
try { return resolveMaterial(part.parts[n]
|
|
975
|
+
try { return resolveMaterial(realisticDisplay(part.parts[n])).params.pattern === "layer-lines"; } catch { return false; }
|
|
957
976
|
}));
|
|
958
|
-
|
|
959
|
-
//
|
|
960
|
-
//
|
|
961
|
-
//
|
|
962
|
-
//
|
|
977
|
+
const burning = new Set(Object.keys(part.parts).filter((n) => burnsFor(part.parts[n])));
|
|
978
|
+
// LAZY: a delivery only records the view and params its job was DISPATCHED with
|
|
979
|
+
// (`dispatched`, set where the loop sends) — not the live ones, which a delivery shown
|
|
980
|
+
// during playback was never built at; the viewer pulls the frames (computeFrames)
|
|
981
|
+
// when it is about to draw the realistic look — live, loading, or borrowed by a
|
|
982
|
+
// capture — so a CAD-only session computes nothing (viewer.js setFrameSource). The
|
|
983
|
+
// snapshot keeps a late computation describing the mesh actually delivered.
|
|
963
984
|
const printFrames = {};
|
|
964
|
-
const
|
|
965
|
-
|
|
966
|
-
|
|
985
|
+
const sheetFrames = {};
|
|
986
|
+
const undrawnFrames = new Map(); // sub-part -> { view, params } its delivered mesh was built at
|
|
987
|
+
function recordFrames(names) {
|
|
988
|
+
const wanted = names.filter((n) => layerLined.has(n) || burning.has(n));
|
|
967
989
|
if (!wanted.length) return;
|
|
968
|
-
const
|
|
969
|
-
|
|
970
|
-
viewer.invalidatePrintFrames?.();
|
|
990
|
+
for (const n of wanted) undrawnFrames.set(n, dispatched.get(n) ?? null); // null: built at params nobody recorded
|
|
991
|
+
viewer.invalidateFrames?.();
|
|
971
992
|
}
|
|
972
|
-
function
|
|
993
|
+
function computeFrames() {
|
|
973
994
|
const resolvedFor = new Map(); // one resolveParams per delivery, not per sub-part
|
|
974
995
|
for (const [n, at] of undrawnFrames) {
|
|
975
|
-
if (!resolvedFor.has(at)) {
|
|
996
|
+
if (at && !resolvedFor.has(at)) {
|
|
976
997
|
try { resolvedFor.set(at, resolveParams(part, at.params)); } catch { resolvedFor.set(at, null); } // diagnosed by the build
|
|
977
998
|
}
|
|
978
|
-
const resolved = resolvedFor.get(at);
|
|
979
|
-
|
|
999
|
+
const resolved = at ? resolvedFor.get(at) : null;
|
|
1000
|
+
// No params to frame this mesh by: a burning sheet draws plain wood rather than
|
|
1001
|
+
// keep a frame that describes other geometry — a sub-millimetre lag along its
|
|
1002
|
+
// normal is enough to char a whole face. A print frame keeps its last value (the
|
|
1003
|
+
// worst it can do is run layer lines the wrong way).
|
|
1004
|
+
if (!resolved) { delete sheetFrames[n]; continue; }
|
|
1005
|
+
if (layerLined.has(n)) printFrames[n] = printFrameMatrix(part.parts[n], { view: at.view, ...resolved });
|
|
1006
|
+
if (burning.has(n)) {
|
|
1007
|
+
const f = sheetFrameFor(part.parts[n], resolved);
|
|
1008
|
+
if (f) sheetFrames[n] = f; else delete sheetFrames[n];
|
|
1009
|
+
}
|
|
980
1010
|
}
|
|
981
1011
|
undrawnFrames.clear();
|
|
982
|
-
return { ...printFrames };
|
|
1012
|
+
return { print: { ...printFrames }, sheet: { ...sheetFrames } };
|
|
983
1013
|
}
|
|
984
|
-
viewer.
|
|
1014
|
+
viewer.setFrameSource?.(computeFrames);
|
|
985
1015
|
|
|
986
1016
|
// Sub-parts whose latest fresh delivery had zero triangles (see the `meshes` case).
|
|
987
1017
|
const emptySubParts = new Set();
|
|
@@ -1044,7 +1074,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
1044
1074
|
// delivered, which buildDone() true guarantees is at the live params.
|
|
1045
1075
|
fastPath.recordDelivered(m.name);
|
|
1046
1076
|
}
|
|
1047
|
-
|
|
1077
|
+
recordFrames(data.meshes.map((m) => m.name));
|
|
1048
1078
|
// A split dispatch answers in two meshes replies; the busy spinner
|
|
1049
1079
|
// stays up until the view has everything (the other worker's job may
|
|
1050
1080
|
// still be running — often OCCT, the slow one).
|
|
@@ -1075,10 +1105,14 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
1075
1105
|
} else if (lastAnimApplyVersion === loop.version()) {
|
|
1076
1106
|
// Stale ONLY because animation frames kept bumping the version:
|
|
1077
1107
|
// show the delivered meshes anyway — that IS best-effort playback —
|
|
1078
|
-
// but record
|
|
1079
|
-
// geometry built at the live params, and this delivery
|
|
1080
|
-
// fast-path stamp is dropped too, so a later pose-only
|
|
1081
|
-
// never re-pose this newer geometry off an older delivery's
|
|
1108
|
+
// but record nothing about the LIVE params. Cache and fast-path stamps
|
|
1109
|
+
// must describe geometry built at the live params, and this delivery
|
|
1110
|
+
// wasn't; the fast-path stamp is dropped too, so a later pose-only
|
|
1111
|
+
// repair can never re-pose this newer geometry off an older delivery's
|
|
1112
|
+
// stamp. Its material frames are the exception: those are recorded
|
|
1113
|
+
// against the params its job was dispatched with, which is what it was
|
|
1114
|
+
// built at — keeping the last fresh delivery's frame instead would lay a
|
|
1115
|
+
// sheet's burn over geometry that has moved along its normal.
|
|
1082
1116
|
// A user edit mid-play pauses playback and bumps the version WITHOUT
|
|
1083
1117
|
// touching lastAnimApplyVersion, so a genuinely user-stale result
|
|
1084
1118
|
// fails this test and is discarded exactly as before.
|
|
@@ -1086,6 +1120,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
1086
1120
|
viewer.setSubGeometry(m.name, m);
|
|
1087
1121
|
fastPath.forget(m.name);
|
|
1088
1122
|
}
|
|
1123
|
+
recordFrames(data.meshes.map((m) => m.name));
|
|
1089
1124
|
ui.hideBusy();
|
|
1090
1125
|
refreshView();
|
|
1091
1126
|
}
|
|
@@ -1389,8 +1424,10 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
1389
1424
|
captureView,
|
|
1390
1425
|
getPanelState: () => panelRef?.getState() ?? {},
|
|
1391
1426
|
getPanelErrors: () => panelRef?.errors() ?? [],
|
|
1392
|
-
|
|
1393
|
-
|
|
1427
|
+
// A sheet part's row carries its stock, evaluated here on the main thread at the
|
|
1428
|
+
// live params the way enabled() is, and omitted when it cannot be (export-rows.js).
|
|
1429
|
+
listExportableParts: () => exportableRows(part, params),
|
|
1430
|
+
listExportFormats: exportFormatList,
|
|
1394
1431
|
exportParts: (opts) => exportCtl.exportParts(opts),
|
|
1395
1432
|
warmExportKernel: () => exportCtl.warmKernel(),
|
|
1396
1433
|
animation: animCtl?.runtime ?? null,
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// One lazy exporter loader per process id, in the registry's order. Besides
|
|
2
|
+
// process/registry.js this is the ONLY module allowed to import process/<id>/*
|
|
3
|
+
// (test/sheet-layering.test.js), and it does so only through literal dynamic imports:
|
|
4
|
+
// an exporter reaches the drawing stage and so paper, which lint, the oracle and
|
|
5
|
+
// partforge/geometry must never load (test/kit-export-guards.test.js).
|
|
6
|
+
//
|
|
7
|
+
// Adding a process: one folder, one line in registry.js, one line here —
|
|
8
|
+
// test/kit-export-guards.test.js fails until the two id lists agree.
|
|
9
|
+
export const EXPORTER_LOADERS = Object.freeze({
|
|
10
|
+
laser: () => import("./laser/export.js"),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
// The process's ProcessExporter ({ drawing(s, o, k, ctx) }), loaded on first use.
|
|
14
|
+
export async function loadExporter(id) {
|
|
15
|
+
const load = Object.hasOwn(EXPORTER_LOADERS, id) ? EXPORTER_LOADERS[id] : null;
|
|
16
|
+
if (!load) throw new Error(`no exporter for process "${id}"`);
|
|
17
|
+
return (await load()).default;
|
|
18
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// The laser process's exporter: one resolved sheet piece → one Drawing (contract §5.5).
|
|
2
|
+
// Loaded only through process/exporters.js's dynamic import — it reaches the drawing
|
|
3
|
+
// stage and so paper, which the laser DESCRIPTOR (read by lint and the oracle) must never
|
|
4
|
+
// do; that is why a process is split into descriptor.js and export.js.
|
|
5
|
+
//
|
|
6
|
+
// Layers, from the piece's canonical-frame declaration (the pose is never read):
|
|
7
|
+
// cut-outer / cut-inner — the profile's outlines / every ring they enclose (holes, and
|
|
8
|
+
// an island in a hole), refit, then kerf, in cut order: every
|
|
9
|
+
// ring before any ring that encloses it (drawing.js's cutRings)
|
|
10
|
+
// score — two-point lines as open paths, score shapes' every ring closed
|
|
11
|
+
// engrave — the engrave shape's rings, a filled region
|
|
12
|
+
// Kerf offsets the cut layer ONLY: a score or engrave line is burned along its centre, so
|
|
13
|
+
// widening the kerf never moves it.
|
|
14
|
+
import { refitRing, ringsOf, cutRings, applyKerf, drawingBounds, LAYER_ORDER } from "../../export/drawing.js";
|
|
15
|
+
import { KIT_DEFAULTS } from "../../export/formats.js";
|
|
16
|
+
|
|
17
|
+
const closedPath = (ring) => ({ start: ring.start, segments: ring.segments, closed: true });
|
|
18
|
+
const refitRegions = (regions) => regions.map((rg) => ({ outer: refitRing(rg.outer), holes: rg.holes.map(refitRing) }));
|
|
19
|
+
const allRings = (regions) => { const r = ringsOf(regions); return [...r.outer, ...r.holes]; };
|
|
20
|
+
|
|
21
|
+
// `s` is a ResolvedSheet (sheet/resolve.js), `o` validated KitOptions (only `kerf` is
|
|
22
|
+
// read), `k` the geometry kernel (unused by laser: every layer is already a Shape2D or
|
|
23
|
+
// plain data), `ctx` = { label, facts? } — facts supplies the narrowest gap a kerf error
|
|
24
|
+
// names.
|
|
25
|
+
function drawing(s, o, k, ctx = {}) {
|
|
26
|
+
const label = ctx.label ?? s.label ?? "sheet part";
|
|
27
|
+
const kerf = o?.kerf ?? KIT_DEFAULTS.kerf;
|
|
28
|
+
const nominal = refitRegions(s.profile.toContours());
|
|
29
|
+
if (nominal.length === 0) throw new Error(`cut kit: "${label}" has an empty profile — nothing to cut`);
|
|
30
|
+
// A capped gap is the search ceiling, not a measured opening — don't name it.
|
|
31
|
+
const gap = ctx.facts && !ctx.facts.gapCapped && Number.isFinite(ctx.facts.gap) ? ctx.facts.gap : null;
|
|
32
|
+
const cut = cutRings(applyKerf(nominal, kerf, { label, gap }));
|
|
33
|
+
|
|
34
|
+
const byId = {
|
|
35
|
+
engrave: s.engrave ? allRings(refitRegions(s.engrave.toContours())).map(closedPath) : [],
|
|
36
|
+
score: [
|
|
37
|
+
...s.score.lines.map(([a, b]) => ({ start: [a[0], a[1]], segments: [{ to: [b[0], b[1]] }], closed: false })),
|
|
38
|
+
...s.score.shapes.flatMap((shape) => allRings(refitRegions(shape.toContours())).map(closedPath)),
|
|
39
|
+
],
|
|
40
|
+
"cut-inner": cut.inner.map(closedPath),
|
|
41
|
+
"cut-outer": cut.outer.map(closedPath),
|
|
42
|
+
};
|
|
43
|
+
const layers = LAYER_ORDER.filter((id) => byId[id].length > 0).map((id) => ({ id, paths: byId[id] }));
|
|
44
|
+
const nb = drawingBounds([{ id: "cut-outer", paths: ringsOf(nominal).outer.map(closedPath) }]);
|
|
45
|
+
return {
|
|
46
|
+
layers,
|
|
47
|
+
bounds: drawingBounds(layers),
|
|
48
|
+
nominal: [nb.max[0] - nb.min[0], nb.max[1] - nb.min[1]],
|
|
49
|
+
kerf,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export default { drawing };
|