partforge 0.134.0 → 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.
@@ -334,9 +334,10 @@ meta: { title: "…", units: "mm", environment: "studio" },
334
334
  - `material` — a preset id from the table below. Without one (or with one the
335
335
  library does not know) the sub-part keeps the CAD view's look — its `color`,
336
336
  else the viewer's blue-grey — and **realistic mode shows it as a PLA print**
337
- (`pla-print`'s finish and layer lines) in that same colour. So an untouched
338
- part looks printed in realistic mode; name a material when it is made some
339
- other way. The PLA look is realistic-only: the CAD view, export colours and
337
+ (`pla-print`'s finish and layer lines) in that same colour — except a laser
338
+ `sheetPart`, which shows its stock instead (below). So an untouched part looks
339
+ printed in realistic mode; name a material when it is made some other way. The
340
+ PLA look is realistic-only: the CAD view, export colours and
340
341
  `declaresMaterials` are unaffected, and a part that names no material is not
341
342
  treated as declaring one.
342
343
  - `color` — the base colour (`0xRRGGBB`). With a preset it is the TINT. Presets
@@ -349,6 +350,23 @@ meta: { title: "…", units: "mm", environment: "studio" },
349
350
  - **One material per sub-part.** Something that needs two finishes (a knurled
350
351
  grip in rubber on an aluminium body) is two sub-parts.
351
352
 
353
+ **Sheet parts default to their stock.** A laser `sheetPart` that names no material (or
354
+ one the library does not know) is not drawn as a PLA print: realistic mode shows it as the
355
+ sheet its stock label names — the first row with a stem that starts a word of the label,
356
+ ignoring case (so `Plexiglas`, `Acrylite` and `poly-carbonate` match; `perplexing` does not):
357
+
358
+ | A word of the stock label starts with | Realistic look |
359
+ | --- | --- |
360
+ | `acryl`, `perspex`, `plexi`, `pmma`, `methacryl`, `polycarb`, `lexan`, `makrolon`, `lucite` | `clear-acrylic` |
361
+ | anything else — plywood, birch, basswood, poplar, MDF, hardboard — or no readable label | `plywood` |
362
+
363
+ Stock that is neither wood nor acrylic — felt, leather, card, cork — takes the `plywood`
364
+ look too, charred edges and all. A `color` tints the look, as it tints any preset (a
365
+ stained plywood, a coloured acrylic); the CAD view still shows the `color` itself. Only a
366
+ string label is read: a stock written as a `(p, d)` function counts as unreadable. Name
367
+ `display.material` to choose the look yourself — for such stock, or any other. Like the
368
+ PLA look, this is realistic-only and never makes a part declare a material.
369
+
352
370
  **Where it shows.** The CAD view (with feature lines) shows each material's
353
371
  colour, flattened so dark materials stay readable. **Realistic** mode — the
354
372
  viewer's toggle — shows the full material under environment lighting, with a
@@ -365,6 +383,15 @@ displayed. If the lines run the wrong way, fix the export pose with `place()`
365
383
  fixed to the sub-part, so they never slide when the camera or an animation
366
384
  moves.
367
385
 
386
+ **Laser-cut wood.** In realistic mode a laser `sheetPart` in a wood — `plywood`, `oak` or
387
+ `walnut`, named or its stock's default (so a plywood sheet that names no material burns too)
388
+ — shows what the laser did: its cut edges are charred, darker on thicker stock (on
389
+ `plywood` the plies show through), and its engraving and score lines are scorched, while its
390
+ faces stay wood. There is nothing to set, and it is still one material: the char follows
391
+ from the sheet and the wood it is drawn in. It needs the sheet's own frame, so a sheet part
392
+ with a custom `build` or its own `place` shows plain wood. The CAD view and every export are
393
+ unchanged.
394
+
368
395
  **`textureScale` is millimetres, and what it measures depends on the preset's
369
396
  pattern** — so a value copied from one preset family is wrong on another (0.2
370
397
  on oak shrinks the grain to a 0.2 mm tile, i.e. invisible noise):
@@ -373,7 +400,7 @@ on oak shrinks the grain to a 0.2 mm tile, i.e. invisible noise):
373
400
  | --- | --- | --- | --- |
374
401
  | layer lines | `pla-print`, `petg-print` | layer height | 0.2 |
375
402
  | SLS grain | `nylon-sls` | grain size | 0.15 |
376
- | wood | `oak`, `walnut` | size of one texture tile (the grain repeats every this many mm) | 250 (`oak`), 400 (`walnut`) |
403
+ | wood | `oak`, `walnut`, `plywood` | size of one texture tile (the grain repeats every this many mm) | 250 (`oak`), 400 (`walnut`), 150 (`plywood`) |
377
404
  | carbon weave | `carbon-fiber` | size of one texture tile | 48 |
378
405
 
379
406
  Presets without a pattern ignore it.
@@ -409,6 +436,7 @@ the viewer's blue-grey.
409
436
  | `rubber` | Rubber | yes | Matte elastomer: gaskets, feet, grips; tint with `color`. |
410
437
  | `oak` | Oak | — | Light oak with open grain. |
411
438
  | `walnut` | Walnut | — | Dark oiled walnut. |
439
+ | `plywood` | Birch plywood | — | Birch plywood sheet: a pale, fine-grained face; its plies show on laser-cut edges. |
412
440
  | `carbon-fiber` | Carbon fibre | — | 2x2 twill carbon fibre under clear coat. |
413
441
 
414
442
  **Environments** (`meta.environment`, default `studio`; viewers can switch):
@@ -1808,8 +1836,8 @@ needing the kernel is `(k, p, d) => …`; a plain value is a literal or `(p, d)
1808
1836
  `material`, `thickness` and `profile` are required; `score`, `engrave`, `pose`,
1809
1837
  `process` (default `"laser"`) are optional; `label`, `views`, `display`, `export`,
1810
1838
  `enabled`, `exportable`, `reference` pass through; your own `place` runs after the
1811
- pose. `build` is supplied — passing one throws, as do `kerf`, `outline`/`cut` (use
1812
- `profile`) and `quantity` (the kit counts identical pieces).
1839
+ pose. `build` is supplied — passing one throws, as do `kerf`, `outline`/`cut` and
1840
+ `quantity`; each error names the fix.
1813
1841
 
1814
1842
  ### Cut, score and engrave
1815
1843
 
@@ -1898,7 +1926,7 @@ PRINT pose, `place` it for display, and cut the slot where the tongue lands:
1898
1926
  | `verify-process-sheets-only` | warning | `verify.process` is set but every exportable part is a sheet part |
1899
1927
  | `laser-thickness-range` | warning | a laser sheet is thinner than 0.5 mm or thicker than 12 mm |
1900
1928
 
1901
- **Verify** runs the laser checks on every sheet part. They are
1929
+ **Verify** runs the laser checks on every sheet part, as
1902
1930
  *volunteered* warnings: none counts toward `declared`/`evaluated`, so none makes
1903
1931
  `verify.ok` true on its own. Declare one in `expect` to make it count:
1904
1932
 
@@ -1928,12 +1956,13 @@ A host downloads sheet parts as one ZIP, the **cut & print kit** (`format: "bund
1928
1956
 
1929
1957
  - One thickness per joint: fingers, tabs and T-slots join panels cut from the
1930
1958
  same sheet.
1931
- - Poses are rigid and axis-aligned: `face` and `up` are the six axis words. A
1932
- panel at an angle is an author `place` after the pose, or a printed part.
1959
+ - Poses are rigid and axis-aligned: `face` and `up` are the six axis words. An
1960
+ angled panel is an author `place` after the pose (realistic mode then shows
1961
+ no laser burns), or a printed part.
1933
1962
  - No bends, folds, living hinges or grain direction: `folds`, `bends` and
1934
1963
  `grain` are reserved keys and throw.
1935
- - Cut and score are vector lines; engraving is filled regions. There is no raster
1936
- image engraving.
1964
+ - Cut and score are vector lines; engraving is filled regions, never a raster
1965
+ image.
1937
1966
  - The laser checks are warnings. Sharp-corner shrinking can over-report at acute
1938
1967
  tips, and a web within 0.05 mm of the floor can read as passing.
1939
1968
 
@@ -1986,7 +2015,7 @@ const hingeSlots = (k, p, d, pose, z) => d.hingeX.flatMap((hx) =>
1986
2015
  [hx - HINGE.tongueX, hx + HINGE.tongueX].map((x) =>
1987
2016
  k.shape2d(tab(p).slot).translate(worldToSheet(pose, [x, p.depth / 2, z]))));
1988
2017
 
1989
- const PLY = { views: ["box"], display: { material: "oak" }, material: "birch plywood", thickness: (p) => p.t };
2018
+ const PLY = { views: ["box"], display: { material: "plywood" }, material: "birch plywood", thickness: (p) => p.t };
1990
2019
  const panel = (name, label, extra = {}) => sheetPart({
1991
2020
  ...PLY, label,
1992
2021
  profile: (k, p, d) => d.box[name].outline, // drawn as seen from outside
@@ -3177,7 +3206,8 @@ addition to) the generated view style button above:
3177
3206
  revert too.
3178
3207
  - `runtime.declaresMaterials` — `true` when any sub-part names a
3179
3208
  `display.material`. A part with none still supports realistic mode (every
3180
- sub-part renders as a PLA print in its CAD colour), so use this to decide
3209
+ sub-part renders as a PLA print in its CAD colour, a laser sheet part as its
3210
+ stock — "Sheet parts default to their stock"), so use this to decide
3181
3211
  whether to surface your own realistic control at all, not whether it works.
3182
3212
  Feature lines are not a preference: they draw whenever `runtime.renderMode`
3183
3213
  reads `"cad"` and never while it reads `"realistic"` — there is no switch
@@ -3665,7 +3695,8 @@ trust handling.
3665
3695
 
3666
3696
  **Appearance** (all warnings) — `unknown-material` (a `display.material` the
3667
3697
  library does not know; the viewer draws it as if it named none — a PLA print
3668
- in realistic mode), `unknown-environment`
3698
+ in realistic mode, or for a laser sheet part its stock's look, per "Sheet parts
3699
+ default to their stock"), `unknown-environment`
3669
3700
  (`meta.environment` not known; realistic mode uses `studio`),
3670
3701
  `material-key-unknown` (a `display` key that is not colour, opacity, material
3671
3702
  or one of the six overrides; ignored), `material-override-clamped` (an override
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.134.0",
3
+ "version": "0.135.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -12,7 +12,9 @@ import { mount } from "./framework/index.js";
12
12
  // open /materials.html. Not part of the production build — check materials.html
13
13
  // by eye after any preset or shader change (see AGENTS.md "Architecture").
14
14
  // Dev-only: the handle is stashed on window so scripts/check-app.mjs can drive
15
- // the embedding contract (runtime.captureCurrent) the way an embedder would.
15
+ // the embedding contract (runtime.captureCurrent) the way an embedder would, and
16
+ // scripts/capture-contact-sheet.mjs can switch views and environments and call
17
+ // runtime.renderViews — removing it breaks both.
16
18
  window.__pfRuntime = mount(materialSwatchesPart, {
17
19
  createWorker: (name) =>
18
20
  new Worker(new URL("./materials-worker.js", import.meta.url), { type: "module", name }),
@@ -1,12 +1,13 @@
1
1
  // src/framework/lint/rules-materials.js
2
2
  // Appearance findings. All WARNINGS by design: a misspelled material must never
3
3
  // block geometry, and the viewer already falls back (resolve.js). Pure — imports
4
- // only the three-free materials data (test/lint-purity.test.js).
4
+ // only three-free materials and sheet data (test/lint-purity.test.js).
5
5
  import { warn } from "./finding.js";
6
6
  import { PRESETS } from "../materials/presets.js";
7
7
  import { ENVIRONMENTS } from "../materials/environments.js";
8
8
  import { resolveMaterial, resolveEnvironmentId, OVERRIDE_RANGES } from "../materials/resolve.js";
9
9
  import { suggest } from "../geometry/op-options.js";
10
+ import { sheetStockLook } from "../materials/sheet-look.js";
10
11
 
11
12
  const listedPresets = () => Object.keys(PRESETS);
12
13
  const subParts = (part) => Object.entries(part?.parts ?? {}).filter(([, sp]) => sp && typeof sp === "object");
@@ -18,9 +19,15 @@ export const MATERIAL_RULES = [
18
19
  id: "unknown-material",
19
20
  run: ({ part }) => issuesOf(part, "unknown-material").map(({ name, value }) => {
20
21
  const near = typeof value === "string" ? suggest(value, listedPresets()) : null;
22
+ // A laser sheet falls back to its stock's look, not to PLA (materials/sheet-look.js) —
23
+ // read from a string label only: a label written as a function takes the default look.
24
+ const sp = part.parts[name];
25
+ const stock = sheetStockLook(sp);
26
+ const why = typeof sp.sheet?.material === "string" ? ", from its stock label" : ": its stock label is a function";
27
+ const fallback = stock ? `in realistic mode \`${stock}\`${why}` : "a PLA print in realistic mode";
21
28
  return warn("unknown-material",
22
29
  `sub-part "${name}" names material ${JSON.stringify(value)}, which is not in the library`,
23
- `${near ? `Did you mean "${near}"? ` : ""}The viewer draws it as if it named no material instead (a PLA print in realistic mode). Known materials: ${listedPresets().join(", ")}.`,
30
+ `${near ? `Did you mean "${near}"? ` : ""}The viewer draws it as if it named no material instead (${fallback}). Known materials: ${listedPresets().join(", ")}.`,
24
31
  `parts.${name}.display.material`);
25
32
  }),
26
33
  },
@@ -66,6 +66,9 @@ the sliced primary and gain-map buffers now start with SOI (`FF D8`) and end wit
66
66
  | `pattern-walnut-color.jpg` | Poly Haven "Dark Wood" (colour; API key `Diffuse`) | https://polyhaven.com/a/dark_wood — file https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/dark_wood/dark_wood_diff_1k.jpg | 2026-09-24 | 1K JPG, sRGB colour -> `node scripts/bake-environments.mjs --texture dark_wood_diff_1k.jpg pattern-walnut-color.jpg` (q82). Scanned tile 2000 mm (`api.polyhaven.com/info/dark_wood` dimensions). |
67
67
  | `pattern-walnut-normal.jpg` | Poly Haven "Dark Wood" (OpenGL normal map; API key `nor_gl`) | https://polyhaven.com/a/dark_wood — file https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/dark_wood/dark_wood_nor_gl_1k.jpg | 2026-09-24 | 1K JPG, OpenGL (+Y up) tangent normals, kept as data -> `node scripts/bake-environments.mjs --texture dark_wood_nor_gl_1k.jpg pattern-walnut-normal.jpg --data --quality 95` (4:4:4 chroma, no grayscale/tint; q95 because the map is nearly flat and the viewer amplifies it, so q90's 8x8 blocks showed). |
68
68
  | `pattern-walnut-rough.jpg` | Poly Haven "Dark Wood" (roughness; API key `Rough`) | https://polyhaven.com/a/dark_wood — file https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/dark_wood/dark_wood_rough_1k.jpg | 2026-09-24 | 1K JPG, grayscale data -> `node scripts/bake-environments.mjs --texture dark_wood_rough_1k.jpg pattern-walnut-rough.jpg --gray` (source is neutral grey; channel means unchanged). |
69
+ | `pattern-plywood-color.jpg` | Poly Haven `plywood` (colour; API key `Diffuse`) | https://polyhaven.com/a/plywood — file https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/plywood/plywood_diff_1k.jpg | 2026-09-30 | 1K JPG, sRGB colour -> `node scripts/bake-environments.mjs --texture plywood_diff_1k.jpg pattern-plywood-color.jpg --modulate 1.85,0.9 --flatten 0.55 --quality 85` (lightened 1.85x and saturation 0.9 from the scan's mid brown to a pale birch; grain contrast 0.55, since lightening doubles it). One continuous veneer, fine even grain running across the image (`grain: "u"`). Chosen 2026-09-30 from five approved CC0 candidates: ambientCG Wood090A, Wood090B, Wood091A and Wood091B (https://ambientcg.com/a/<id>) are edge-glued strip panels with visible parallel joints, not a veneer face; Wood087/088/089 (checked earlier the same day) show plywood edges. |
70
+ | `pattern-plywood-normal.jpg` | Poly Haven `plywood` (OpenGL normal map; API key `nor_gl`) | https://polyhaven.com/a/plywood — file https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/plywood/plywood_nor_gl_1k.jpg | 2026-09-30 | 1K JPG, OpenGL (+Y up) tangent normals, kept as data -> `node scripts/bake-environments.mjs --texture plywood_nor_gl_1k.jpg pattern-plywood-normal.jpg --data --quality 95` (4:4:4 chroma, no grayscale/tint). The map leans slightly (mean red 118/255, a ~4° tilt along the grain) as the source does; kept as authored. |
71
+ | `pattern-plywood-rough.jpg` | Poly Haven `plywood` (roughness; API key `Rough`) | https://polyhaven.com/a/plywood — file https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/plywood/plywood_rough_1k.jpg | 2026-09-30 | 1K JPG, grayscale data -> `node scripts/bake-environments.mjs --texture plywood_rough_1k.jpg pattern-plywood-rough.jpg --gray` (mean 191/255 = 0.749, the preset's `roughnessMean`). |
69
72
  | `ground-maple-color.jpg` | Poly Haven "Oak Veneer 01" (colour; API key `Diffuse`) | https://polyhaven.com/a/oak_veneer_01 — file https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/oak_veneer_01/oak_veneer_01_diff_1k.jpg | 2026-09-24 | Workshop floor, "unfinished maple": `node scripts/bake-environments.mjs --texture oak_veneer_01_diff_1k.jpg ground-maple-color.jpg --modulate 1.5,0.7 --quality 85` (lightened 1.5x, saturation 0.7). |
70
73
  | `ground-maple-normal.jpg` | Poly Haven "Oak Veneer 01" (OpenGL normal map; API key `nor_gl`) | https://polyhaven.com/a/oak_veneer_01 — file https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/oak_veneer_01/oak_veneer_01_nor_gl_1k.jpg | 2026-09-24 | Workshop floor relief: `node scripts/bake-environments.mjs --texture oak_veneer_01_nor_gl_1k.jpg ground-maple-normal.jpg --data --quality 92`. A separate file from `pattern-oak-normal.jpg` so the floor and the oak material never share (and dispose) one texture. |
71
74
  | `ground-pei.jpg` | None — procedural | n/a | n/a | Gold-brown fine-grain speckle, no external source. First attempt tinted ambientCG "Plastic 010"'s colour map (near-uniform, stdev < 1/channel — visibly flat, no grain, when actually viewed) gold-brown; that colour map carries no usable grain at all (its roughness map does, which is why `ground-pei-rough.jpg` below is still Plastic010's roughness). Checked ambientCG alternatives (Metal049A — also flat; Granite006A — real speckle but reads as rock, not powder-coat) and found nothing closer. Generated in-baker instead, same procedural precedent as `pattern-carbon.jpg`: per-pixel triangular-distributed noise around a `168,134,82` bronze-gold base (asymmetric per channel so flecks warm rather than grey), then a 0.5px blur to turn single-pixel noise into soft grains -> `node scripts/bake-environments.mjs --speckle ground-pei.jpg --tint 168,134,82`. (Re-baked 2026-09-24 from `196,154,82`: darker and less saturated, closer to a Bambu textured-PEI plate's muted bronze-gold.) |
@@ -76,9 +79,11 @@ the sliced primary and gain-map buffers now start with SOI (`FF D8`) and end wit
76
79
  | `pattern-carbon.jpg` | None — procedural | n/a | n/a | ambientCG has no CC0 carbon-fiber weave (searched "carbon"; only unrelated fabric hits). Generated in-baker: a 256x256 2x2 twill tile (32 threads/tile, alternating dark/light grey diagonal bands) -> `node scripts/bake-environments.mjs --carbon pattern-carbon.jpg`. No external source/licence to record. |
77
80
 
78
81
  ambientCG publishes every asset under CC0 (https://ambientcg.com/, "CC0 Public Domain
79
- license", checked 2026-09-24). The wood sets (`pattern-oak-*`, `pattern-walnut-*`,
80
- `ground-maple-*`) are Poly Haven textures, CC0 like its HDRIs above
81
- (https://polyhaven.com/license, checked 2026-09-24); the exact 1K JPG URLs came
82
+ license", checked 2026-09-24; https://ambientcg.com/license, "Creative Commons CC0 1.0
83
+ Universal License", checked again 2026-09-30 for the plywood candidates). The wood sets
84
+ (`pattern-oak-*`, `pattern-walnut-*`, `pattern-plywood-*`, `ground-maple-*`) are Poly Haven
85
+ textures, CC0 like its HDRIs above (https://polyhaven.com/license, checked 2026-09-24, and
86
+ again 2026-09-30 for `plywood`); the exact 1K JPG URLs came
82
87
  from `https://api.polyhaven.com/files/<slug>` (keys `Diffuse`, `nor_gl`, `Rough`).
83
88
  The presets tile them smaller than the scanned size — see the comment on `oak` in
84
89
  `presets.js` and on `workshop` in `environments.js`.
@@ -86,5 +91,6 @@ The presets tile them smaller than the scanned size — see the comment on `oak`
86
91
  ## Budgets
87
92
 
88
93
  Environments stay under 1.5 MB (largest: `env-workshop.jpg` at ~398 KB); ground/pattern
89
- textures stay under 400 KB (largest: `pattern-oak-rough.jpg` at ~218 KB). All committed
94
+ textures stay under 400 KB (largest: `ground-concrete-normal.jpg` at ~380 KB; the largest
95
+ pattern map is `pattern-plywood-normal.jpg` at ~337 KB). All committed
90
96
  outputs are well inside both ceilings — see `test/framework/materials-assets.test.js`.
@@ -25,6 +25,9 @@ const URLS = {
25
25
  "pattern-walnut-color.jpg": new URL("./assets/pattern-walnut-color.jpg", import.meta.url).href,
26
26
  "pattern-walnut-normal.jpg": new URL("./assets/pattern-walnut-normal.jpg", import.meta.url).href,
27
27
  "pattern-walnut-rough.jpg": new URL("./assets/pattern-walnut-rough.jpg", import.meta.url).href,
28
+ "pattern-plywood-color.jpg": new URL("./assets/pattern-plywood-color.jpg", import.meta.url).href,
29
+ "pattern-plywood-normal.jpg": new URL("./assets/pattern-plywood-normal.jpg", import.meta.url).href,
30
+ "pattern-plywood-rough.jpg": new URL("./assets/pattern-plywood-rough.jpg", import.meta.url).href,
28
31
  "pattern-carbon.jpg": new URL("./assets/pattern-carbon.jpg", import.meta.url).href,
29
32
  };
30
33
 
@@ -5,7 +5,10 @@
5
5
  // they move with the sub-part under animation and never slide when the camera
6
6
  // moves. Layer lines additionally map object space through `pfPrintFrame`
7
7
  // (print-frame.js: display→export), so they run the way the part will print.
8
+ // A laser-cut wood sheet's burn (applyBurn) composes over the wood pass in the sheet's
9
+ // canonical frame (sheet-look.js).
8
10
  import * as THREE from "three";
11
+ import { BURN, srgbToLinear } from "./sheet-look.js";
9
12
 
10
13
  const VERT_DECL = "varying vec3 vPfObjPos;\nvarying vec3 vPfObjNormal;\nvarying vec3 vPfPrintUpView;\nuniform mat4 pfPrintFrame;\n";
11
14
  // Wood's triplanar normal map builds its normal in OBJECT space; the fragment
@@ -354,3 +357,103 @@ export function applyBrushFrame(material) {
354
357
  material.needsUpdate = true;
355
358
  return material;
356
359
  }
360
+
361
+ // --- laser burns ------------------------------------------------------------------
362
+ // A laser-cut sheet in a wood shows what the laser did to it (sheet-look.js decides which
363
+ // sub-parts, and mount hands the viewer each one's canonical frame): cut walls charred,
364
+ // engrave and score floors scorched, faces untouched. Composes over the wood pass the way
365
+ // applyBrushFrame does, injecting at three points in three's meshphysical order:
366
+ // (a) colour + roughness, before <normal_fragment_begin>: after wood's body (injected
367
+ // after <roughnessmap_fragment>) and <metalnessmap_fragment>. A second replace of
368
+ // <roughnessmap_fragment> would land IN FRONT of wood's body, so it is not reused.
369
+ // pfBurnWall / pfBurnFloor are declared at main() scope, like pfLayer, for (b), (c).
370
+ // (b) the normal, before <emissivemap_fragment>: after wood's normal map and the
371
+ // clearcoat normals. Char flattens the wood's relief: the wood normal fades toward
372
+ // the geometric one (nonPerturbedNormal, set by <normal_fragment_begin>), keeping
373
+ // BURN.relief of it.
374
+ // (c) clearcoat, after <lights_physical_fragment>, which assigns material.clearcoat:
375
+ // walnut's lacquer comes off the char.
376
+ // Every snippet sits between `// pf-burn {` and `// } pf-burn` lines: that is how the tests
377
+ // prove a burning program is the plain wood program plus these and nothing else. The
378
+ // vertex stage is untouched — the pass reads the varyings every pattern already has. With
379
+ // pfSheetT = 0 (no frame delivered yet) it draws plain wood. No noise term: an unfiltered
380
+ // striation would shimmer at part scale (the layer-lines lesson above).
381
+ const glf = (x) => (Number.isInteger(x) ? x.toFixed(1) : String(x));
382
+ const CHARCOAL = srgbToLinear(BURN.charcoal);
383
+ const burnBlock = (glsl) => `// pf-burn {\n${glsl}\n// } pf-burn`;
384
+
385
+ const BURN_FRAG_DECL = burnBlock(`uniform mat4 pfSheetFrame;
386
+ uniform float pfSheetT;
387
+ uniform float pfPlies;
388
+ uniform vec3 pfFaceAvg;
389
+ // 1 on a cross ply (an odd one), 0 on a face-grain ply: the square wave box-filtered over
390
+ // the pixel (its integral, differenced across fwidth), so plies too thin to draw average
391
+ // out instead of shimmering.
392
+ float pfCrossPlyI(float x) { return floor(x * 0.5) + max(0.0, fract(x * 0.5) * 2.0 - 1.0); }
393
+ float pfCrossPly(float x) {
394
+ float w = max(fwidth(x), 1e-4);
395
+ return (pfCrossPlyI(x + 0.5 * w) - pfCrossPlyI(x - 0.5 * w)) / w;
396
+ }`);
397
+
398
+ const BURN_FRAG_BODY = burnBlock(`float pfBurnWall = 0.0;
399
+ float pfBurnFloor = 0.0;
400
+ if (pfSheetT > 0.0) {
401
+ vec3 pfC = (pfSheetFrame * vec4(vPfObjPos, 1.0)).xyz;
402
+ vec3 pfCn = normalize(mat3(pfSheetFrame) * vPfObjNormal);
403
+ pfBurnWall = 1.0 - smoothstep(${glf(BURN.wallNz[0])}, ${glf(BURN.wallNz[1])}, abs(pfCn.z));
404
+ pfBurnFloor = step(0.5, pfCn.z) * step(pfC.z, pfSheetT - ${glf(BURN.floorDepth)});
405
+ float pfK = mix(${glf(BURN.kThin)}, ${glf(BURN.kThick)}, clamp((pfSheetT - ${glf(BURN.tThin)}) / ${glf(BURN.tThick - BURN.tThin)}, 0.0, 1.0));
406
+ float pfZ = clamp(pfC.z / pfSheetT, 0.0, 1.0);
407
+ vec3 pfFace = diffuse * pfFaceAvg;
408
+ // Per channel, not the vector scaled by one luminance ratio (sheet-look.js's burnAlbedo,
409
+ // this shader's twin, explains why): a channel where charOfDark·face is already below raw
410
+ // charcoal chars toward ITS OWN darker value, so no channel can come out lighter than it
411
+ // went in.
412
+ vec3 pfChar = min(vec3(${CHARCOAL.map((c) => c.toFixed(6)).join(", ")}), ${glf(BURN.charOfDark)} * pfFace);
413
+ vec3 pfEdge = mix(pfFace, pfChar, min(1.0, pfK + ${glf(BURN.exit)} * (1.0 - pfZ)));
414
+ if (pfPlies > 0.0) pfEdge *= mix(1.0, ${glf(BURN.crossPly)}, pfCrossPly(pfZ * pfPlies));
415
+ vec3 pfMark = mix(pfFace, pfChar, pfK - ${glf(BURN.engraveLess)});
416
+ // The grain survives the char (sheet-look.js's burnTexel is the twin): pfEdge and pfMark
417
+ // are what the face AVERAGE chars to, and each texel keeps its ratio to that average,
418
+ // blended in by BURN.grain. At 1, a pure multiply, the average chars exactly as above and
419
+ // no texel comes out lighter than it went in.
420
+ vec3 pfGrain = mix(vec3(1.0), diffuseColor.rgb / max(pfFace, vec3(1e-4)), ${glf(BURN.grain)});
421
+ pfEdge *= pfGrain;
422
+ pfMark *= pfGrain;
423
+ diffuseColor.rgb = mix(diffuseColor.rgb, pfEdge, pfBurnWall);
424
+ diffuseColor.rgb = mix(diffuseColor.rgb, pfMark, pfBurnFloor);
425
+ roughnessFactor = mix(roughnessFactor, ${glf(BURN.roughness)}, max(pfBurnWall, pfBurnFloor));
426
+ }`);
427
+
428
+ const BURN_FRAG_NORMAL = burnBlock(`normal = normalize(mix(normal, nonPerturbedNormal, max(pfBurnWall, pfBurnFloor) * ${glf(1 - BURN.relief)}));`);
429
+
430
+ const BURN_FRAG_CLEARCOAT = burnBlock(`#ifdef USE_CLEARCOAT
431
+ material.clearcoat *= 1.0 - max(pfBurnWall, pfBurnFloor);
432
+ #endif`);
433
+
434
+ // `faceAvg` is the preset's own colour (0xRRGGBB) — its texture's average, never the tint:
435
+ // the tint arrives as `diffuse`, so the shader's face colour is diffuse · pfFaceAvg.
436
+ export function applyBurn(material, { faceAvg }) {
437
+ const uniforms = material.userData.patternUniforms;
438
+ if (!uniforms?.pfGrainSwap) return material; // wood only: the burn draws over the wood pass
439
+ Object.assign(uniforms, {
440
+ pfSheetFrame: { value: new THREE.Matrix4() },
441
+ pfSheetT: { value: 0 },
442
+ pfPlies: { value: 0 },
443
+ pfFaceAvg: { value: new THREE.Color(faceAvg) },
444
+ });
445
+ const prev = material.onBeforeCompile;
446
+ const prevKey = material.customProgramCacheKey;
447
+ material.onBeforeCompile = (shader, renderer) => {
448
+ prev?.call(material, shader, renderer); // applyPattern's hook copies `uniforms` — burn uniforms included
449
+ shader.fragmentShader = shader.fragmentShader
450
+ .replace("#include <common>", `#include <common>\n${BURN_FRAG_DECL}`)
451
+ .replace("#include <normal_fragment_begin>", `${BURN_FRAG_BODY}\n#include <normal_fragment_begin>`)
452
+ .replace("#include <emissivemap_fragment>", `${BURN_FRAG_NORMAL}\n#include <emissivemap_fragment>`)
453
+ .replace("#include <lights_physical_fragment>", `#include <lights_physical_fragment>\n${BURN_FRAG_CLEARCOAT}`);
454
+ };
455
+ const base = prevKey && prevKey !== THREE.Material.prototype.customProgramCacheKey ? prevKey.call(material) : "";
456
+ material.customProgramCacheKey = () => `${base}|pf-burn`;
457
+ material.needsUpdate = true;
458
+ return material;
459
+ }
@@ -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";
@@ -863,6 +864,12 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
863
864
  // to its own worker in parallel, so a mixed part previews its plain sub-parts at
864
865
  // Manifold speed while only the OCCT-routed ones wait. The return value is
865
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
866
873
  const loop = createRegenLoop({
867
874
  missingParts,
868
875
  send: (missing) => {
@@ -870,6 +877,8 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
870
877
  // for the overlay; this build reports the poses repaired in its own cycle.
871
878
  lastGen = { skipped: needed.length - missing.length, rebuilt: missing.length, posed: pendingPosed.size };
872
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);
873
882
  ui.showBusy("generating");
874
883
  const backends = backendPolicy.backendsFor(params);
875
884
  let jobs = 0;
@@ -951,44 +960,58 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
951
960
  }
952
961
  }
953
962
 
954
- // Print frames for the layer-line pattern: the display → export map of the
955
- // geometry just delivered, so layers run the way the part is printed rather
956
- // than the way it is displayed. Each frame describes the delivered mesh
957
- // (which a later pose-only repair only moves), and only sub-parts whose
958
- // material draws layer lines have one — which includes every
959
- // sub-part naming no material, since realistic mode shows those as PLA
960
- // (resolve.js). One without a place() is identity at once; one with it
961
- // 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).
962
974
  const layerLined = new Set(Object.keys(part.parts).filter((n) => {
963
- 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; }
964
976
  }));
965
- // LAZY: a delivery only records the view and params it was built at; the
966
- // viewer pulls the frames (computePrintFrames) when it is about to draw the
967
- // realistic look — live, loading, or borrowed by a capture — so a CAD-only
968
- // session never probes (viewer.js setPrintFrameSource). The snapshot keeps
969
- // 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.
970
984
  const printFrames = {};
971
- const undrawnFrames = new Map(); // sub-part -> { view, params } at its delivery
972
- function recordPrintFrames(names) {
973
- 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));
974
989
  if (!wanted.length) return;
975
- const at = { view: view(), params: { ...params } };
976
- for (const n of wanted) undrawnFrames.set(n, at);
977
- viewer.invalidatePrintFrames?.();
990
+ for (const n of wanted) undrawnFrames.set(n, dispatched.get(n) ?? null); // null: built at params nobody recorded
991
+ viewer.invalidateFrames?.();
978
992
  }
979
- function computePrintFrames() {
993
+ function computeFrames() {
980
994
  const resolvedFor = new Map(); // one resolveParams per delivery, not per sub-part
981
995
  for (const [n, at] of undrawnFrames) {
982
- if (!resolvedFor.has(at)) {
996
+ if (at && !resolvedFor.has(at)) {
983
997
  try { resolvedFor.set(at, resolveParams(part, at.params)); } catch { resolvedFor.set(at, null); } // diagnosed by the build
984
998
  }
985
- const resolved = resolvedFor.get(at);
986
- 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
+ }
987
1010
  }
988
1011
  undrawnFrames.clear();
989
- return { ...printFrames };
1012
+ return { print: { ...printFrames }, sheet: { ...sheetFrames } };
990
1013
  }
991
- viewer.setPrintFrameSource?.(computePrintFrames);
1014
+ viewer.setFrameSource?.(computeFrames);
992
1015
 
993
1016
  // Sub-parts whose latest fresh delivery had zero triangles (see the `meshes` case).
994
1017
  const emptySubParts = new Set();
@@ -1051,7 +1074,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
1051
1074
  // delivered, which buildDone() true guarantees is at the live params.
1052
1075
  fastPath.recordDelivered(m.name);
1053
1076
  }
1054
- recordPrintFrames(data.meshes.map((m) => m.name));
1077
+ recordFrames(data.meshes.map((m) => m.name));
1055
1078
  // A split dispatch answers in two meshes replies; the busy spinner
1056
1079
  // stays up until the view has everything (the other worker's job may
1057
1080
  // still be running — often OCCT, the slow one).
@@ -1082,10 +1105,14 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
1082
1105
  } else if (lastAnimApplyVersion === loop.version()) {
1083
1106
  // Stale ONLY because animation frames kept bumping the version:
1084
1107
  // show the delivered meshes anyway — that IS best-effort playback —
1085
- // but record NOTHING. Cache and fast-path stamps must describe
1086
- // geometry built at the live params, and this delivery wasn't; the
1087
- // fast-path stamp is dropped too, so a later pose-only repair can
1088
- // 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.
1089
1116
  // A user edit mid-play pauses playback and bumps the version WITHOUT
1090
1117
  // touching lastAnimApplyVersion, so a genuinely user-stale result
1091
1118
  // fails this test and is discarded exactly as before.
@@ -1093,6 +1120,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
1093
1120
  viewer.setSubGeometry(m.name, m);
1094
1121
  fastPath.forget(m.name);
1095
1122
  }
1123
+ recordFrames(data.meshes.map((m) => m.name));
1096
1124
  ui.hideBusy();
1097
1125
  refreshView();
1098
1126
  }
@@ -9,6 +9,11 @@
9
9
  // `sp.sheet.generatedBuild` is the build made here; an author who replaces `build`
10
10
  // afterwards is detectable as `sp.build !== sp.sheet.generatedBuild`.
11
11
  //
12
+ // Likewise `sp.sheet.generatedPlace` is the place made here (the pose, then the author's
13
+ // own) and `sp.sheet.place` the author's own place, or null: the realistic look
14
+ // (materials/sheet-look.js) trusts a sheet's canonical frame only with no author place
15
+ // and `sp.place === sp.sheet.generatedPlace`.
16
+ //
12
17
  // Placement (decision 10): the pose applies for display AND export, then the author's
13
18
  // own `place` — so STEP/3MF/STL files of a sheet forge come out assembled.
14
19
  import { RENAMED_KEYS, RESERVED_KEYS, SHEET_KEYS, SUBPART_PASSTHROUGH_KEYS } from "./constants.js";
@@ -67,32 +72,42 @@ export function sheetPart(spec) {
67
72
  const s = resolveSheet(k, sub, p, d);
68
73
  return (processById(s.process).preview ?? sheetPreview)(k, s);
69
74
  };
75
+
76
+ // Placement (decision 10): the pose applies for display AND export, then the author's own
77
+ // place. Made BEFORE the record is frozen, so the record can name it. Everything the
78
+ // place reads is taken NOW, into the same values the record freezes — never read off
79
+ // `spec` later, which an author may reuse and change for the next panel: the build and
80
+ // the burn's frame both read the frozen record, and a place that read the spec live would
81
+ // move the panel away from them.
82
+ const pose = spec.pose ?? null;
83
+ const thickness = spec.thickness;
84
+ const authorPlace = spec.place ?? null;
85
+ const generatedPlace = pose !== null || authorPlace ? (solid, ctx) => {
86
+ const resolved = typeof pose === "function" ? pose(ctx.p, ctx.d) : pose;
87
+ let posed = solid;
88
+ if (resolved !== null) {
89
+ const reason = validatePose(resolved);
90
+ if (reason) throw new Error(`sheet pose: ${reason}`);
91
+ const t = typeof thickness === "function" ? thickness(ctx.p, ctx.d) : thickness;
92
+ posed = applyPose(solid, resolved, t);
93
+ }
94
+ return authorPlace ? authorPlace(posed, ctx) : posed;
95
+ } : null;
96
+
70
97
  const sheet = Object.freeze({
71
98
  process,
72
99
  material: spec.material,
73
- thickness: spec.thickness,
100
+ thickness,
74
101
  profile: spec.profile,
75
102
  score: spec.score ?? null,
76
103
  engrave: spec.engrave ?? null,
77
- pose: spec.pose ?? null,
104
+ pose,
105
+ place: authorPlace,
78
106
  generatedBuild,
107
+ generatedPlace,
79
108
  });
80
109
  sub.build = generatedBuild;
81
-
82
- const authorPlace = spec.place;
83
- if (sheet.pose !== null || authorPlace) {
84
- sub.place = (solid, ctx) => {
85
- const pose = typeof sheet.pose === "function" ? sheet.pose(ctx.p, ctx.d) : sheet.pose;
86
- let posed = solid;
87
- if (pose !== null) {
88
- const reason = validatePose(pose);
89
- if (reason) throw new Error(`sheet pose: ${reason}`);
90
- const t = typeof sheet.thickness === "function" ? sheet.thickness(ctx.p, ctx.d) : sheet.thickness;
91
- posed = applyPose(solid, pose, t);
92
- }
93
- return authorPlace ? authorPlace(posed, ctx) : posed;
94
- };
95
- }
110
+ if (generatedPlace) sub.place = generatedPlace;
96
111
  sub.sheet = sheet;
97
112
  return sub;
98
113
  }
@@ -12,6 +12,7 @@ import { ensureBoxUVs } from "./materials/uv.js";
12
12
  import { loadEnvironmentRig } from "./materials/environment.js";
13
13
  import { assetUrl } from "./materials/assets.js";
14
14
  import { resolveEnvironmentId } from "./materials/resolve.js";
15
+ import { burnsFor, realisticDisplay } from "./materials/sheet-look.js";
15
16
  import { neutralToneMapToSrgb8 } from "./materials/tonemap-readback.js";
16
17
  import { createCutaway } from "./cutaway.js";
17
18
  import { CUTAWAY_OVERLAY_RENDER_ORDER } from "./cutaway-render.js";
@@ -591,9 +592,11 @@ export function createViewer(container, part) {
591
592
  const textureCache = new Map(); // asset file name -> Texture
592
593
  const textureLoader = new THREE.TextureLoader();
593
594
  let pmrem = null;
594
- let printFrames = {};
595
- let printFrameSource = null; // () => frames; mount's lazy provider (setPrintFrameSource)
596
- let printFramesStale = false; // the source has frames the materials have not seen
595
+ let printFrames = {}; // name -> mat4 array: layer lines' display → export map
596
+ let sheetFrames = {}; // name -> { frame, t, plies }: a burning sheet's canonical frame (sheet-look.js)
597
+ let frameSource = null; // () => { print, sheet }; mount's lazy provider (setFrameSource)
598
+ let framesStale = false; // the source has frames the materials have not seen
599
+ const IDENTITY_MAT4 = new THREE.Matrix4().toArray();
597
600
  const modeListeners = new Set();
598
601
  const envListeners = new Set();
599
602
  const SHADOW_LOWRES_MS = 100; // at most one low-res shadow per this, while a part moves
@@ -728,9 +731,14 @@ export function createViewer(container, part) {
728
731
  function physicalFor(name) {
729
732
  let m = physicalMats.get(name);
730
733
  if (!m) {
731
- m = cloneKeepsPattern(buildPhysicalMaterial(part.parts[name].display, { printFrame: printFrames[name], loadTexture }));
734
+ const sp = part.parts[name];
735
+ // `burn` is decided once per sub-part (sheet-look.js burnsFor): only a laser sheet in
736
+ // a wood compiles the burn pass, so every other program is exactly what it was.
737
+ // realisticDisplay: a laser sheet with no material draws as its stock (sheet-look.js).
738
+ m = cloneKeepsPattern(buildPhysicalMaterial(realisticDisplay(sp), { printFrame: printFrames[name], burn: burnsFor(sp), loadTexture }));
732
739
  physicalMats.set(name, m);
733
740
  syncGrain(name);
741
+ applySheetFrame(m, sheetFrames[name]);
734
742
  }
735
743
  return m;
736
744
  }
@@ -859,7 +867,7 @@ export function createViewer(container, part) {
859
867
  // ground only moves on showAssembly, never because a capture happened.
860
868
  function enterRealistic(rig, { live = true, reground = true } = {}) {
861
869
  try {
862
- syncPrintFrames(); // before the swap: the first realistic frame has the export-pose layers
870
+ syncFrames(); // before the swap: the first realistic frame has the export-pose layers and the sheets' burns
863
871
  renderMode = "realistic";
864
872
  // A regen can land between the proxy compile and here, while the mode
865
873
  // was still CAD, so setSubGeometry skipped the UVs. Idempotent.
@@ -929,7 +937,7 @@ export function createViewer(container, part) {
929
937
  // target bound — each only for the synchronous compile() inside
930
938
  // compileAsync, so the view on screen is untouched while it waits.
931
939
  function compileRealistic(rig, { forCapture = false } = {}) {
932
- syncPrintFrames();
940
+ syncFrames();
933
941
  const proxy = new THREE.Scene();
934
942
  proxy.environment = rig.envMap;
935
943
  for (const n of names) {
@@ -1030,30 +1038,45 @@ export function createViewer(container, part) {
1030
1038
  for (const [n, m] of physicalMats) m.userData.patternUniforms?.pfPrintFrame.value.fromArray(printFrames[n] ?? identity);
1031
1039
  }
1032
1040
 
1033
- // The lazy form mount uses. Computing a frame runs the sub-part's pose probe
1034
- // (two geometry-free builds for a placed sub-part), and every sub-part with
1035
- // no material draws layer lines, so frames are PULLED only when something is
1036
- // about to draw the realistic look: the live view (or a switch loading),
1037
- // and any capture that borrows it — compileRealistic and enterRealistic are
1038
- // the two doors every such path goes through. A delivery just marks them
1039
- // stale (invalidatePrintFrames); in CAD nothing is computed.
1040
- function setPrintFrameSource(source) {
1041
- printFrameSource = typeof source === "function" ? source : null;
1042
- invalidatePrintFrames();
1043
- }
1044
- function invalidatePrintFrames() {
1045
- printFramesStale = true;
1046
- if (renderMode === "realistic" || realisticPending) syncPrintFrames();
1047
- }
1048
- function syncPrintFrames() {
1049
- if (!printFrameSource || !printFramesStale) return;
1050
- printFramesStale = false;
1041
+ // A burning sheet's canonical frame (sheet-look.js sheetFrameFor). No frame — not yet
1042
+ // delivered, or not computable at these params — is pfSheetT = 0: plain wood.
1043
+ function applySheetFrame(m, f) {
1044
+ const u = m.userData.patternUniforms;
1045
+ if (!u?.pfSheetFrame) return;
1046
+ u.pfSheetFrame.value.fromArray(f?.frame ?? IDENTITY_MAT4);
1047
+ u.pfSheetT.value = f?.t ?? 0;
1048
+ u.pfPlies.value = f?.plies ?? 0;
1049
+ }
1050
+ function setSheetFrames(frames) {
1051
+ sheetFrames = frames ?? {};
1052
+ for (const [n, m] of physicalMats) applySheetFrame(m, sheetFrames[n]);
1053
+ }
1054
+
1055
+ // The lazy form mount uses. Frames are PULLED only when something is about to draw the
1056
+ // realistic look: the live view (or a switch loading), and any capture that borrows it —
1057
+ // compileRealistic and enterRealistic are the two doors every such path goes through. A
1058
+ // delivery just marks them stale (invalidateFrames); in CAD nothing is computed. One
1059
+ // source carries both kinds: `print` (layer lines; a placed sub-part's costs two
1060
+ // geometry-free probe builds, and every sub-part with no material draws layer lines) and
1061
+ // `sheet` (a burning sheet's canonical frame, from data).
1062
+ function setFrameSource(source) {
1063
+ frameSource = typeof source === "function" ? source : null;
1064
+ invalidateFrames();
1065
+ }
1066
+ function invalidateFrames() {
1067
+ framesStale = true;
1068
+ if (renderMode === "realistic" || realisticPending) syncFrames();
1069
+ }
1070
+ function syncFrames() {
1071
+ if (!frameSource || !framesStale) return;
1072
+ framesStale = false;
1051
1073
  let frames;
1052
- try { frames = printFrameSource(); } catch (e) {
1053
- console.warn("partforge: computing print frames failed", e);
1074
+ try { frames = frameSource(); } catch (e) {
1075
+ console.warn("partforge: computing material frames failed", e);
1054
1076
  return;
1055
1077
  }
1056
- setPrintFrames(frames);
1078
+ setPrintFrames(frames?.print);
1079
+ setSheetFrames(frames?.sheet);
1057
1080
  }
1058
1081
 
1059
1082
  // Resolves once the current environment's rig is loaded (captures wait on it).
@@ -2735,8 +2758,9 @@ export function createViewer(container, part) {
2735
2758
  isRealisticPending: () => realisticPending,
2736
2759
  onEnvironmentChange: (cb) => { envListeners.add(cb); return () => envListeners.delete(cb); },
2737
2760
  setPrintFrames,
2738
- setPrintFrameSource,
2739
- invalidatePrintFrames,
2761
+ setSheetFrames,
2762
+ setFrameSource,
2763
+ invalidateFrames,
2740
2764
  whenRealisticReady,
2741
2765
  dispose,
2742
2766
  }, () => requestRender(API_GRACE_MS));
@@ -42,7 +42,7 @@ const hingeSlots = (k, p, d, pose, z) => d.hingeX.flatMap((hx) =>
42
42
  [hx - HINGE.tongueX, hx + HINGE.tongueX].map((x) =>
43
43
  k.shape2d(tab(p).slot).translate(worldToSheet(pose, [x, p.depth / 2, z]))));
44
44
 
45
- const PLY = { views: ["box"], display: { material: "oak" }, material: "birch plywood", thickness: (p) => p.t };
45
+ const PLY = { views: ["box"], display: { material: "plywood" }, material: "birch plywood", thickness: (p) => p.t };
46
46
  const panel = (name, label, extra = {}) => sheetPart({
47
47
  ...PLY, label,
48
48
  profile: (k, p, d) => d.box[name].outline, // drawn as seen from outside
@@ -1,7 +1,10 @@
1
1
  // Dev-only contact sheet for the material library: one 30 mm sample per preset,
2
2
  // laid out in a grid, so a change to a preset or the pattern shader can be
3
- // judged by eye in every environment (materials.html). Not a reference part.
3
+ // judged by eye in every environment (materials.html). The "Laser-cut" views
4
+ // (below) do the same for the laser burn. Not a reference part.
4
5
  import { PRESETS } from "../framework/materials/presets.js";
6
+ import { sheetPart } from "../framework/sheet/part.js";
7
+ import { sheetHole } from "../framework/sheet/joinery.js";
5
8
 
6
9
  const ids = Object.keys(PRESETS);
7
10
  const COLS = 6, PITCH = 40;
@@ -19,10 +22,59 @@ const FILAMENT_COLOURS = [
19
22
  const withHole = (k) =>
20
23
  k.box({ size: [30, 30, 12] }).fillet({ r: 3 }).cut(k.cylinder({ r: 5, h: 20 }).translate([0, 0, -4]));
21
24
 
25
+ // The "Laser-cut" view: sheet swatches as the laser burn draws them — each 30 × 30 with a
26
+ // cut hole, an engraved label (its thickness) and a score line — in every wood at 3, 6 and
27
+ // 9 mm; one oak panel standing on edge (posed, so its frame is not the identity); and the
28
+ // looks that must NOT burn: a clear-acrylic sheet and an oak block that is not a sheet part.
29
+ // "Laser-cut, unburnt" is its twin: the same swatches with an identity author `place`,
30
+ // which shows plain wood (materials/sheet-look.js) — same geometry, same place, same
31
+ // material — so the two views differ ONLY by the burn and scripts/capture-contact-sheet.mjs
32
+ // can diff them pixel for pixel — and one row of sheets that name no material, drawn as
33
+ // their stock (sheet-look.js STOCK_LOOKS).
34
+ const SW = 30;
35
+ const WOODS = ["plywood", "oak", "walnut"];
36
+ const CONTROLS_Y = -WOODS.length * PITCH;
37
+ const LASER_SWATCHES = [
38
+ ...WOODS.flatMap((material, row) => [3, 6, 9].map((t, col) => ({
39
+ key: `${material}_${t}`, label: `${PRESETS[material].label}, ${t} mm`, at: [col * PITCH, -row * PITCH], t, display: { material },
40
+ }))),
41
+ { key: "acrylic_3", label: "Clear acrylic, 3 mm", at: [0, CONTROLS_Y], t: 3, display: { material: "clear-acrylic" }, stock: "clear acrylic" },
42
+ { key: "oak_standing", label: "Oak, 3 mm, standing", at: [PITCH, CONTROLS_Y], t: 3, display: { material: "oak" }, standing: true },
43
+ { key: "oak_block", label: "Oak block (not a sheet part)", at: [2 * PITCH, CONTROLS_Y], t: 3, display: { material: "oak" }, block: true },
44
+ { key: "stock_plywood", label: "No material, \"birch plywood\" stock", at: [0, CONTROLS_Y - PITCH], t: 3, stock: "birch plywood" },
45
+ { key: "stock_acrylic", label: "No material, \"clear acrylic\" stock", at: [PITCH, CONTROLS_Y - PITCH], t: 3, stock: "clear acrylic" },
46
+ { key: "stock_stained", label: "A bare colour on \"birch plywood\" stock", at: [2 * PITCH, CONTROLS_Y - PITCH], t: 3, stock: "birch plywood", display: { color: 0x2e8b3d } },
47
+ ];
48
+
49
+ function laserSwatch(s, unburnt) {
50
+ const [x, y] = s.at;
51
+ const common = {
52
+ label: unburnt ? `${s.label} (unburnt)` : s.label,
53
+ views: [unburnt ? "unburnt" : "laser"],
54
+ ...(s.display ? { display: s.display } : {}),
55
+ };
56
+ if (s.block) {
57
+ return { ...common, build: (k) => k.box({ min: [x, y, 0], max: [x + SW, y + SW, s.t] })
58
+ .cut(k.cylinder({ d: 8, h: s.t + 2 }).translate([x + 21, y + 21, -1])) };
59
+ }
60
+ // A standing swatch is drawn at the drawing origin; its pose stands it on edge at `at`.
61
+ const [u, v] = s.standing ? [0, 0] : [x, y];
62
+ return sheetPart({
63
+ ...common,
64
+ material: s.stock ?? "birch plywood",
65
+ thickness: s.t,
66
+ profile: (k) => k.shape2d([[u, v], [u + SW, v], [u + SW, v + SW], [u, v + SW]]).cut(sheetHole({ d: 8, at: [u + 21, v + 21] })),
67
+ engrave: (k) => k.text2d(String(s.t), { size: 10, align: "center", valign: "middle" }).translate([u + 9, v + 20]),
68
+ score: () => [[[u + 3, v + 7], [u + SW - 3, v + 7]]],
69
+ ...(s.standing ? { pose: { face: "-Y", up: "+Z", at: [x, y, 0] } } : {}),
70
+ ...(unburnt ? { place: (solid) => solid } : {}),
71
+ });
72
+ }
73
+
22
74
  export default {
23
75
  meta: { title: "Material swatches", units: "mm", environment: "studio" },
24
76
  defaults: {},
25
- views: { all: { label: "All presets", default: true }, colours: { label: "PLA colours" }, petg: { label: "PETG colours" }, print: { label: "Layer lines" } },
77
+ views: { all: { label: "All presets", default: true }, colours: { label: "PLA colours" }, petg: { label: "PETG colours" }, print: { label: "Layer lines" }, laser: { label: "Laser-cut" }, unburnt: { label: "Laser-cut, unburnt" } },
26
78
  parts: {
27
79
  ...Object.fromEntries(ids.map((id, i) => [id.replaceAll("-", "_"), {
28
80
  label: PRESETS[id].label,
@@ -65,5 +117,9 @@ export default {
65
117
  build: (k) => k.box({ size: [30, 12, 40] }).translate([50, 0, 0]),
66
118
  display: { material: "pla-print", color: 0xe0592a },
67
119
  },
120
+ ...Object.fromEntries(LASER_SWATCHES.flatMap((s) => [
121
+ [`laser_${s.key}`, laserSwatch(s, false)],
122
+ [`unburnt_${s.key}`, laserSwatch(s, true)],
123
+ ])),
68
124
  },
69
125
  };
package/types/part.d.ts CHANGED
@@ -441,6 +441,10 @@ export interface SheetDeclaration<P = ResolvedParams, D = Derived> {
441
441
  pose: SheetPose | ((p: P, d: D) => SheetPose | null) | null;
442
442
  /** The build `sheetPart` made; `sub.build !== sub.sheet.generatedBuild` is a custom build. */
443
443
  generatedBuild: (k: GeometryKernel, p: P, d: D) => Solid;
444
+ /** The author's own `place` (the spec's), run after the pose; `null` when there is none. */
445
+ place: ((solid: Solid, ctx: PlaceContext<P, D>) => Solid) | null;
446
+ /** The `place` `sheetPart` installed (the pose, then the author's own), or `null`; `sub.place !== sub.sheet.generatedPlace` is a replaced place. */
447
+ generatedPlace: ((solid: Solid, ctx: PlaceContext<P, D>) => Solid) | null;
444
448
  }
445
449
 
446
450
  export interface ViewDefinition {