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
@@ -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
- const m = buildPhysical(display, opts);
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) — there is no hidden "default" record any more.
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: these scans' tangent normals barely leave +Z (a
77
- // ~1% tilt), which reads as flat under an environment map. `grain` is the
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. `declaresMaterials` still counts
31
- // only a NAMED material: this default never makes a part "declare" one.
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
+ }
@@ -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 { exportablePartNames, partLabel } from "./export-select.js";
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
- // Print frames for the layer-line pattern: the display → export map of the
948
- // geometry just delivered, so layers run the way the part is printed rather
949
- // than the way it is displayed. Each frame describes the delivered mesh
950
- // (which a later pose-only repair only moves), and only sub-parts whose
951
- // material draws layer lines have one — which includes every
952
- // sub-part naming no material, since realistic mode shows those as PLA
953
- // (resolve.js). One without a place() is identity at once; one with it
954
- // costs two geometry-free probe builds.
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].display).params.pattern === "layer-lines"; } catch { return false; }
975
+ try { return resolveMaterial(realisticDisplay(part.parts[n])).params.pattern === "layer-lines"; } catch { return false; }
957
976
  }));
958
- // LAZY: a delivery only records the view and params it was built at; the
959
- // viewer pulls the frames (computePrintFrames) when it is about to draw the
960
- // realistic look — live, loading, or borrowed by a capture — so a CAD-only
961
- // session never probes (viewer.js setPrintFrameSource). The snapshot keeps
962
- // a late computation describing the mesh actually delivered.
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 undrawnFrames = new Map(); // sub-part -> { view, params } at its delivery
965
- function recordPrintFrames(names) {
966
- const wanted = names.filter((n) => layerLined.has(n));
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 at = { view: view(), params: { ...params } };
969
- for (const n of wanted) undrawnFrames.set(n, at);
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 computePrintFrames() {
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
- if (resolved) printFrames[n] = printFrameMatrix(part.parts[n], { view: at.view, ...resolved });
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.setPrintFrameSource?.(computePrintFrames);
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
- recordPrintFrames(data.meshes.map((m) => m.name));
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 NOTHING. Cache and fast-path stamps must describe
1079
- // geometry built at the live params, and this delivery wasn't; the
1080
- // fast-path stamp is dropped too, so a later pose-only repair can
1081
- // never re-pose this newer geometry off an older delivery's stamp.
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
- listExportableParts: () =>
1393
- exportablePartNames(part, params).map((name) => ({ name, label: partLabel(part, name) })),
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 };