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