partforge 0.152.0 → 0.154.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.
@@ -403,7 +403,7 @@ meta: { title: "…", units: "mm", environment: "studio" },
403
403
  - **One material per sub-part.** Something that needs two finishes (a knurled
404
404
  grip in rubber on an aluminium body) is two sub-parts.
405
405
 
406
- **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
407
407
  one the library does not know) is not drawn as a PLA print: realistic mode shows it as the
408
408
  sheet its stock label names — the first row with a stem that starts a word of the label,
409
409
  ignoring case (so `Plexiglas`, `Acrylite` and `poly-carbonate` match; `perplexing` does not):
@@ -411,10 +411,14 @@ ignoring case (so `Plexiglas`, `Acrylite` and `poly-carbonate` match; `perplexin
411
411
  | A word of the stock label starts with | Realistic look |
412
412
  | --- | --- |
413
413
  | `acryl`, `perspex`, `plexi`, `pmma`, `methacryl`, `polycarb`, `lexan`, `makrolon`, `lucite` | `clear-acrylic` |
414
+ | `pine`, `douglas` | `pine-plywood` |
415
+ | `walnut` | `walnut` |
416
+ | `oak` | `oak` |
414
417
  | anything else — plywood, birch, basswood, poplar, MDF, hardboard — or no readable label | `plywood` |
415
418
 
416
419
  Stock that is neither wood nor acrylic — felt, leather, card, cork — takes the `plywood`
417
- 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
418
422
  stained plywood, a coloured acrylic); the CAD view still shows the `color` itself. Only a
419
423
  string label is read: a stock written as a `(p, d)` function counts as unreadable. Name
420
424
  `display.material` to choose the look yourself — for such stock, or any other. Like the
@@ -445,6 +449,16 @@ from the sheet and the wood it is drawn in. It needs the sheet's own frame, so a
445
449
  with a custom `build` or a `views` pose of its own shows plain wood. The CAD view and every export are
446
450
  unchanged.
447
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
+
448
462
  **`textureScale` is millimetres, and what it measures depends on the preset's
449
463
  pattern** — so a value copied from one preset family is wrong on another (0.2
450
464
  on oak shrinks the grain to a 0.2 mm tile, i.e. invisible noise):
@@ -453,7 +467,7 @@ on oak shrinks the grain to a 0.2 mm tile, i.e. invisible noise):
453
467
  | --- | --- | --- | --- |
454
468
  | layer lines | `pla-print`, `petg-print` | layer height | 0.2 |
455
469
  | SLS grain | `nylon-sls` | grain size | 0.15 |
456
- | wood | `oak`, `walnut`, `plywood`, `pine-plywood` | size of one texture tile (the grain repeats every this many mm) | 250 (`oak`), 400 (`walnut`), 150 (`plywood`), 1000 (`pine-plywood`, sized for whole 4 × 8 sheets) |
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) |
457
471
  | carbon weave | `carbon-fiber` | size of one texture tile | 48 |
458
472
 
459
473
  Presets without a pattern ignore it.
@@ -489,8 +503,8 @@ the viewer's blue-grey.
489
503
  | `rubber` | Rubber | yes | Matte elastomer: gaskets, feet, grips; tint with `color`. |
490
504
  | `oak` | Oak | — | Light oak with open grain. |
491
505
  | `walnut` | Walnut | — | Dark oiled walnut. |
492
- | `plywood` | Birch plywood | — | Birch plywood sheet: a pale, fine-grained face; its plies show on laser-cut edges. |
493
- | `pine-plywood` | Sanded pine plywood | — | Hardware-store 4 × 8 sheet plywood (sanded pine/fir face): warm, bold grain; 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. |
494
508
  | `carbon-fiber` | Carbon fibre | — | 2x2 twill carbon fibre under clear coat. |
495
509
 
496
510
  **Environments** (`meta.environment`, default `studio`; viewers can switch):
@@ -3963,7 +3977,7 @@ rigid motion — translate/rotate — never a reshape; an error). A pose the pro
3963
3977
 
3964
3978
  **Appearance** (all warnings) — `unknown-material` (a `display.material` the
3965
3979
  library does not know; the viewer draws it as if it named none — a PLA print
3966
- 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
3967
3981
  default to their stock"), `unknown-environment`
3968
3982
  (`meta.environment` not known; realistic mode uses `studio`),
3969
3983
  `material-key-unknown` (a `display` key that is not colour, opacity, material
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.152.0",
3
+ "version": "0.154.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",
@@ -11,6 +11,16 @@
11
11
  // rotation; parenting the glTF root under the pivot would turn the part twice.
12
12
  // Undoing the root here hands the viewer geometry in the frame it already owns.
13
13
  //
14
+ // Feature edges (encoder version 2): a sub-part's mesh may carry a second
15
+ // primitive, mode LINES, holding the kernel's edge segments. GLTFLoader then
16
+ // makes the node a Group of a Mesh and a LineSegments (the node's extras and
17
+ // name on the Group, not the Mesh), so each surface and its lines are matched
18
+ // through the glTF NODE they belong to. Lines go back to model frame through
19
+ // the same matrix as the positions; a node with no line primitive — version 1,
20
+ // or a sub-part whose edges were empty — decodes to an EMPTY Float32Array,
21
+ // which the viewer reads as "no feature edges" (never undefined, which would
22
+ // make it derive lines by angle).
23
+ //
14
24
  // Rejects on anything that is not a decodable GLB; the callers (mount's
15
25
  // `preview`, mountPreview) decide what a failure means for them.
16
26
  import { Matrix3, Matrix4, Vector3 } from "three";
@@ -50,15 +60,50 @@ function metaOf(extras) {
50
60
  return Object.keys(out).length ? out : null;
51
61
  }
52
62
 
63
+ // The object standing for a glTF node: the object itself when its node had one
64
+ // primitive, else the Group GLTFLoader made for a multi-primitive mesh — the
65
+ // one carrying the node's extras / name (a primitive's own Mesh has neither).
66
+ function nodeObjectOf(obj) {
67
+ const own = obj.userData ?? {};
68
+ if ("partforgeSubPart" in own || "name" in own) return obj;
69
+ const parent = obj.parent;
70
+ return parent?.isGroup && parent.userData && ("partforgeSubPart" in parent.userData || "name" in parent.userData) ? parent : obj;
71
+ }
72
+
73
+ // Line segments as endpoint pairs in model frame (an indexed LINES primitive —
74
+ // weld() indexes it — expanded back to pairs).
75
+ function readSegments(geometry, matrix) {
76
+ const position = geometry.getAttribute("position");
77
+ if (!position || position.count === 0) return null;
78
+ const points = readVec3(position, matrix, false);
79
+ if (!geometry.index) return points;
80
+ const index = geometry.index.array;
81
+ const out = new Float32Array(index.length * 3);
82
+ for (let i = 0; i < index.length; i++) out.set(points.subarray(index[i] * 3, index[i] * 3 + 3), i * 3);
83
+ return out;
84
+ }
85
+
53
86
  export async function decodePreviewGlb(bytes) {
54
87
  const data = toArrayBuffer(bytes);
55
88
  const loader = new GLTFLoader().setMeshoptDecoder(MeshoptDecoder);
56
89
  const gltf = await loader.parseAsync(data, "");
57
90
  gltf.scene.updateMatrixWorld(true);
58
91
  const meshes = [];
92
+ const edgesByNode = new Map(); // node object → model-frame segment arrays
93
+ const meshByNode = new Map(); // node object → its surface payload
59
94
  const toModel = new Matrix4();
60
95
  const normalMatrix = new Matrix3();
61
96
  gltf.scene.traverse((obj) => {
97
+ if (obj.isLineSegments) {
98
+ toModel.multiplyMatrices(Y_UP_TO_Z_UP, obj.matrixWorld);
99
+ const segments = readSegments(obj.geometry, toModel);
100
+ if (segments) {
101
+ const node = nodeObjectOf(obj);
102
+ edgesByNode.set(node, [...(edgesByNode.get(node) ?? []), segments]);
103
+ }
104
+ obj.geometry.dispose();
105
+ return;
106
+ }
62
107
  if (!obj.isMesh) return;
63
108
  const geometry = obj.geometry;
64
109
  const position = geometry.getAttribute("position");
@@ -67,19 +112,31 @@ export async function decodePreviewGlb(bytes) {
67
112
  normalMatrix.getNormalMatrix(toModel);
68
113
  const normal = geometry.getAttribute("normal");
69
114
  const indices = geometry.index ? Uint32Array.from(geometry.index.array) : null;
70
- const { partforgeSubPart, partforgeDisplay, name: originalName } = obj.userData;
71
- meshes.push({
115
+ const node = nodeObjectOf(obj);
116
+ const { partforgeSubPart, partforgeDisplay, name: originalName } = node.userData;
117
+ const payload = {
72
118
  // The writer's own record of the key, then the unsanitized glTF node name.
73
- name: typeof partforgeSubPart === "string" ? partforgeSubPart : (originalName ?? obj.name),
119
+ name: typeof partforgeSubPart === "string" ? partforgeSubPart : (originalName ?? node.name),
74
120
  positions: readVec3(position, toModel, false),
75
121
  normals: normal ? readVec3(normal, normalMatrix, true) : null,
76
122
  indices,
77
123
  triangles: (indices ? indices.length : position.count) / 3,
78
- edges: new Float32Array(0), // previews carry no feature edges (v1)
124
+ edges: new Float32Array(0), // filled below from the node's LINES primitive, if any
79
125
  display: partforgeDisplay && typeof partforgeDisplay === "object" ? partforgeDisplay : null,
80
- });
126
+ };
127
+ meshes.push(payload);
128
+ if (!meshByNode.has(node)) meshByNode.set(node, payload);
81
129
  geometry.dispose();
82
130
  });
131
+ for (const [node, parts] of edgesByNode) {
132
+ const payload = meshByNode.get(node);
133
+ if (!payload) continue; // lines with no surface: nothing to draw them on
134
+ const total = parts.reduce((n, p) => n + p.length, 0);
135
+ const edges = new Float32Array(total);
136
+ let at = 0;
137
+ for (const p of parts) { edges.set(p, at); at += p.length; }
138
+ payload.edges = edges;
139
+ }
83
140
  const extras = gltf.userData ?? {};
84
141
  return {
85
142
  view: typeof extras.partforgeView === "string" ? extras.partforgeView : null,
@@ -3,8 +3,15 @@
3
3
  // buildView all hold) as a small GLB a viewer can show before the kernel boots.
4
4
  //
5
5
  // The file, which a reader may rely on:
6
- // - one node + mesh + primitive per sub-part, all three named by the sub-part
7
- // key; POSITION + NORMAL (+ indices when the kernel sent them)
6
+ // - one node + mesh per sub-part, both named by the sub-part key; the mesh's
7
+ // FIRST primitive is the surface (TRIANGLES: POSITION + NORMAL, + indices
8
+ // when the kernel sent them); a sub-part with feature edges adds a SECOND
9
+ // primitive, mode LINES, POSITION only and no material — the kernel's edge
10
+ // segments in the same posed frame. A sub-part whose `edges` is empty or
11
+ // absent has no line primitive; a reader takes that as "no feature edges"
12
+ // (never "derive them"). Version-1 files have no line primitives at all.
13
+ // Both primitives share the mesh, so quantize() gives them ONE quantization
14
+ // volume and one node transform: the lines dequantize onto the faces.
8
15
  // - every sub-part node under ONE root node, "partforge", rotated −90° about X:
9
16
  // partforge models Z-up in millimetres and glTF is Y-up. Units stay mm — the
10
17
  // file is for partforge's own viewer; metres would only matter for AR.
@@ -14,20 +21,20 @@
14
21
  // - top-level extras { partforgeView, partforgeEncoder, partforgeMeta? }
15
22
  // - weld() → quantize() → meshopt({ level: "medium" }): 5–8 bytes a triangle,
16
23
  // the recipe measured in partforge-cloud's local_glb_benchmark.md
17
- // Not encoded (v1): feature edges, textures, patterns.
24
+ // Not encoded: textures, patterns. (Feature edges since version 2.)
18
25
  //
19
26
  // Runs in Node, a page and a Web Worker: no three.js, no DOM, no Blob
20
27
  // (test/gltf-layering.test.js). Deterministic: the same input writes the same
21
28
  // bytes, and nothing here mutates the input (exportPreview passes the viewer's
22
29
  // live buffers). Bump PREVIEW_ENCODER_VERSION whenever the bytes for the same
23
30
  // input change — downstream caches key on it.
24
- import { Document, Logger, WebIO } from "@gltf-transform/core";
31
+ import { Document, Logger, Primitive, WebIO } from "@gltf-transform/core";
25
32
  import { EXTMeshoptCompression, KHRMeshQuantization } from "@gltf-transform/extensions";
26
33
  import { meshopt, quantize, weld } from "@gltf-transform/functions";
27
34
  import { MeshoptEncoder } from "meshoptimizer/encoder";
28
35
  import { cadAppearance, DISPLAY_KEYS } from "../materials/resolve.js";
29
36
 
30
- export const PREVIEW_ENCODER_VERSION = 1;
37
+ export const PREVIEW_ENCODER_VERSION = 2; // 2: feature edges as a LINES primitive
31
38
 
32
39
  // −90° about X as [x, y, z, w]: model (x, y, z) → glTF (x, z, −y), the viewer's pivot.
33
40
  const Z_UP_TO_Y_UP = [-Math.SQRT1_2, 0, 0, Math.SQRT1_2];
@@ -72,6 +79,13 @@ function checkMesh(m, seen) {
72
79
  if (m.normals != null && m.normals.length > 0 && m.normals.length !== m.positions.length) {
73
80
  throw new Error(`encodePreviewGlb: "${m.name}" normals do not match its positions`);
74
81
  }
82
+ // Any zero-length edges (typed or plain) mean "no feature edges": nothing to check.
83
+ if (m.edges != null && m.edges.length !== 0) {
84
+ if (!ArrayBuffer.isView(m.edges) && !Array.isArray(m.edges)) {
85
+ throw new TypeError(`encodePreviewGlb: "${m.name}" edges must be a typed array or an array of numbers`);
86
+ }
87
+ if (m.edges.length % 6 !== 0) throw new Error(`encodePreviewGlb: "${m.name}" edges are not whole segments`);
88
+ }
75
89
  if (indexed) {
76
90
  const vertexCount = m.positions.length / 3;
77
91
  for (let i = 0; i < m.indices.length; i++) {
@@ -126,9 +140,14 @@ export async function encodePreviewGlb({ meshes, part = null, view = null } = {}
126
140
  .setMetallicFactor(look.metalness)
127
141
  .setRoughnessFactor(look.roughness)
128
142
  .setAlphaMode(look.opacity < 1 ? "BLEND" : "OPAQUE"));
143
+ const mesh = doc.createMesh(m.name).addPrimitive(prim);
144
+ if (m.edges?.length) {
145
+ mesh.addPrimitive(doc.createPrimitive().setMode(Primitive.Mode.LINES).setAttribute(
146
+ "POSITION", doc.createAccessor().setType("VEC3").setArray(Float32Array.from(m.edges)).setBuffer(buffer)));
147
+ }
129
148
  const extras = displayExtras(display);
130
149
  root.addChild(doc.createNode(m.name)
131
- .setMesh(doc.createMesh(m.name).addPrimitive(prim))
150
+ .setMesh(mesh)
132
151
  .setExtras(extras ? { partforgeSubPart: m.name, partforgeDisplay: extras } : { partforgeSubPart: m.name }));
133
152
  }
134
153
  if (root.listChildren().length === 0) throw new Error("encodePreviewGlb: every mesh was empty");
@@ -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);
@@ -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,24 +79,25 @@ 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). Its
94
- // textureScale is set for whole sheets, not hand-sized parts: one tile per metre, so the
95
- // bold cathedral figure does not visibly repeat across a 1220 mm panel (the scan itself
96
- // is 800 mm; 1000 is the top of OVERRIDE_RANGES.textureScale). Plies show on laser-cut
97
- // walls only — the ply bands ride the laser burn pass (sheet-look.js burnsFor).
98
- "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 laser-cut edges.",
99
- { color: 0xe0b77d, metalness: 0, roughness: 0.6, pattern: "wood", textureScale: 1000, laminated: true,
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,
100
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" } }),
101
102
  "carbon-fiber": P("carbon-fiber", "Carbon fibre", "natural", "2x2 twill carbon fibre under clear coat.",
102
103
  { color: 0x1b1c1e, metalness: 0.2, roughness: 0.35, clearcoat: 1, clearcoatRoughness: 0.05, pattern: "carbon", textureScale: 48 }),
@@ -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.
@@ -8,8 +8,9 @@
8
8
  // built once the GLB has decoded, from a synthetic part: one sub-part per glTF
9
9
  // mesh, carrying the `display` block the writer stored beside it, and the file's
10
10
  // background/environment meta. The CAD look is exact; realistic mode uses each
11
- // sub-part's display only — no sheet stock, no layer-line print frames (a v1
12
- // residual alongside "no edges"). The decoder loads on demand (it carries the
11
+ // sub-part's display only — no sheet stock, no layer-line print frames (a
12
+ // residual of the format). CAD feature edges come from the file (encoder
13
+ // version 2); a version-1 file decodes with none. The decoder loads on demand (it carries the
13
14
  // GLTFLoader and meshopt), so a bundle that never mounts a preview never ships it.
14
15
  // Hence `ready`. Every handle method is safe before it resolves.
15
16
  import "./app.css";
@@ -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
@@ -1723,9 +1741,12 @@ export function createViewer(container, part) {
1723
1741
  // draw none rather than falling back. Only a payload with NO edge data at
1724
1742
  // all (edges === undefined; no current backend does this) derives by angle.
1725
1743
  const lg = new LineSegmentsGeometry();
1726
- if (edges) lg.setPositions(edges); // edges is already a well-formed (possibly zero-length) Float32Array
1727
- else lg.fromEdgesGeometry(new THREE.EdgesGeometry(out, EDGE_ANGLE));
1744
+ // The raw segment pairs drawn, kept for getSubMeshPayload (reading them back
1745
+ // out of LineSegmentsGeometry's interleaved instance buffer is awkward).
1746
+ const segments = edges ?? new THREE.EdgesGeometry(out, EDGE_ANGLE).getAttribute("position").array;
1747
+ lg.setPositions(segments); // a well-formed (possibly zero-length) Float32Array
1728
1748
  out.userData.edges = lg;
1749
+ out.userData.edgePositions = segments;
1729
1750
  return out;
1730
1751
  }
1731
1752
 
@@ -1772,7 +1793,9 @@ export function createViewer(container, part) {
1772
1793
  // The geometry drawn for one sub-part, as a fresh worker-style payload with its
1773
1794
  // fast-path pose (setSubPose) baked in — the posed mesh a headless buildView
1774
1795
  // would return. Copies: the caller (exportPreview → partforge/gltf) may keep or
1775
- // transfer them. null when nothing has been delivered under that name.
1796
+ // transfer them. null when nothing has been delivered under that name. Its
1797
+ // `edges` are the lines shown on screen: the kernel's segments, or — when the
1798
+ // delivery carried no edge data at all — the angle-derived fallback lines.
1776
1799
  const _payloadV = new THREE.Vector3();
1777
1800
  const _payloadNormal = new THREE.Matrix3();
1778
1801
  function getSubMeshPayload(name) {
@@ -1793,10 +1816,17 @@ export function createViewer(container, part) {
1793
1816
  _payloadV.fromBufferAttribute(nor, i).applyMatrix3(_payloadNormal).normalize().toArray(normals, i * 3);
1794
1817
  }
1795
1818
  }
1819
+ // Feature edges, posed by the same matrix (an empty array stays empty: "none").
1820
+ const rawEdges = geo.userData.edgePositions ?? new Float32Array(0);
1821
+ const edges = new Float32Array(rawEdges.length);
1822
+ for (let i = 0; i < rawEdges.length; i += 3) {
1823
+ _payloadV.fromArray(rawEdges, i).applyMatrix4(pose).toArray(edges, i);
1824
+ }
1796
1825
  return {
1797
1826
  name,
1798
1827
  positions,
1799
1828
  normals,
1829
+ edges,
1800
1830
  indices: geo.index ? Uint32Array.from(geo.index.array) : null,
1801
1831
  triangles: geo.userData.triangles ?? (geo.index ? geo.index.count : pos.count) / 3,
1802
1832
  };
@@ -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;
@@ -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
  };
package/types/gltf.d.ts CHANGED
@@ -20,7 +20,11 @@ export interface PreviewMesh {
20
20
  /** Absent for a non-indexed soup (Manifold); OCCT sends indices. */
21
21
  indices?: Uint32Array | null;
22
22
  triangles?: number;
23
- /** Ignored: feature edges are not encoded in this version. */
23
+ /**
24
+ * Feature-edge segments, endpoint pairs `[x0,y0,z0, x1,y1,z1, …]` in the same posed
25
+ * frame as `positions` (encoder version 2+). Written as a LINES primitive on the
26
+ * sub-part's mesh. Empty or absent writes none, and decodes as "no feature edges".
27
+ */
24
28
  edges?: Float32Array | null;
25
29
  }
26
30
 
@@ -33,7 +37,7 @@ export interface EncodePreviewInput {
33
37
  view?: string | null;
34
38
  }
35
39
 
36
- /** Bumped whenever the encoder writes different bytes for the same input. */
40
+ /** Bumped whenever the encoder writes different bytes for the same input (2: feature edges). */
37
41
  export const PREVIEW_ENCODER_VERSION: number;
38
42
 
39
43
  /** Encode one view's posed meshes as a welded, quantized, meshopt-compressed GLB (Y-up, millimetres). Deterministic. */