partforge 0.151.0 → 0.153.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.
@@ -393,7 +393,9 @@ meta: { title: "…", units: "mm", environment: "studio" },
393
393
  marked tintable are normally coloured this way (anodizing, plastic, paint);
394
394
  the others have an intrinsic colour (brass) but still accept one. (A viewer
395
395
  user PICKING an untintable preset drops an authored tint for that pick — see
396
- the runtime section below.)
396
+ the runtime section below.) On a TEXTURED preset (`oak`, `walnut`, `plywood`,
397
+ `pine-plywood`) `color` multiplies the scanned wood and dulls its grain — leave
398
+ it unset unless you mean a stain (lint warns: `material-tint-textured`).
397
399
  - Overrides — each clamped to its range: `roughness` (0–1), `metalness` (0–1),
398
400
  `clearcoat` (0–1), `clearcoatRoughness` (0–1), `anisotropy` (0–1), `textureScale` (0.01–1000).
399
401
  Transmission, index of refraction, sheen and iridescence come only from a preset.
@@ -401,7 +403,7 @@ meta: { title: "…", units: "mm", environment: "studio" },
401
403
  - **One material per sub-part.** Something that needs two finishes (a knurled
402
404
  grip in rubber on an aluminium body) is two sub-parts.
403
405
 
404
- **Sheet parts default to their stock.** A laser `sheetPart` that names no material (or
406
+ **Sheet parts default to their stock.** A laser `sheetPart` or a `routedPart` that names no material (or
405
407
  one the library does not know) is not drawn as a PLA print: realistic mode shows it as the
406
408
  sheet its stock label names — the first row with a stem that starts a word of the label,
407
409
  ignoring case (so `Plexiglas`, `Acrylite` and `poly-carbonate` match; `perplexing` does not):
@@ -409,10 +411,14 @@ ignoring case (so `Plexiglas`, `Acrylite` and `poly-carbonate` match; `perplexin
409
411
  | A word of the stock label starts with | Realistic look |
410
412
  | --- | --- |
411
413
  | `acryl`, `perspex`, `plexi`, `pmma`, `methacryl`, `polycarb`, `lexan`, `makrolon`, `lucite` | `clear-acrylic` |
414
+ | `pine`, `douglas` | `pine-plywood` |
415
+ | `walnut` | `walnut` |
416
+ | `oak` | `oak` |
412
417
  | anything else — plywood, birch, basswood, poplar, MDF, hardboard — or no readable label | `plywood` |
413
418
 
414
419
  Stock that is neither wood nor acrylic — felt, leather, card, cork — takes the `plywood`
415
- look too, charred edges and all. A `color` tints the look, as it tints any preset (a
420
+ look too (charred edges and all, on a laser sheet), and a species-faced ply — `walnut
421
+ plywood`, `oak ply` — takes that species' solid look, without plies. A `color` tints the look, as it tints any preset (a
416
422
  stained plywood, a coloured acrylic); the CAD view still shows the `color` itself. Only a
417
423
  string label is read: a stock written as a `(p, d)` function counts as unreadable. Name
418
424
  `display.material` to choose the look yourself — for such stock, or any other. Like the
@@ -434,15 +440,25 @@ modelled in its print orientation, not the material. Wood, carbon fibre and SLS
434
440
  fixed to the sub-part, so they never slide when the camera or an animation
435
441
  moves.
436
442
 
437
- **Laser-cut wood.** In realistic mode a laser `sheetPart` in a wood — `plywood`, `oak` or
443
+ **Laser-cut wood.** In realistic mode a laser `sheetPart` in a wood — `plywood`, `pine-plywood`, `oak` or
438
444
  `walnut`, named or its stock's default (so a plywood sheet that names no material burns too)
439
445
  — shows what the laser did: its cut edges are charred, darker on thicker stock (on
440
- `plywood` the plies show through), and its engraving and score lines are scorched, while its
446
+ `plywood` and `pine-plywood` the plies show through), and its engraving and score lines are scorched, while its
441
447
  faces stay wood. There is nothing to set, and it is still one material: the char follows
442
448
  from the sheet and the wood it is drawn in. It needs the sheet's own frame, so a sheet part
443
449
  with a custom `build` or a `views` pose of its own shows plain wood. The CAD view and every export are
444
450
  unchanged.
445
451
 
452
+ **Plywood edges.** Anything drawn in `plywood` shows its plies wherever a surface runs across
453
+ them: alternating face-grain and cross plies (side grain on one, darker end grain on the
454
+ other, swapping as the edge turns), each veneer its own tone, with a thin glue line between
455
+ plies. The ply count follows the thickness (3 mm → 3, 6 → 5, 9 → 7, 12 → 9, then one per
456
+ ~1.4 mm: 18 mm → 13). A `sheetPart` or `routedPart` stacks them along its own thickness, so a
457
+ CNC-routed plywood board shows clean plies on its profile, pockets and drills, and a laser
458
+ sheet shows them through its char; any other sub-part in plywood stacks them along its
459
+ thinnest bounding-box axis — right for a slab, a guess for a chunky block. Realistic mode
460
+ only, like the burn.
461
+
446
462
  **`textureScale` is millimetres, and what it measures depends on the preset's
447
463
  pattern** — so a value copied from one preset family is wrong on another (0.2
448
464
  on oak shrinks the grain to a 0.2 mm tile, i.e. invisible noise):
@@ -451,7 +467,7 @@ on oak shrinks the grain to a 0.2 mm tile, i.e. invisible noise):
451
467
  | --- | --- | --- | --- |
452
468
  | layer lines | `pla-print`, `petg-print` | layer height | 0.2 |
453
469
  | SLS grain | `nylon-sls` | grain size | 0.15 |
454
- | wood | `oak`, `walnut`, `plywood` | size of one texture tile (the grain repeats every this many mm) | 250 (`oak`), 400 (`walnut`), 150 (`plywood`) |
470
+ | wood | `oak`, `walnut`, `plywood`, `pine-plywood` | size of one texture tile (the grain repeats every this many mm) | 250 (`oak`), 400 (`walnut`), 150 (`plywood`), 400 (`pine-plywood`; raise it toward 1000 for a whole 4 × 8 sheet with no visible repeat, at the cost of a softer grain) |
455
471
  | carbon weave | `carbon-fiber` | size of one texture tile | 48 |
456
472
 
457
473
  Presets without a pattern ignore it.
@@ -487,7 +503,8 @@ the viewer's blue-grey.
487
503
  | `rubber` | Rubber | yes | Matte elastomer: gaskets, feet, grips; tint with `color`. |
488
504
  | `oak` | Oak | — | Light oak with open grain. |
489
505
  | `walnut` | Walnut | — | Dark oiled walnut. |
490
- | `plywood` | Birch plywood | — | Birch plywood sheet: a pale, fine-grained face; its plies show on laser-cut edges. |
506
+ | `plywood` | Birch plywood | — | Birch plywood sheet: a pale, fine-grained face; its plies show on cut edges. |
507
+ | `pine-plywood` | Sanded pine plywood | — | Hardware-store 4 × 8 sheet plywood (sanded pine/fir face): warm, bold grain; plies show on cut edges. |
491
508
  | `carbon-fiber` | Carbon fibre | — | 2x2 twill carbon fibre under clear coat. |
492
509
 
493
510
  **Environments** (`meta.environment`, default `studio`; viewers can switch):
@@ -2173,8 +2190,8 @@ plate is `sheetPart`.
2173
2190
  import { routedPart, roundedRectProfile } from "partforge/geometry";
2174
2191
 
2175
2192
  plinth: routedPart({
2176
- label: "Hardwood base", views: { assembly: true, base: true }, display: { color: 0x6e3d23 },
2177
- material: "walnut", // stock label: groups pieces in the kit
2193
+ label: "Plywood base", views: { assembly: true, base: true }, display: { material: "pine-plywood" },
2194
+ material: "pine plywood", // stock label: groups pieces in the kit
2178
2195
  thickness: (p) => p.baseThickness, // the MEASURED board, from a control
2179
2196
  profile: (k, p) => k.shape2d(roundedRectProfile(p.baseLength, p.baseWidth, 10)), // the THROUGH cut
2180
2197
  pockets: (k, p, d) => [
@@ -3960,12 +3977,15 @@ rigid motion — translate/rotate — never a reshape; an error). A pose the pro
3960
3977
 
3961
3978
  **Appearance** (all warnings) — `unknown-material` (a `display.material` the
3962
3979
  library does not know; the viewer draws it as if it named none — a PLA print
3963
- in realistic mode, or for a laser sheet part its stock's look, per "Sheet parts
3980
+ in realistic mode, or for a laser or routed sheet part its stock's look, per "Sheet parts
3964
3981
  default to their stock"), `unknown-environment`
3965
3982
  (`meta.environment` not known; realistic mode uses `studio`),
3966
3983
  `material-key-unknown` (a `display` key that is not colour, opacity, material
3967
3984
  or one of the six overrides; ignored), `material-override-clamped` (an override
3968
- outside its range, or not a number; clamped).
3985
+ outside its range, or not a number; clamped), `material-tint-textured` (a
3986
+ `display.color` on a textured preset that is not tintable — `oak`, `walnut`,
3987
+ `plywood`, `pine-plywood`: the colour multiplies the scanned wood and dulls its
3988
+ grain; drop it unless a stain is intended).
3969
3989
 
3970
3990
  **Geometry imports** — `import-unknown-name` (a build calls `k.import` with a
3971
3991
  name the part's `imports` field doesn't declare — this throws at build time;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.151.0",
3
+ "version": "0.153.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",
@@ -19,7 +19,7 @@ export const MATERIAL_RULES = [
19
19
  id: "unknown-material",
20
20
  run: ({ part }) => issuesOf(part, "unknown-material").map(({ name, value }) => {
21
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) —
22
+ // A laser or routed sheet falls back to its stock's look, not to PLA (materials/sheet-look.js) —
23
23
  // read from a string label only: a label written as a function takes the default look.
24
24
  const sp = part.parts[name];
25
25
  const stock = sheetStockLook(sp);
@@ -27,10 +27,27 @@ export const MATERIAL_RULES = [
27
27
  const fallback = stock ? `in realistic mode \`${stock}\`${why}` : "a PLA print in realistic mode";
28
28
  return warn("unknown-material",
29
29
  `sub-part "${name}" names material ${JSON.stringify(value)}, which is not in the library`,
30
- `${near ? `Did you mean "${near}"? ` : ""}The viewer draws it as if it named no material instead (${fallback}). Known materials: ${listedPresets().join(", ")}.`,
30
+ `${near ? `Did you mean "${near}"? ` : ""}It is drawn as if it named none (${fallback}). Known materials: ${listedPresets().join(", ")}.`,
31
31
  `parts.${name}.display.material`);
32
32
  }),
33
33
  },
34
+ {
35
+ // A `color` on a scanned-texture preset that is not tintable MULTIPLIES the scan
36
+ // (physical.js), so the grain goes muddy — usually an agent "helping" a wood look
37
+ // with a brown. A warning, not an error: a deliberate stain is legitimate.
38
+ id: "material-tint-textured",
39
+ run: ({ part }) => subParts(part).flatMap(([name, sp]) => {
40
+ const d = sp.display;
41
+ if (!d || typeof d !== "object" || d.color == null || typeof d.material !== "string") return [];
42
+ if (!Object.hasOwn(PRESETS, d.material)) return [];
43
+ const preset = PRESETS[d.material];
44
+ if (!preset.textures || preset.tintable) return [];
45
+ return [warn("material-tint-textured",
46
+ `sub-part "${name}" sets display.color on "${d.material}", a textured material: the colour multiplies the scanned ${preset.label.toLowerCase()} and dulls its grain`,
47
+ `Drop display.color to show ${d.material}'s own colour and grain — the preset already carries it, in the CAD view too. Keep color only for a deliberate stain.`,
48
+ `parts.${name}.display.color`)];
49
+ }),
50
+ },
34
51
  {
35
52
  id: "unknown-environment",
36
53
  run: ({ part }) => {
@@ -69,6 +69,9 @@ the sliced primary and gain-map buffers now start with SOI (`FF D8`) and end wit
69
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
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
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`). |
72
+ | `pattern-pine-plywood-color.jpg` | ambientCG "Wood 048" (Color, `Wood048_1K-JPG_Color.jpg` from the 1K-JPG zip) | https://ambientcg.com/a/Wood048 — file https://ambientcg.com/get?file=Wood048_1K-JPG.zip | 2026-10-15 | 1K JPG, sRGB colour -> `node scripts/bake-environments.mjs --texture Wood048_1K-JPG_Color.jpg pattern-pine-plywood-color.jpg --modulate 1.25,1.3 --flatten 0.9 --quality 85` (lightened 1.25x and saturation 1.3 from the scan's dull olive-tan toward a sanded pine/fir sheet's warm yellow; grain contrast 0.9 keeps the bold figure). Mean `224,183,125` (`0xe0b77d`, the preset's `color`). Scanned tile 800 x 800 mm (ambientCG `dimensionX/Y`). One continuous softwood face with bold cathedral/flame figure running across the image (`grain: "u"`), seamless. Chosen 2026-10-15 as the hardware-store sheet look (the birch `plywood` set is pale and fine-grained). Rejected: ambientCG Wood047 (closest rotary-fir figure, but an 80 x 40 cm 2:1 scan the square bake would crop in half), Wood050 (same family, pinkish cast), Wood096 and Wood031 (knotty board, not a sanded face); Poly Haven `coated_pine`/`coated_pine_02` (glossy varnished knotty pine), `kitchen_wood` (grey, fine), `okoume_veneer`/`angli_veneer` (pink-brown, near-featureless hardwood); the plywood/Chipboard rows ambientCG tags `plywood` show plywood edges or particle board, not a face. |
73
+ | `pattern-pine-plywood-normal.jpg` | ambientCG "Wood 048" (NormalGL, `Wood048_1K-JPG_NormalGL.jpg`) | https://ambientcg.com/a/Wood048 — file https://ambientcg.com/get?file=Wood048_1K-JPG.zip | 2026-10-15 | 1K JPG, OpenGL (+Y up) tangent normals, kept as data -> `node scripts/bake-environments.mjs --texture Wood048_1K-JPG_NormalGL.jpg pattern-pine-plywood-normal.jpg --data --quality 88` (4:4:4 chroma, no grayscale/tint). q88, not the other wood sets' q95: this map carries about twice birch's relief (stdev 6.8 vs 3.4 /255), so its 8x8 blocks are far below the grain, and q95 (~640 KB) and q90 (~412 KB) both broke the 400 KB pattern budget; q88 is 37.7 dB PSNR against the source. |
74
+ | `pattern-pine-plywood-rough.jpg` | ambientCG "Wood 048" (Roughness, `Wood048_1K-JPG_Roughness.jpg`) | https://ambientcg.com/a/Wood048 — file https://ambientcg.com/get?file=Wood048_1K-JPG.zip | 2026-10-15 | 1K JPG, grayscale data -> `node scripts/bake-environments.mjs --texture Wood048_1K-JPG_Roughness.jpg pattern-pine-plywood-rough.jpg --gray` (mean 141/255 = 0.552, the preset's `roughnessMean`). |
72
75
  | `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). |
73
76
  | `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. |
74
77
  | `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.) |
@@ -80,7 +83,8 @@ the sliced primary and gain-map buffers now start with SOI (`FF D8`) and end wit
80
83
 
81
84
  ambientCG publishes every asset under CC0 (https://ambientcg.com/, "CC0 Public Domain
82
85
  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
86
+ Universal License", checked again 2026-09-30 for the plywood candidates and 2026-10-15 for
87
+ `pattern-pine-plywood-*`, Wood048). The wood sets
84
88
  (`pattern-oak-*`, `pattern-walnut-*`, `pattern-plywood-*`, `ground-maple-*`) are Poly Haven
85
89
  textures, CC0 like its HDRIs above (https://polyhaven.com/license, checked 2026-09-24, and
86
90
  again 2026-09-30 for `plywood`); the exact 1K JPG URLs came
@@ -92,5 +96,5 @@ The presets tile them smaller than the scanned size — see the comment on `oak`
92
96
 
93
97
  Environments stay under 1.5 MB (largest: `env-workshop.jpg` at ~398 KB); ground/pattern
94
98
  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
99
+ pattern map is `pattern-pine-plywood-normal.jpg` at ~353 KB). All committed
96
100
  outputs are well inside both ceilings — see `test/framework/materials-assets.test.js`.
@@ -28,6 +28,9 @@ const URLS = {
28
28
  "pattern-plywood-color.jpg": new URL("./assets/pattern-plywood-color.jpg", import.meta.url).href,
29
29
  "pattern-plywood-normal.jpg": new URL("./assets/pattern-plywood-normal.jpg", import.meta.url).href,
30
30
  "pattern-plywood-rough.jpg": new URL("./assets/pattern-plywood-rough.jpg", import.meta.url).href,
31
+ "pattern-pine-plywood-color.jpg": new URL("./assets/pattern-pine-plywood-color.jpg", import.meta.url).href,
32
+ "pattern-pine-plywood-normal.jpg": new URL("./assets/pattern-pine-plywood-normal.jpg", import.meta.url).href,
33
+ "pattern-pine-plywood-rough.jpg": new URL("./assets/pattern-pine-plywood-rough.jpg", import.meta.url).href,
31
34
  "pattern-carbon.jpg": new URL("./assets/pattern-carbon.jpg", import.meta.url).href,
32
35
  };
33
36
 
@@ -5,10 +5,11 @@
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
+ // A laminated wood's plies (applyPlies) compose over the wood pass on its cut walls, and a
9
+ // laser-cut wood sheet's burn (applyBurn) over both, in the sheet's canonical frame
10
+ // (sheet-look.js).
10
11
  import * as THREE from "three";
11
- import { BURN, srgbToLinear } from "./sheet-look.js";
12
+ import { BURN, PLY, srgbToLinear } from "./sheet-look.js";
12
13
 
13
14
  const VERT_DECL = "varying vec3 vPfObjPos;\nvarying vec3 vPfObjNormal;\nvarying vec3 vPfPrintUpView;\nuniform mat4 pfPrintFrame;\n";
14
15
  // Wood's triplanar normal map builds its normal in OBJECT space; the fragment
@@ -358,6 +359,121 @@ export function applyBrushFrame(material) {
358
359
  return material;
359
360
  }
360
361
 
362
+ // --- plies ------------------------------------------------------------------------
363
+ // A laminated wood (presets.js `laminated`: plywood) shows its plies wherever a surface runs
364
+ // across them — laser cut, router pass, or the side of a plain solid. The viewer hands each
365
+ // such sub-part a LAMINATE frame (sheet-look.js: a canonical sheet's own frame, else
366
+ // boxPlyFrame's), with the plies stacked along its z over [0, pfPlyT] and the face grain
367
+ // along pfPlyGrain. On a wall each ply is drawn from scratch — side grain (the colour map
368
+ // laid along the wall, each ply its own stretch of it) or end grain (darker, rougher, a fine
369
+ // mottle), whichever its grain shows that wall — with its own tone, and a glue line at
370
+ // every inner boundary. Injected where the burn's colour body goes, before
371
+ // <normal_fragment_begin>; applyPlies runs first, so the burn (placed after this) chars the
372
+ // plies it finds and keeps them through its grain ratio. Every snippet sits between
373
+ // `// pf-ply {` and `// } pf-ply`. pfPlyT = 0 (no frame yet) draws the plain wood pass.
374
+ //
375
+ // Box-filtered like layer lines must be: the odd/even ply split and the glue lines are each
376
+ // their wave's integral differenced across fwidth, so plies too thin to draw average out
377
+ // instead of shimmering, and the per-ply tone fades out as the plies do. The texture reads
378
+ // use textureGrad on the CONTINUOUS coordinates, so a ply's jump to its own stretch of the
379
+ // map does not drop a seam of the coarsest mip at each boundary.
380
+ // A JS number as a GLSL float literal.
381
+ const glf = (x) => (Number.isInteger(x) ? x.toFixed(1) : String(x));
382
+ const vec3f = (a) => `vec3(${a.map(glf).join(", ")})`;
383
+ const plyBlock = (glsl) => `// pf-ply {\n${glsl}\n// } pf-ply`;
384
+
385
+ const PLY_FRAG_DECL = plyBlock(`uniform mat4 pfPlyFrame;
386
+ uniform float pfPlyT;
387
+ uniform float pfPlyCount;
388
+ uniform vec2 pfPlyGrain;
389
+ uniform vec3 pfPlyAvg;
390
+ // The fraction of the pixel on odd (cross) plies.
391
+ float pfPlyOddI(float x) { return floor(x * 0.5) + max(0.0, fract(x * 0.5) * 2.0 - 1.0); }
392
+ float pfPlyOdd(float x) {
393
+ float w = max(fwidth(x), 1e-4);
394
+ return clamp((pfPlyOddI(x + 0.5 * w) - pfPlyOddI(x - 0.5 * w)) / w, 0.0, 1.0);
395
+ }
396
+ // The fraction of the pixel on a glue line: pulses of half-width h (in plies) on integers.
397
+ float pfGlueI(float x, float h) { float y = x + h; return floor(y) * 2.0 * h + min(fract(y), 2.0 * h); }
398
+ float pfGlue(float x, float h) {
399
+ float w = max(fwidth(x), 1e-4);
400
+ return clamp((pfGlueI(x + 0.5 * w, h) - pfGlueI(x - 0.5 * w, h)) / w, 0.0, 1.0);
401
+ }`);
402
+
403
+ // `uv` is "xy" when the colour map's figure runs along u (presets.js grain "u"), else "yx".
404
+ const plyFragBody = (uv) => plyBlock(`float pfPlyWall = 0.0;
405
+ if (pfPlyT > 0.0 && pfPlyCount > 0.0) {
406
+ vec3 pfQ = (pfPlyFrame * vec4(vPfObjPos, 1.0)).xyz;
407
+ vec3 pfQn = normalize(mat3(pfPlyFrame) * vPfObjNormal);
408
+ pfPlyWall = 1.0 - smoothstep(${glf(PLY.wallNz[0])}, ${glf(PLY.wallNz[1])}, abs(pfQn.z));
409
+ if (pfPlyWall > 0.0) {
410
+ float pfX = clamp(pfQ.z / pfPlyT, 0.0, 1.0) * pfPlyCount; // in plies
411
+ float pfPly = floor(min(pfX, pfPlyCount - 0.5));
412
+ vec2 pfWn = normalize(pfQn.xy);
413
+ // How squarely each ply's grain runs into this wall: 1 is end grain.
414
+ float pfAlong = abs(dot(pfPlyGrain, pfWn));
415
+ float pfEndFace = smoothstep(0.25, 0.9, pfAlong);
416
+ float pfEndCross = smoothstep(0.25, 0.9, sqrt(max(0.0, 1.0 - pfAlong * pfAlong)));
417
+ // Two wall projections, by which in-plane axis the wall faces (the triplanar rule).
418
+ vec2 pfW = pow(abs(pfWn), vec2(4.0)); pfW /= (pfW.x + pfW.y);
419
+ float pfOff = pfPly * 37.0; // each ply its own stretch of the map
420
+ vec2 pfSa = vec2(pfQ.y, pfQ.z) / pfPatternScale, pfSb = vec2(pfQ.x, pfQ.z) / pfPatternScale;
421
+ vec2 pfDx = vec2(dFdx(pfQ.y), dFdx(pfQ.z)) / pfPatternScale, pfDy = vec2(dFdy(pfQ.y), dFdy(pfQ.z)) / pfPatternScale;
422
+ vec2 pfDxb = vec2(dFdx(pfQ.x), dFdx(pfQ.z)) / pfPatternScale, pfDyb = vec2(dFdy(pfQ.x), dFdy(pfQ.z)) / pfPatternScale;
423
+ vec2 pfO = vec2(0.0, pfOff / pfPatternScale);
424
+ vec3 pfSide = textureGrad(pfPatternMap, (pfSa + pfO).${uv}, pfDx.${uv}, pfDy.${uv}).rgb * pfW.x
425
+ + textureGrad(pfPatternMap, (pfSb + pfO).${uv}, pfDxb.${uv}, pfDyb.${uv}).rgb * pfW.y;
426
+ // End grain: the same map read ACROSS its figure and finer, kept only as a mottle.
427
+ float pfE = ${glf(PLY.endScale)};
428
+ vec3 pfEndT = textureGrad(pfPatternMap, ((pfSa + pfO) * pfE).${uv === "xy" ? "yx" : "xy"}, (pfDx * pfE).${uv === "xy" ? "yx" : "xy"}, (pfDy * pfE).${uv === "xy" ? "yx" : "xy"}).rgb * pfW.x
429
+ + textureGrad(pfPatternMap, ((pfSb + pfO) * pfE).${uv === "xy" ? "yx" : "xy"}, (pfDxb * pfE).${uv === "xy" ? "yx" : "xy"}, (pfDyb * pfE).${uv === "xy" ? "yx" : "xy"}).rgb * pfW.y;
430
+ float pfAvgL = max(dot(pfPlyAvg, vec3(0.2126, 0.7152, 0.0722)), 1e-4);
431
+ float pfMott = mix(1.0, dot(pfEndT, vec3(0.2126, 0.7152, 0.0722)) / pfAvgL, ${glf(PLY.endMottle)});
432
+ vec3 pfEnd = pfPlyAvg * ${glf(PLY.endTone)} * ${vec3f(PLY.endWarm)} * pfMott;
433
+ float pfOdd = pfPlyOdd(pfX);
434
+ float pfEndness = mix(pfEndFace, pfEndCross, pfOdd);
435
+ vec3 pfCol = diffuse * mix(pfSide, pfEnd, pfEndness);
436
+ // Each veneer its own tone, fading out as the plies get too thin to tell apart.
437
+ float pfJ = (pfHash(vec3(pfPly, 7.13, 3.71)) * 2.0 - 1.0) * ${glf(PLY.jitter)};
438
+ pfCol *= 1.0 + pfJ * (1.0 - smoothstep(0.3, 0.8, fwidth(pfX)));
439
+ float pfH = 0.5 * ${glf(PLY.glueMm)} * pfPlyCount / pfPlyT;
440
+ float pfG = pfGlue(pfX, pfH) * step(0.5, pfX) * step(pfX, pfPlyCount - 0.5);
441
+ pfCol = mix(pfCol, pfCol * ${glf(PLY.glue)} * ${vec3f(PLY.glueTint)}, pfG);
442
+ diffuseColor.rgb = mix(diffuseColor.rgb, pfCol, pfPlyWall);
443
+ roughnessFactor = mix(roughnessFactor, clamp(roughnessFactor + ${glf(PLY.endRough)} * pfEndness, 0.0, 1.0), pfPlyWall);
444
+ }
445
+ }`);
446
+
447
+ const PLY_FRAG_NORMAL = plyBlock(`normal = normalize(mix(normal, nonPerturbedNormal, pfPlyWall * ${glf(1 - PLY.relief)}));`);
448
+
449
+ // `faceAvg` is the preset's own colour (0xRRGGBB) — its texture's average, never the tint.
450
+ // Wood only, like the burn; the viewer sets the frame uniforms (viewer.js applyPlyFrame).
451
+ export function applyPlies(material, { faceAvg }) {
452
+ const uniforms = material.userData.patternUniforms;
453
+ if (!uniforms?.pfGrainSwap) return material;
454
+ Object.assign(uniforms, {
455
+ pfPlyFrame: { value: new THREE.Matrix4() },
456
+ pfPlyT: { value: 0 },
457
+ pfPlyCount: { value: 0 },
458
+ pfPlyGrain: { value: new THREE.Vector2(1, 0) },
459
+ pfPlyAvg: { value: new THREE.Color(faceAvg) },
460
+ });
461
+ const uv = material.userData.pfGrain === "v" ? "yx" : "xy";
462
+ const prev = material.onBeforeCompile;
463
+ const prevKey = material.customProgramCacheKey;
464
+ material.onBeforeCompile = (shader, renderer) => {
465
+ prev?.call(material, shader, renderer); // applyPattern's hook copies `uniforms` — ply uniforms included
466
+ shader.fragmentShader = shader.fragmentShader
467
+ .replace("#include <common>", `#include <common>\n${PLY_FRAG_DECL}`)
468
+ .replace("#include <normal_fragment_begin>", `${plyFragBody(uv)}\n#include <normal_fragment_begin>`)
469
+ .replace("#include <emissivemap_fragment>", `${PLY_FRAG_NORMAL}\n#include <emissivemap_fragment>`);
470
+ };
471
+ const base = prevKey && prevKey !== THREE.Material.prototype.customProgramCacheKey ? prevKey.call(material) : "";
472
+ material.customProgramCacheKey = () => `${base}|pf-ply`;
473
+ material.needsUpdate = true;
474
+ return material;
475
+ }
476
+
361
477
  // --- laser burns ------------------------------------------------------------------
362
478
  // A laser-cut sheet in a wood shows what the laser did to it (sheet-look.js decides which
363
479
  // sub-parts, and mount hands the viewer each one's canonical frame): cut walls charred,
@@ -378,22 +494,12 @@ export function applyBrushFrame(material) {
378
494
  // vertex stage is untouched — the pass reads the varyings every pattern already has. With
379
495
  // pfSheetT = 0 (no frame delivered yet) it draws plain wood. No noise term: an unfiltered
380
496
  // striation would shimmer at part scale (the layer-lines lesson above).
381
- const glf = (x) => (Number.isInteger(x) ? x.toFixed(1) : String(x));
382
497
  const CHARCOAL = srgbToLinear(BURN.charcoal);
383
498
  const burnBlock = (glsl) => `// pf-burn {\n${glsl}\n// } pf-burn`;
384
499
 
385
500
  const BURN_FRAG_DECL = burnBlock(`uniform mat4 pfSheetFrame;
386
501
  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
- }`);
502
+ uniform vec3 pfFaceAvg;`);
397
503
 
398
504
  const BURN_FRAG_BODY = burnBlock(`float pfBurnWall = 0.0;
399
505
  float pfBurnFloor = 0.0;
@@ -411,11 +517,10 @@ if (pfSheetT > 0.0) {
411
517
  // went in.
412
518
  vec3 pfChar = min(vec3(${CHARCOAL.map((c) => c.toFixed(6)).join(", ")}), ${glf(BURN.charOfDark)} * pfFace);
413
519
  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
520
  vec3 pfMark = mix(pfFace, pfChar, pfK - ${glf(BURN.engraveLess)});
416
521
  // The grain survives the char (sheet-look.js's burnTexel is the twin): pfEdge and pfMark
417
522
  // 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
523
+ // blended in by BURN.grain — the plies too, which applyPlies has already drawn. At 1, a pure multiply, the average chars exactly as above and
419
524
  // no texel comes out lighter than it went in.
420
525
  vec3 pfGrain = mix(vec3(1.0), diffuseColor.rgb / max(pfFace, vec3(1e-4)), ${glf(BURN.grain)});
421
526
  pfEdge *= pfGrain;
@@ -439,7 +544,6 @@ export function applyBurn(material, { faceAvg }) {
439
544
  Object.assign(uniforms, {
440
545
  pfSheetFrame: { value: new THREE.Matrix4() },
441
546
  pfSheetT: { value: 0 },
442
- pfPlies: { value: 0 },
443
547
  pfFaceAvg: { value: new THREE.Color(faceAvg) },
444
548
  });
445
549
  const prev = material.onBeforeCompile;
@@ -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, hasAppearance } from "./resolve.js";
8
- import { applyBrushFrame, applyBurn, applyPattern } from "./patterns.js";
8
+ import { applyBrushFrame, applyBurn, applyPattern, applyPlies } from "./patterns.js";
9
9
  import { PATTERN_TEXTURES } from "./assets.js";
10
10
 
11
11
  export function buildCadMaterial(display, base) {
@@ -22,10 +22,13 @@ export function buildCadMaterial(display, base) {
22
22
  // Realistic-mode material. `loadTexture(fileName)` is injected (the viewer owns
23
23
  // a caching TextureLoader), so this stays unit-testable without a network.
24
24
  // `burn` is the viewer's per-sub-part decision (sheet-look.js burnsFor): it adds the laser
25
- // burn pass to a wood and changes no other program.
25
+ // burn pass to a wood and changes no other program. A laminated wood (plywood) adds the ply
26
+ // pass first, so the burn chars the plies it draws.
26
27
  export function buildPhysicalMaterial(display, opts = {}) {
27
28
  let m = buildPhysical(display, opts);
28
- if (opts.burn) m = applyBurn(m, { faceAvg: resolveMaterial(display).preset.color });
29
+ const { preset } = resolveMaterial(display);
30
+ if (preset.laminated) m = applyPlies(m, { faceAvg: preset.color });
31
+ if (opts.burn) m = applyBurn(m, { faceAvg: 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
  }
@@ -79,17 +79,26 @@ export const PRESETS = {
79
79
  // times that, a sanded face's relief, so it keeps its own. `grain` is the
80
80
  // texture axis the scan's figure runs along — oak's runs up the image ("v"),
81
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).
82
+ // long axis. `laminated` (plywood) adds the ply pass (patterns.js applyPlies): plies on
83
+ // every cut wall, at sheet-look.js's plyCount(t).
84
84
  oak: P("oak", "Oak", "natural", "Light oak with open grain.",
85
85
  { color: 0xa17e57, metalness: 0, roughness: 0.65, pattern: "wood", textureScale: 250,
86
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" } }),
87
87
  walnut: P("walnut", "Walnut", "natural", "Dark oiled walnut.",
88
88
  { color: 0x5d2a18, metalness: 0, roughness: 0.5, clearcoat: 0.3, clearcoatRoughness: 0.4, pattern: "wood", textureScale: 400,
89
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.",
90
+ plywood: P("plywood", "Birch plywood", "natural", "Birch plywood sheet: a pale, fine-grained face; its plies show on cut edges.",
91
91
  { color: 0xe1c1a1, metalness: 0, roughness: 0.6, pattern: "wood", textureScale: 150, laminated: true,
92
92
  textures: { color: "pattern-plywood-color.jpg", normal: "pattern-plywood-normal.jpg", roughness: "pattern-plywood-rough.jpg", roughnessMean: 0.749, normalScale: 1, grain: "u" } }),
93
+ // Hardware-store 4 x 8 sheet plywood, sanded pine/fir face (ambientCG Wood048). One tile
94
+ // per 400 mm, half the scan's own 800 mm: at the full scan size (1000 here, once) a 1K map
95
+ // is ~1 mm a texel and a hand-sized part showed a smear, not grain. 400 keeps the bold
96
+ // cathedral figure reading as a sheet face; the cost is a figure that repeats about three
97
+ // times across a full 1220 mm sheet. Like plywood it is
98
+ // `laminated`: the ply pass draws its plies on every cut wall.
99
+ "pine-plywood": P("pine-plywood", "Sanded pine plywood", "natural", "Hardware-store 4 × 8 sheet plywood (sanded pine/fir face): warm, bold grain; plies show on cut edges.",
100
+ { color: 0xe0b77d, metalness: 0, roughness: 0.6, pattern: "wood", textureScale: 400, laminated: true,
101
+ textures: { color: "pattern-pine-plywood-color.jpg", normal: "pattern-pine-plywood-normal.jpg", roughness: "pattern-pine-plywood-rough.jpg", roughnessMean: 0.552, normalScale: 1, grain: "u" } }),
93
102
  "carbon-fiber": P("carbon-fiber", "Carbon fibre", "natural", "2x2 twill carbon fibre under clear coat.",
94
103
  { color: 0x1b1c1e, metalness: 0.2, roughness: 0.35, clearcoat: 1, clearcoatRoughness: 0.05, pattern: "carbon", textureScale: 48 }),
95
104
  };
@@ -13,7 +13,7 @@
13
13
  // author place, a replaced place or a custom build is not trusted with a frame — it draws
14
14
  // plain wood, never a frame that could char a face.
15
15
  //
16
- // It also says what a laser sheet part with no usable material looks like: not a PLA print
16
+ // It also says what a sheet part (laser or routed) with no usable material looks like: not a PLA print
17
17
  // (resolve.js's default for every other sub-part) but the sheet its stock label names —
18
18
  // realisticDisplay, from the ONE keyword table STOCK_LOOKS. Realistic-only, like the PLA
19
19
  // default: the CAD view, 3MF colours and declaresMaterials read sp.display itself.
@@ -42,8 +42,9 @@ const IDENTITY = Object.freeze([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1])
42
42
  // takes a channel over wherever face_i < charcoal_i / charOfDark — walnut's green and blue —
43
43
  // so a dark or strongly tinted face still chars darker in every channel. Mixing toward
44
44
  // `charcoal` alone would RAISE walnut's near-black blue, since charcoal's is lighter. Char and
45
- // scorched floors take `roughness`, a matte finish, and shed any clearcoat; on plywood's cut
46
- // walls a cross ply is `crossPly` of a face-grain ply.
45
+ // scorched floors take `roughness`, a matte finish, and shed any clearcoat. Plywood's plies
46
+ // are not the burn's: the ply pass (PLY below) draws them first, and the char keeps each
47
+ // texel's ratio to the face (`grain`), so they show through it.
47
48
  // grain how much of the wood's grain shows through the char: each texel's ratio to
48
49
  // the face average, blended in by `grain`, multiplies the average char (1, a
49
50
  // pure multiply: every texel darkens by the same per-channel factor, so the
@@ -70,7 +71,6 @@ export const BURN = Object.freeze({
70
71
  engraveLess: 0.1,
71
72
  charOfDark: 0.37,
72
73
  roughness: 0.68,
73
- crossPly: 0.78,
74
74
  grain: 1,
75
75
  relief: 0.35,
76
76
  });
@@ -83,30 +83,101 @@ export function srgbToLinear(hex) {
83
83
  });
84
84
  }
85
85
 
86
- // Plies in a birch-plywood sheet t mm thick: 3 mm → 3, 6 → 5, 9 → 7, 12 → 9. Always odd, so
87
- // both outer plies run with the face grain.
88
- const PLY_STEPS = Object.freeze([[4.5, 3], [7.5, 5], [10.5, 7]]);
86
+ // Plies in a birch-plywood sheet t mm thick: 3 mm → 3, 6 → 5, 9 → 7, 12 → 9, then one ply
87
+ // per ~1.4 mm (15 → 11, 18 → 13, 24 → 17). Always odd, so both outer plies run with the face
88
+ // grain.
89
+ const PLY_STEPS = Object.freeze([[4.5, 3], [7.5, 5], [10.5, 7], [13.3, 9]]);
89
90
  export function plyCount(t) {
90
91
  for (const [below, plies] of PLY_STEPS) if (t < below) return plies;
91
- return 9;
92
+ return 2 * Math.round((t / PLY.thickPly - 1) / 2) + 1;
93
+ }
94
+
95
+ // How a laminated wood's cut wall looks (patterns.js applyPlies): what any `laminated`
96
+ // preset shows wherever a surface runs across its plies — a laser cut, a router pass, or
97
+ // the side of a plain solid in plywood. In a LAMINATE frame like the sheet's canonical one
98
+ // (plies stacked along z over [0, t]), each ply's grain alternates: the outer plies run
99
+ // with the face grain, every other one across it. On a wall, a ply whose grain runs along
100
+ // the wall shows SIDE grain — the face texture's long streaks, laid along the wall — and one
101
+ // whose grain runs into it shows END grain: darker and rougher, the pores open.
102
+ // wallNz the same wall band as the burn's (BURN.wallNz): below it a surface is a wall.
103
+ // endTone end grain's albedo, a multiple of the face average (birch end grain drinks
104
+ // light); `endWarm` tints it toward amber, per channel.
105
+ // endMottle how much of the texture's own variation end grain keeps (a fine mottle, not
106
+ // streaks: its sample runs across the grain, `endScale` times finer).
107
+ // endRough added to roughness on end grain.
108
+ // jitter each ply's own tone, ± this fraction (a hash of its index): veneers differ.
109
+ // glueMm the glue line between two plies, mm wide (none on the outer faces), drawn
110
+ // at `glue` of the ply colour, tinted toward `glueTint`.
111
+ // relief the share of the face's normal map a wall keeps: the face scan's relief laid
112
+ // across plies reads as noise, so walls are mostly geometric.
113
+ // thickPly above 13.3 mm, ply thickness in mm (plyCount).
114
+ // Like BURN, judged by eye on the contact sheet (materials.html, "Plywood edges").
115
+ export const PLY = Object.freeze({
116
+ wallNz: BURN.wallNz,
117
+ endTone: 0.74,
118
+ endWarm: Object.freeze([1.0, 0.93, 0.82]),
119
+ endMottle: 0.35,
120
+ endScale: 4,
121
+ endRough: 0.12,
122
+ jitter: 0.06,
123
+ glueMm: 0.12,
124
+ glue: 0.42,
125
+ glueTint: Object.freeze([1.0, 0.82, 0.62]),
126
+ relief: 0.25,
127
+ thickPly: 1.4,
128
+ });
129
+
130
+ // Is this sub-part's realistic look a laminated wood (its walls draw plies)? Static — it
131
+ // reads no params — so it decides a PROGRAM, like burnsFor.
132
+ export function laminatedFor(sp) {
133
+ try {
134
+ const look = lookOf(sp);
135
+ return look.params.pattern === "wood" && look.preset.laminated === true;
136
+ } catch { return false; }
137
+ }
138
+
139
+ // The laminate frame of a sub-part with no sheet frame: its delivered mesh's thinnest
140
+ // object-space bounding-box axis is the stack, the box's low corner z = 0. `min`/`max` are
141
+ // the box corners ([x, y, z]); `grainAxis` is the wood pass's grain axis (patterns.js
142
+ // grainAxisFor). Returns { frame, t, plies, grain }: a column-major 4×4 that permutes the
143
+ // thin axis onto z (keeping the frame right-handed) and the face grain in that frame, a
144
+ // unit [x, y] (x when the grain axis IS the thin one, a cube's tie). null for an empty box.
145
+ export function boxPlyFrame(min, max, grainAxis = 0) {
146
+ const s = [0, 1, 2].map((i) => max[i] - min[i]);
147
+ if (!s.every((v) => Number.isFinite(v)) || Math.max(...s) <= 0) return null;
148
+ let thin = 2;
149
+ for (const i of [1, 0]) if (s[i] < s[thin] - Math.max(...s) * 1e-6) thin = i;
150
+ const a = (thin + 1) % 3, b = (thin + 2) % 3; // cyclic, so (a, b, thin) → (x, y, z) is a rotation
151
+ const frame = Array(16).fill(0);
152
+ frame[a * 4 + 0] = 1; frame[b * 4 + 1] = 1; frame[thin * 4 + 2] = 1; frame[15] = 1;
153
+ frame[12] = -min[a]; frame[13] = -min[b]; frame[14] = -min[thin];
154
+ const t = s[thin];
155
+ return { frame, t, plies: plyCount(t), grain: grainAxis === b ? [0, 1] : [1, 0] };
92
156
  }
93
157
 
94
158
  // The material realistic mode draws this sub-part in.
95
159
  const lookOf = (sp) => resolveMaterial(realisticDisplay(sp));
96
160
 
97
- // A laser sheet with no usable material takes its look from its stock label: the first row
161
+ // A sheet part (laser or CNC-routed) with no usable material takes its look from its stock label: the first row
98
162
  // with a stem that STARTS a word of the label (ignoring case; a hyphenated word is also read
99
163
  // joined, so "poly-carbonate" is one word and "clear-acrylic" still has "acrylic"), else
100
164
  // DEFAULT_STOCK_LOOK — plywood for birch, basswood, poplar, MDF, hardboard or anything
101
165
  // unlabelled. Word starts, not substrings, so "Plexiglas" and "Acrylite" match while
102
166
  // "perplexing" does not. A residual, named in the guide: stock that is neither wood nor
103
- // acrylic (felt, leather, card, cork) takes the plywood look too, burns and all. Only a
167
+ // acrylic (felt, leather, card, cork) takes the plywood look too, burns and all; and a
168
+ // species-faced ply ("walnut plywood", "oak ply") takes that species' SOLID look, with no
169
+ // plies — name display.material to choose otherwise. Only a
104
170
  // string label is read: a (p, d) function would make a sub-part's look (and its program)
105
171
  // depend on the params it was first drawn at, so it counts as unlabelled.
106
172
  export const STOCK_LOOKS = Object.freeze([
107
173
  Object.freeze({ look: "clear-acrylic", words: Object.freeze([
108
174
  "acryl", "perspex", "plexi", "pmma", "methacryl", "polycarb", "lexan", "makrolon", "lucite",
109
175
  ]) }),
176
+ // The woods the library has a look for, so a board labelled by its species draws in it.
177
+ // Pine first: "pine plywood" and Douglas-fir sheet are softwood ply, the pine-plywood look.
178
+ Object.freeze({ look: "pine-plywood", words: Object.freeze(["pine", "douglas"]) }),
179
+ Object.freeze({ look: "walnut", words: Object.freeze(["walnut"]) }),
180
+ Object.freeze({ look: "oak", words: Object.freeze(["oak"]) }),
110
181
  ]);
111
182
  export const DEFAULT_STOCK_LOOK = "plywood";
112
183
 
@@ -121,12 +192,15 @@ export function stockLook(stock) {
121
192
  return STOCK_LOOKS.find((row) => row.words.some((stem) => words.some((w) => w.startsWith(stem))))?.look ?? DEFAULT_STOCK_LOOK;
122
193
  }
123
194
 
124
- // The stock's look for a laser sheet part, else null (lint names it in its fallback hint).
195
+ // The processes whose parts are drawn as their stock: flat stock, cut by a laser or a router.
196
+ const STOCK_LOOK_PROCESSES = new Set(["laser", "cnc-router"]);
197
+
198
+ // The stock's look for a sheet part, else null (lint names it in its fallback hint).
125
199
  export function sheetStockLook(sp) {
126
- return isSheetPart(sp) && sp.sheet.process === "laser" ? stockLook(sp.sheet.material) : null;
200
+ return isSheetPart(sp) && STOCK_LOOK_PROCESSES.has(sp.sheet.process) ? stockLook(sp.sheet.material) : null;
127
201
  }
128
202
 
129
- // The display realistic mode draws: sp.display itself, except that a laser sheet part
203
+ // The display realistic mode draws: sp.display itself, except that a sheet part
130
204
  // naming no KNOWN material — none, or one the library does not know, the same "no usable
131
205
  // material" resolve.js treats as one case — gets its stock's look. A `color` rides along,
132
206
  // so a bare colour tints that look (stained plywood, coloured acrylic).
@@ -138,26 +212,43 @@ export function realisticDisplay(sp) {
138
212
  return { ...(display && typeof display === "object" ? display : {}), material: look };
139
213
  }
140
214
 
215
+ // Is this a sheet part (any process) the viewer can trust with a canonical frame: one whose
216
+ // delivered mesh is its canonical solid moved by poseSteps alone? A custom build, an author
217
+ // place, a replaced place or a pose entry in a views map all move it off that pose.
218
+ function canonicalSheet(sp) {
219
+ if (!isSheetPart(sp)) return false;
220
+ if (sp.build !== sp.sheet.generatedBuild) return false; // a custom build is not the canonical sheet
221
+ if (sp.sheet.place != null) return false; // an author place moves it off its pose
222
+ if ((sp.place ?? null) !== (sp.sheet.generatedPlace ?? null)) return false; // place replaced after sheetPart
223
+ if (isViewsMap(sp.views) && Object.values(sp.views).some((v) => typeof v === "function")) return false; // a pose entry moves it off its pose
224
+ return true;
225
+ }
226
+
141
227
  // Does this sub-part carry the burn pass at all? Static — it reads no params — so the
142
228
  // viewer decides a sub-part's PROGRAM once, and every sub-part that answers false keeps
143
229
  // exactly the program it had before the burn existed.
144
230
  export function burnsFor(sp) {
145
231
  if (!isSheetPart(sp) || sp.sheet.process !== "laser") return false;
146
- if (sp.build !== sp.sheet.generatedBuild) return false; // a custom build is not the canonical sheet
147
- if (sp.sheet.place != null) return false; // an author place moves it off its pose
148
- if ((sp.place ?? null) !== (sp.sheet.generatedPlace ?? null)) return false; // place replaced after sheetPart
149
- if (isViewsMap(sp.views) && Object.values(sp.views).some((v) => typeof v === "function")) return false; // a pose entry moves it off its pose
232
+ if (!canonicalSheet(sp)) return false;
150
233
  try { return lookOf(sp).params.pattern === "wood"; } catch { return false; }
151
234
  }
152
235
 
236
+ // Does this sub-part want a canonical sheet frame from mount? A burning laser sheet does
237
+ // (its burn needs one), and so does any canonical sheet part — laser or routed — in a
238
+ // laminated wood, whose plies then stack exactly along its thickness. Everything else that
239
+ // is laminated stacks its plies by its bounding box (boxPlyFrame), in the viewer.
240
+ export function wantsSheetFrame(sp) {
241
+ return burnsFor(sp) || (canonicalSheet(sp) && laminatedFor(sp));
242
+ }
243
+
153
244
  // At (p, d) — a delivery's params — the frame that maps the delivered display mesh's
154
245
  // object space into the canonical sheet frame, the sheet's thickness, and how many plies
155
- // its cut walls show (0 unless its preset is `laminated`). null when burnsFor says no or
246
+ // its cut walls show (0 unless its look is laminated). null when wantsSheetFrame says no or
156
247
  // the pose or thickness cannot be read (the build reports those).
157
248
  // `frame` "canonical": the delivered mesh is the sheet's own canonical solid (place() not
158
249
  // applied), so no frame maps it — identity, same t and plies.
159
250
  export function sheetFrameFor(sp, { p, d, frame: delivered = "posed" } = {}) {
160
- if (!burnsFor(sp)) return null;
251
+ if (!wantsSheetFrame(sp)) return null;
161
252
  const meta = sheetMeta(sp, p, d);
162
253
  if (!meta) return null;
163
254
  const t = meta.thickness;
@@ -165,7 +256,7 @@ export function sheetFrameFor(sp, { p, d, frame: delivered = "posed" } = {}) {
165
256
  try { pose = typeof sp.sheet.pose === "function" ? sp.sheet.pose(p, d) : sp.sheet.pose; } catch { return null; }
166
257
  if (pose != null && validatePose(pose)) return null;
167
258
  const frame = delivered === "canonical" || pose == null ? [...IDENTITY] : invertRigid(composePose(poseSteps(pose, t)));
168
- return { frame, t, plies: lookOf(sp).preset.laminated ? plyCount(t) : 0 };
259
+ return { frame, t, plies: laminatedFor(sp) ? plyCount(t) : 0 };
169
260
  }
170
261
 
171
262
  // The shader's surface classification, in JS: the frame tests hold real geometry to it.
@@ -14,7 +14,7 @@ import { attachSelectionPopover } from "./selection-popover.js";
14
14
  import { ENVIRONMENTS } from "./materials/environments.js";
15
15
  import { declaresMaterials, resolveMaterial } from "./materials/resolve.js";
16
16
  import { printFrameMatrix } from "./materials/print-frame.js";
17
- import { burnsFor, realisticDisplay, sheetFrameFor } from "./materials/sheet-look.js";
17
+ import { realisticDisplay, sheetFrameFor, wantsSheetFrame } from "./materials/sheet-look.js";
18
18
  import { pruneOverrides } from "./materials/overrides.js";
19
19
  import { subPartRows } from "./sub-part-list.js";
20
20
  import { buildControls } from "./controls.js";
@@ -1171,7 +1171,8 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
1171
1171
  const isLayerLined = (n) => {
1172
1172
  try { return resolveMaterial(realisticDisplay(effectiveSubPart(n))).params.pattern === "layer-lines"; } catch { return false; }
1173
1173
  };
1174
- const isBurning = (n) => burnsFor(effectiveSubPart(n));
1174
+ // A burning laser sheet, or a canonical sheet part (laser or routed) in plywood.
1175
+ const isBurning = (n) => wantsSheetFrame(effectiveSubPart(n));
1175
1176
  // LAZY: a delivery only records the view and params its job was DISPATCHED with
1176
1177
  // (`dispatched`, set where the loop sends) — not the live ones, which a delivery shown
1177
1178
  // during playback was never built at; the viewer pulls the frames (computeFrames)
@@ -12,7 +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
+ import { boxPlyFrame, burnsFor, realisticDisplay } from "./materials/sheet-look.js";
16
16
  import { effectiveDisplay, mergeOverride, sanitizeDisplayPatch, subPartVisibility, APPEARANCE_OVERRIDE_KEYS, VIEWING_KEYS } from "./materials/overrides.js";
17
17
  import { neutralToneMapToSrgb8 } from "./materials/tonemap-readback.js";
18
18
  import { createMaterialThumbCache, thumbDisplay } from "./materials/thumb-cache.js";
@@ -844,14 +844,13 @@ export function createViewer(container, part) {
844
844
  const sp = part.parts[name];
845
845
  // `burn` is decided per sub-part material (sheet-look.js burnsFor): only a laser sheet in
846
846
  // a wood compiles the burn pass, so every other program is exactly what it was.
847
- // realisticDisplay: a laser sheet with no material draws as its stock (sheet-look.js).
847
+ // realisticDisplay: a sheet part with no material draws as its stock (sheet-look.js).
848
848
  // Both read sp.display, so hand them a sub-part whose display is the EFFECTIVE one
849
849
  // (authored + the user's appearance override).
850
850
  const eff = { ...sp, display: effectiveDisplayOf(name) };
851
851
  m = cloneKeepsPattern(buildPhysicalMaterial(realisticDisplay(eff), { printFrame: printFrames[name], burn: burnsFor(eff), loadTexture }));
852
852
  physicalMats.set(name, m);
853
853
  syncGrain(name);
854
- applySheetFrame(m, sheetFrames[name]);
855
854
  }
856
855
  return m;
857
856
  }
@@ -863,6 +862,7 @@ export function createViewer(container, part) {
863
862
  const m = physicalMats.get(name);
864
863
  const geo = subCache[name];
865
864
  if (m && geo) setGrainAxis(m, grainAxisFor(geo.boundingBox));
865
+ if (m) applySheetFrame(m, sheetFrames[name], name);
866
866
  }
867
867
 
868
868
  function publishMode(extra = {}) {
@@ -1159,16 +1159,34 @@ export function createViewer(container, part) {
1159
1159
 
1160
1160
  // A burning sheet's canonical frame (sheet-look.js sheetFrameFor). No frame — not yet
1161
1161
  // delivered, or not computable at these params — is pfSheetT = 0: plain wood.
1162
- function applySheetFrame(m, f) {
1162
+ // The same frame stacks a laminated sheet's plies (patterns.js applyPlies); a laminated
1163
+ // sub-part with no sheet frame stacks them by its own bounding box (boxPlyFrame).
1164
+ function applySheetFrame(m, f, name) {
1163
1165
  const u = m.userData.patternUniforms;
1164
- if (!u?.pfSheetFrame) return;
1165
- u.pfSheetFrame.value.fromArray(f?.frame ?? IDENTITY_MAT4);
1166
- u.pfSheetT.value = f?.t ?? 0;
1167
- u.pfPlies.value = f?.plies ?? 0;
1166
+ if (u?.pfSheetFrame) {
1167
+ u.pfSheetFrame.value.fromArray(f?.frame ?? IDENTITY_MAT4);
1168
+ u.pfSheetT.value = f?.t ?? 0;
1169
+ }
1170
+ if (u?.pfPlyFrame) applyPlyFrame(u, m, f, name);
1171
+ }
1172
+ function applyPlyFrame(u, m, f, name) {
1173
+ const box = subCache[name]?.boundingBox;
1174
+ const axis = m.userData.pfGrainAxis ?? 0;
1175
+ let ply = f?.plies > 0 ? { frame: f.frame, t: f.t, plies: f.plies, grain: null } : null;
1176
+ if (!ply && box && !box.isEmpty()) ply = boxPlyFrame(box.min.toArray(), box.max.toArray(), axis);
1177
+ if (!ply) { u.pfPlyT.value = 0; return; }
1178
+ u.pfPlyFrame.value.fromArray(ply.frame);
1179
+ u.pfPlyT.value = ply.t;
1180
+ u.pfPlyCount.value = ply.plies;
1181
+ if (ply.grain) { u.pfPlyGrain.value.fromArray(ply.grain); return; }
1182
+ // A sheet frame: the face grain is the wood pass's grain axis carried into it.
1183
+ const g = new THREE.Vector3().setComponent(axis, 1).transformDirection(u.pfPlyFrame.value);
1184
+ if (Math.hypot(g.x, g.y) < 1e-6) u.pfPlyGrain.value.set(1, 0);
1185
+ else u.pfPlyGrain.value.set(g.x, g.y).normalize();
1168
1186
  }
1169
1187
  function setSheetFrames(frames) {
1170
1188
  sheetFrames = frames ?? {};
1171
- for (const [n, m] of physicalMats) applySheetFrame(m, sheetFrames[n]);
1189
+ for (const [n, m] of physicalMats) applySheetFrame(m, sheetFrames[n], n);
1172
1190
  }
1173
1191
 
1174
1192
  // The lazy form mount uses. Frames are PULLED only when something is about to draw the
@@ -5,6 +5,7 @@
5
5
  import { PRESETS } from "../framework/materials/presets.js";
6
6
  import { sheetPart } from "../framework/sheet/part.js";
7
7
  import { sheetHole } from "../framework/sheet/joinery.js";
8
+ import { routedPart } from "../framework/sheet/routed.js";
8
9
 
9
10
  const ids = Object.keys(PRESETS);
10
11
  const COLS = 6, PITCH = 40;
@@ -32,11 +33,11 @@ const withHole = (k) =>
32
33
  // can diff them pixel for pixel — and one row of sheets that name no material, drawn as
33
34
  // their stock (sheet-look.js STOCK_LOOKS).
34
35
  const SW = 30;
35
- const WOODS = ["plywood", "oak", "walnut"];
36
+ const WOODS = ["plywood", "pine-plywood", "oak", "walnut"];
36
37
  const CONTROLS_Y = -WOODS.length * PITCH;
37
38
  const LASER_SWATCHES = [
38
39
  ...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
+ key: `${material.replaceAll("-", "_")}_${t}`, label: `${PRESETS[material].label}, ${t} mm`, at: [col * PITCH, -row * PITCH], t, display: { material },
40
41
  }))),
41
42
  { key: "acrylic_3", label: "Clear acrylic, 3 mm", at: [0, CONTROLS_Y], t: 3, display: { material: "clear-acrylic" }, stock: "clear acrylic" },
42
43
  { key: "oak_standing", label: "Oak, 3 mm, standing", at: [PITCH, CONTROLS_Y], t: 3, display: { material: "oak" }, standing: true },
@@ -71,10 +72,56 @@ function laserSwatch(s, unburnt) {
71
72
  });
72
73
  }
73
74
 
75
+ // The "Plywood edges" view: the ply pass (materials/patterns.js applyPlies) on every kind of
76
+ // plywood wall — CNC-routed panels at 6, 12 and 18 mm (a rounded profile, a pocket and a
77
+ // through drill, so straight, curved and inner walls all show), one standing on edge, a
78
+ // routed disc that names no material (drawn as its stock; a wall that turns through every grain direction), an 18 mm pine-plywood panel, a 6 mm laser panel (plies
79
+ // under the char), and two plain solids in plywood that are not sheet parts at all (their
80
+ // plies stack along the thinnest axis).
81
+ const PW = 50;
82
+ const roundedRect = (k, x, y, w, h, r) => k.shape2d([[x, y], [x + w, y], [x + w, y + h], [x, y + h]]).fillet(r);
83
+ const ROUTED = [[6, 0], [12, 1], [18, 2]];
84
+ const plywoodParts = {
85
+ ...Object.fromEntries(ROUTED.map(([t, col]) => [`ply_routed_${t}`, routedPart({
86
+ label: `Routed plywood, ${t} mm`, views: ["plywood"], material: "birch plywood", thickness: t, display: { material: "plywood" },
87
+ profile: (k) => roundedRect(k, col * (PW + 15), 0, PW, 36, 6),
88
+ pockets: (k) => [{ region: roundedRect(k, col * (PW + 15) + 6, 6, 18, 24, 3), depth: t / 2 }],
89
+ drills: () => [{ at: [col * (PW + 15) + 37, 18], d: 10, depth: t }],
90
+ })])),
91
+ ply_routed_standing: routedPart({
92
+ label: "Routed plywood, 12 mm, standing", views: ["plywood"], material: "birch plywood", thickness: 12, display: { material: "plywood" },
93
+ profile: (k) => roundedRect(k, 0, 0, PW, 36, 6),
94
+ drills: () => [{ at: [25, 18], d: 10, depth: 12 }],
95
+ pose: { face: "-Y", up: "+Z", at: [0, -30, 0] },
96
+ }),
97
+ ply_routed_disc: routedPart({
98
+ // names no material: drawn as its stock (sheet-look.js realisticDisplay)
99
+ label: "Routed plywood disc, 18 mm (no material)", views: ["plywood"], material: "birch plywood", thickness: 18,
100
+ profile: (k) => k.shape2d(Array.from({ length: 96 }, (_, i) => [90 + 22 * Math.cos((i / 96) * 2 * Math.PI), -45 + 22 * Math.sin((i / 96) * 2 * Math.PI)])),
101
+ }),
102
+ ply_routed_pine: routedPart({
103
+ label: "Routed pine plywood, 18 mm", views: ["plywood"], material: "pine plywood", thickness: 18, display: { material: "pine-plywood" },
104
+ profile: (k) => roundedRect(k, 130, -100, PW, 30, 6),
105
+ drills: () => [{ at: [165, -85], d: 10, depth: 18 }],
106
+ }),
107
+ ply_laser_6: sheetPart({
108
+ label: "Laser-cut plywood, 6 mm", views: ["plywood"], material: "birch plywood", thickness: 6, display: { material: "plywood" },
109
+ profile: (k) => k.shape2d([[130, -60], [130 + SW, -60], [130 + SW, -60 + SW], [130, -60 + SW]]).cut(sheetHole({ d: 8, at: [151, -39] })),
110
+ }),
111
+ ply_block: {
112
+ label: "Plywood block (not a sheet part)", views: ["plywood"], display: { material: "plywood" },
113
+ build: (k) => k.box({ min: [0, -100, 0], max: [40, -70, 18] }).cut(k.cylinder({ d: 10, h: 30 }).translate([28, -85, -5])),
114
+ },
115
+ ply_filleted: {
116
+ label: "Filleted plywood block (not a sheet part)", views: ["plywood"], display: { material: "plywood" },
117
+ build: (k) => k.box({ min: [60, -100, 0], max: [100, -70, 18] }).fillet({ r: 4 }),
118
+ },
119
+ };
120
+
74
121
  export default {
75
122
  meta: { title: "Material swatches", units: "mm", environment: "studio" },
76
123
  defaults: {},
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" } },
124
+ 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" }, plywood: { label: "Plywood edges" } },
78
125
  parts: {
79
126
  ...Object.fromEntries(ids.map((id, i) => [id.replaceAll("-", "_"), {
80
127
  label: PRESETS[id].label,
@@ -121,5 +168,6 @@ export default {
121
168
  [`laser_${s.key}`, laserSwatch(s, false)],
122
169
  [`unburnt_${s.key}`, laserSwatch(s, true)],
123
170
  ])),
171
+ ...plywoodParts,
124
172
  },
125
173
  };