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.
- package/docs/AUTHORING-PARTS.md +20 -6
- package/package.json +1 -1
- package/src/framework/gltf/decode-preview.js +62 -5
- package/src/framework/gltf/encode-preview.js +25 -6
- package/src/framework/lint/rules-materials.js +1 -1
- package/src/framework/materials/patterns.js +121 -17
- package/src/framework/materials/physical.js +6 -3
- package/src/framework/materials/presets.js +11 -10
- package/src/framework/materials/sheet-look.js +111 -20
- package/src/framework/mount-preview.js +3 -2
- package/src/framework/mount.js +3 -2
- package/src/framework/viewer.js +42 -12
- package/src/parts/material-swatches.js +49 -1
- package/types/gltf.d.ts +6 -2
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -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
|
|
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`),
|
|
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
|
|
493
|
-
| `pine-plywood` | Sanded pine plywood | — | Hardware-store 4 × 8 sheet plywood (sanded pine/fir face): warm, bold grain; plies show on
|
|
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
|
@@ -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
|
|
71
|
-
|
|
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 ??
|
|
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), //
|
|
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
|
|
7
|
-
//
|
|
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
|
|
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 =
|
|
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(
|
|
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
|
|
9
|
-
//
|
|
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
|
|
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
|
-
|
|
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)
|
|
83
|
-
//
|
|
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
|
|
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).
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
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
|
|
46
|
-
//
|
|
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
|
|
87
|
-
// both outer plies run with the face
|
|
88
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 (!
|
|
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:
|
|
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
|
|
12
|
-
// residual
|
|
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";
|
package/src/framework/mount.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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)
|
package/src/framework/viewer.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 (
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
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
|
-
|
|
1727
|
-
|
|
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
|
-
/**
|
|
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. */
|