partforge 0.134.0 → 0.136.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -334,9 +334,10 @@ meta: { title: "…", units: "mm", environment: "studio" },
334
334
  - `material` — a preset id from the table below. Without one (or with one the
335
335
  library does not know) the sub-part keeps the CAD view's look — its `color`,
336
336
  else the viewer's blue-grey — and **realistic mode shows it as a PLA print**
337
- (`pla-print`'s finish and layer lines) in that same colour. So an untouched
338
- part looks printed in realistic mode; name a material when it is made some
339
- other way. The PLA look is realistic-only: the CAD view, export colours and
337
+ (`pla-print`'s finish and layer lines) in that same colour — except a laser
338
+ `sheetPart`, which shows its stock instead (below). So an untouched part looks
339
+ printed in realistic mode; name a material when it is made some other way. The
340
+ PLA look is realistic-only: the CAD view, export colours and
340
341
  `declaresMaterials` are unaffected, and a part that names no material is not
341
342
  treated as declaring one.
342
343
  - `color` — the base colour (`0xRRGGBB`). With a preset it is the TINT. Presets
@@ -349,6 +350,23 @@ meta: { title: "…", units: "mm", environment: "studio" },
349
350
  - **One material per sub-part.** Something that needs two finishes (a knurled
350
351
  grip in rubber on an aluminium body) is two sub-parts.
351
352
 
353
+ **Sheet parts default to their stock.** A laser `sheetPart` that names no material (or
354
+ one the library does not know) is not drawn as a PLA print: realistic mode shows it as the
355
+ sheet its stock label names — the first row with a stem that starts a word of the label,
356
+ ignoring case (so `Plexiglas`, `Acrylite` and `poly-carbonate` match; `perplexing` does not):
357
+
358
+ | A word of the stock label starts with | Realistic look |
359
+ | --- | --- |
360
+ | `acryl`, `perspex`, `plexi`, `pmma`, `methacryl`, `polycarb`, `lexan`, `makrolon`, `lucite` | `clear-acrylic` |
361
+ | anything else — plywood, birch, basswood, poplar, MDF, hardboard — or no readable label | `plywood` |
362
+
363
+ Stock that is neither wood nor acrylic — felt, leather, card, cork — takes the `plywood`
364
+ look too, charred edges and all. A `color` tints the look, as it tints any preset (a
365
+ stained plywood, a coloured acrylic); the CAD view still shows the `color` itself. Only a
366
+ string label is read: a stock written as a `(p, d)` function counts as unreadable. Name
367
+ `display.material` to choose the look yourself — for such stock, or any other. Like the
368
+ PLA look, this is realistic-only and never makes a part declare a material.
369
+
352
370
  **Where it shows.** The CAD view (with feature lines) shows each material's
353
371
  colour, flattened so dark materials stay readable. **Realistic** mode — the
354
372
  viewer's toggle — shows the full material under environment lighting, with a
@@ -365,6 +383,15 @@ displayed. If the lines run the wrong way, fix the export pose with `place()`
365
383
  fixed to the sub-part, so they never slide when the camera or an animation
366
384
  moves.
367
385
 
386
+ **Laser-cut wood.** In realistic mode a laser `sheetPart` in a wood — `plywood`, `oak` or
387
+ `walnut`, named or its stock's default (so a plywood sheet that names no material burns too)
388
+ — shows what the laser did: its cut edges are charred, darker on thicker stock (on
389
+ `plywood` the plies show through), and its engraving and score lines are scorched, while its
390
+ faces stay wood. There is nothing to set, and it is still one material: the char follows
391
+ from the sheet and the wood it is drawn in. It needs the sheet's own frame, so a sheet part
392
+ with a custom `build` or its own `place` shows plain wood. The CAD view and every export are
393
+ unchanged.
394
+
368
395
  **`textureScale` is millimetres, and what it measures depends on the preset's
369
396
  pattern** — so a value copied from one preset family is wrong on another (0.2
370
397
  on oak shrinks the grain to a 0.2 mm tile, i.e. invisible noise):
@@ -373,7 +400,7 @@ on oak shrinks the grain to a 0.2 mm tile, i.e. invisible noise):
373
400
  | --- | --- | --- | --- |
374
401
  | layer lines | `pla-print`, `petg-print` | layer height | 0.2 |
375
402
  | SLS grain | `nylon-sls` | grain size | 0.15 |
376
- | wood | `oak`, `walnut` | size of one texture tile (the grain repeats every this many mm) | 250 (`oak`), 400 (`walnut`) |
403
+ | wood | `oak`, `walnut`, `plywood` | size of one texture tile (the grain repeats every this many mm) | 250 (`oak`), 400 (`walnut`), 150 (`plywood`) |
377
404
  | carbon weave | `carbon-fiber` | size of one texture tile | 48 |
378
405
 
379
406
  Presets without a pattern ignore it.
@@ -409,6 +436,7 @@ the viewer's blue-grey.
409
436
  | `rubber` | Rubber | yes | Matte elastomer: gaskets, feet, grips; tint with `color`. |
410
437
  | `oak` | Oak | — | Light oak with open grain. |
411
438
  | `walnut` | Walnut | — | Dark oiled walnut. |
439
+ | `plywood` | Birch plywood | — | Birch plywood sheet: a pale, fine-grained face; its plies show on laser-cut edges. |
412
440
  | `carbon-fiber` | Carbon fibre | — | 2x2 twill carbon fibre under clear coat. |
413
441
 
414
442
  **Environments** (`meta.environment`, default `studio`; viewers can switch):
@@ -1808,8 +1836,8 @@ needing the kernel is `(k, p, d) => …`; a plain value is a literal or `(p, d)
1808
1836
  `material`, `thickness` and `profile` are required; `score`, `engrave`, `pose`,
1809
1837
  `process` (default `"laser"`) are optional; `label`, `views`, `display`, `export`,
1810
1838
  `enabled`, `exportable`, `reference` pass through; your own `place` runs after the
1811
- pose. `build` is supplied — passing one throws, as do `kerf`, `outline`/`cut` (use
1812
- `profile`) and `quantity` (the kit counts identical pieces).
1839
+ pose. `build` is supplied — passing one throws, as do `kerf`, `outline`/`cut` and
1840
+ `quantity`; each error names the fix.
1813
1841
 
1814
1842
  ### Cut, score and engrave
1815
1843
 
@@ -1898,7 +1926,7 @@ PRINT pose, `place` it for display, and cut the slot where the tongue lands:
1898
1926
  | `verify-process-sheets-only` | warning | `verify.process` is set but every exportable part is a sheet part |
1899
1927
  | `laser-thickness-range` | warning | a laser sheet is thinner than 0.5 mm or thicker than 12 mm |
1900
1928
 
1901
- **Verify** runs the laser checks on every sheet part. They are
1929
+ **Verify** runs the laser checks on every sheet part, as
1902
1930
  *volunteered* warnings: none counts toward `declared`/`evaluated`, so none makes
1903
1931
  `verify.ok` true on its own. Declare one in `expect` to make it count:
1904
1932
 
@@ -1928,12 +1956,13 @@ A host downloads sheet parts as one ZIP, the **cut & print kit** (`format: "bund
1928
1956
 
1929
1957
  - One thickness per joint: fingers, tabs and T-slots join panels cut from the
1930
1958
  same sheet.
1931
- - Poses are rigid and axis-aligned: `face` and `up` are the six axis words. A
1932
- panel at an angle is an author `place` after the pose, or a printed part.
1959
+ - Poses are rigid and axis-aligned: `face` and `up` are the six axis words. An
1960
+ angled panel is an author `place` after the pose (realistic mode then shows
1961
+ no laser burns), or a printed part.
1933
1962
  - No bends, folds, living hinges or grain direction: `folds`, `bends` and
1934
1963
  `grain` are reserved keys and throw.
1935
- - Cut and score are vector lines; engraving is filled regions. There is no raster
1936
- image engraving.
1964
+ - Cut and score are vector lines; engraving is filled regions, never a raster
1965
+ image.
1937
1966
  - The laser checks are warnings. Sharp-corner shrinking can over-report at acute
1938
1967
  tips, and a web within 0.05 mm of the floor can read as passing.
1939
1968
 
@@ -1986,7 +2015,7 @@ const hingeSlots = (k, p, d, pose, z) => d.hingeX.flatMap((hx) =>
1986
2015
  [hx - HINGE.tongueX, hx + HINGE.tongueX].map((x) =>
1987
2016
  k.shape2d(tab(p).slot).translate(worldToSheet(pose, [x, p.depth / 2, z]))));
1988
2017
 
1989
- const PLY = { views: ["box"], display: { material: "oak" }, material: "birch plywood", thickness: (p) => p.t };
2018
+ const PLY = { views: ["box"], display: { material: "plywood" }, material: "birch plywood", thickness: (p) => p.t };
1990
2019
  const panel = (name, label, extra = {}) => sheetPart({
1991
2020
  ...PLY, label,
1992
2021
  profile: (k, p, d) => d.box[name].outline, // drawn as seen from outside
@@ -3177,7 +3206,8 @@ addition to) the generated view style button above:
3177
3206
  revert too.
3178
3207
  - `runtime.declaresMaterials` — `true` when any sub-part names a
3179
3208
  `display.material`. A part with none still supports realistic mode (every
3180
- sub-part renders as a PLA print in its CAD colour), so use this to decide
3209
+ sub-part renders as a PLA print in its CAD colour, a laser sheet part as its
3210
+ stock — "Sheet parts default to their stock"), so use this to decide
3181
3211
  whether to surface your own realistic control at all, not whether it works.
3182
3212
  Feature lines are not a preference: they draw whenever `runtime.renderMode`
3183
3213
  reads `"cad"` and never while it reads `"realistic"` — there is no switch
@@ -3665,7 +3695,8 @@ trust handling.
3665
3695
 
3666
3696
  **Appearance** (all warnings) — `unknown-material` (a `display.material` the
3667
3697
  library does not know; the viewer draws it as if it named none — a PLA print
3668
- in realistic mode), `unknown-environment`
3698
+ in realistic mode, or for a laser sheet part its stock's look, per "Sheet parts
3699
+ default to their stock"), `unknown-environment`
3669
3700
  (`meta.environment` not known; realistic mode uses `studio`),
3670
3701
  `material-key-unknown` (a `display` key that is not colour, opacity, material
3671
3702
  or one of the six overrides; ignored), `material-override-clamped` (an override
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.134.0",
3
+ "version": "0.136.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -12,7 +12,9 @@ import { mount } from "./framework/index.js";
12
12
  // open /materials.html. Not part of the production build — check materials.html
13
13
  // by eye after any preset or shader change (see AGENTS.md "Architecture").
14
14
  // Dev-only: the handle is stashed on window so scripts/check-app.mjs can drive
15
- // the embedding contract (runtime.captureCurrent) the way an embedder would.
15
+ // the embedding contract (runtime.captureCurrent) the way an embedder would, and
16
+ // scripts/capture-contact-sheet.mjs can switch views and environments and call
17
+ // runtime.renderViews — removing it breaks both.
16
18
  window.__pfRuntime = mount(materialSwatchesPart, {
17
19
  createWorker: (name) =>
18
20
  new Worker(new URL("./materials-worker.js", import.meta.url), { type: "module", name }),
@@ -31,3 +31,11 @@ export async function resolveFonts(fontsDecl) {
31
31
  }
32
32
  return resolveDecl(fontsDecl, resolveOne);
33
33
  }
34
+
35
+ // Drop one source's memo entry. jobs.js calls this once a font is PARSED: the
36
+ // parsed Font is what the kernel keeps (kernel._fontsBySource, bounded), so
37
+ // holding the raw ArrayBuffer here as well doubled the cost of every font a
38
+ // session ever auditioned and released none of it (spec 2026-10-01 §3).
39
+ export function forgetFontSource(source) {
40
+ cache.delete(source);
41
+ }
@@ -6,7 +6,7 @@ import { meshTo3MF } from "./geometry/threemf.js";
6
6
  import { printColor } from "./materials/resolve.js";
7
7
  import { exportablePartNames } from "./export-select.js";
8
8
  import { fontControlAllows, fontSourceAllowed, isNoFontSource } from "./font-source.js";
9
- import { fontsFor, resolveFonts } from "./fonts.js";
9
+ import { fontsFor, resolveFonts, forgetFontSource } from "./fonts.js";
10
10
  import { normalizeOpentype, parseFont } from "./geometry/opentype-interop.js";
11
11
  import { imageControlAllows, imageSourceAllowed, isNoImageSource } from "./image-source.js";
12
12
  import { imagesFor, ensureImages } from "./images.js";
@@ -16,6 +16,8 @@ import { vectorControlAllows, vectorSourceAllowed, isNoVectorSource } from "./ve
16
16
  import { vectorsFor, ensureVectors } from "./vectors.js";
17
17
  import { exportSubParts, resolveParams, buildPosed } from "./part-model.js";
18
18
 
19
+ export const FONT_SOURCE_CACHE_MAX = 4;
20
+
19
21
  // The oracle loads LAZILY, per job family, never at worker boot. It is the largest
20
22
  // JS payload in the worker's graph (measure/verify/build and silhouette/match), and
21
23
  // only the `inspect` job runs any of it — the generate/export hot path touches none.
@@ -264,26 +266,51 @@ export async function handle(kernel, part, msg, post, opts = {}) {
264
266
  if (part.fonts && kernel._fonts) {
265
267
  const declared = fontsToResolve ?? {};
266
268
  if (Object.keys(declared).length) {
267
- onProgress("resolving fonts");
268
- const opentype = normalizeOpentype(await import("opentype.js"));
269
- const bufs = await resolveFonts(declared);
270
269
  // Keyed on the SOURCE, not the name. A name is not a font identity: one
271
270
  // worker outlives many parts (worker-rebind) and, once a font can come
272
271
  // from a param, many picks — all of which reuse the same declared name.
273
272
  // The old `if (!_fonts.has(name))` made the first bytes ever seen under a
274
273
  // name permanent for the life of the worker.
275
274
  //
276
- // The source, not the resolved buffer: the two agree only because the
277
- // resolver's own memo is unbounded and hands back the identical object
278
- // every time. Key on that and this memo silently degrades to per-fetch
279
- // identity — a re-parse per build — the day eviction is added there.
275
+ // The parse cache keys on the source and is the ONLY holder of a font:
276
+ // the resolver's memo is transient (forgotten once a font is parsed).
280
277
  kernel._fontsBySource ??= new Map();
281
- for (const [name, buf] of bufs) {
282
- const source = declared[name];
278
+ // Resolve only what this kernel has not parsed yet: a source already in
279
+ // the parse cache needs no bytes at all, which is what lets the resolver
280
+ // forget a buffer the moment its font is parsed (below).
281
+ const unparsed = Object.fromEntries(
282
+ Object.entries(declared).filter(([, src]) => !kernel._fontsBySource.has(src)),
283
+ );
284
+ let bufs = new Map();
285
+ if (Object.keys(unparsed).length) {
286
+ onProgress("resolving fonts");
287
+ bufs = await resolveFonts(unparsed);
288
+ }
289
+ const opentype = bufs.size ? normalizeOpentype(await import("opentype.js")) : null;
290
+ for (const [name, source] of Object.entries(declared)) {
283
291
  let font = kernel._fontsBySource.get(source);
284
- if (!font) { font = parseFont(opentype, buf, name); kernel._fontsBySource.set(source, font); }
292
+ if (font) {
293
+ kernel._fontsBySource.delete(source); // re-insert = most recent
294
+ } else {
295
+ font = parseFont(opentype, bufs.get(name), name);
296
+ forgetFontSource(source); // the parsed Font is what we keep
297
+ }
298
+ kernel._fontsBySource.set(source, font);
285
299
  kernel._fonts.set(name, font);
286
300
  }
301
+ // Bounded (spec 2026-10-01 §3): auditioning fonts in the picker
302
+ // otherwise kept every face ever tried, per kernel, for the worker's
303
+ // life. Map order is recency (re-inserted on use above), so eviction
304
+ // takes the oldest. A source THIS build declared is never evicted — a
305
+ // part declaring more than the bound would otherwise evict its own
306
+ // fonts and refetch/reparse them on every build — so the bound is
307
+ // max(FONT_SOURCE_CACHE_MAX, this build's distinct sources).
308
+ const keep = new Set(Object.values(declared));
309
+ const limit = Math.max(FONT_SOURCE_CACHE_MAX, keep.size);
310
+ for (const src of kernel._fontsBySource.keys()) {
311
+ if (kernel._fontsBySource.size <= limit) break;
312
+ if (!keep.has(src)) kernel._fontsBySource.delete(src);
313
+ }
287
314
  }
288
315
  // Drop every name this build's declaration does not supply. `_fonts` is
289
316
  // the kernel's, and the kernel outlives the job: without this, a face the
@@ -1,12 +1,13 @@
1
1
  // src/framework/lint/rules-materials.js
2
2
  // Appearance findings. All WARNINGS by design: a misspelled material must never
3
3
  // block geometry, and the viewer already falls back (resolve.js). Pure — imports
4
- // only the three-free materials data (test/lint-purity.test.js).
4
+ // only three-free materials and sheet data (test/lint-purity.test.js).
5
5
  import { warn } from "./finding.js";
6
6
  import { PRESETS } from "../materials/presets.js";
7
7
  import { ENVIRONMENTS } from "../materials/environments.js";
8
8
  import { resolveMaterial, resolveEnvironmentId, OVERRIDE_RANGES } from "../materials/resolve.js";
9
9
  import { suggest } from "../geometry/op-options.js";
10
+ import { sheetStockLook } from "../materials/sheet-look.js";
10
11
 
11
12
  const listedPresets = () => Object.keys(PRESETS);
12
13
  const subParts = (part) => Object.entries(part?.parts ?? {}).filter(([, sp]) => sp && typeof sp === "object");
@@ -18,9 +19,15 @@ export const MATERIAL_RULES = [
18
19
  id: "unknown-material",
19
20
  run: ({ part }) => issuesOf(part, "unknown-material").map(({ name, value }) => {
20
21
  const near = typeof value === "string" ? suggest(value, listedPresets()) : null;
22
+ // A laser sheet falls back to its stock's look, not to PLA (materials/sheet-look.js) —
23
+ // read from a string label only: a label written as a function takes the default look.
24
+ const sp = part.parts[name];
25
+ const stock = sheetStockLook(sp);
26
+ const why = typeof sp.sheet?.material === "string" ? ", from its stock label" : ": its stock label is a function";
27
+ const fallback = stock ? `in realistic mode \`${stock}\`${why}` : "a PLA print in realistic mode";
21
28
  return warn("unknown-material",
22
29
  `sub-part "${name}" names material ${JSON.stringify(value)}, which is not in the library`,
23
- `${near ? `Did you mean "${near}"? ` : ""}The viewer draws it as if it named no material instead (a PLA print in realistic mode). Known materials: ${listedPresets().join(", ")}.`,
30
+ `${near ? `Did you mean "${near}"? ` : ""}The viewer draws it as if it named no material instead (${fallback}). Known materials: ${listedPresets().join(", ")}.`,
24
31
  `parts.${name}.display.material`);
25
32
  }),
26
33
  },
@@ -66,6 +66,9 @@ the sliced primary and gain-map buffers now start with SOI (`FF D8`) and end wit
66
66
  | `pattern-walnut-color.jpg` | Poly Haven "Dark Wood" (colour; API key `Diffuse`) | https://polyhaven.com/a/dark_wood — file https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/dark_wood/dark_wood_diff_1k.jpg | 2026-09-24 | 1K JPG, sRGB colour -> `node scripts/bake-environments.mjs --texture dark_wood_diff_1k.jpg pattern-walnut-color.jpg` (q82). Scanned tile 2000 mm (`api.polyhaven.com/info/dark_wood` dimensions). |
67
67
  | `pattern-walnut-normal.jpg` | Poly Haven "Dark Wood" (OpenGL normal map; API key `nor_gl`) | https://polyhaven.com/a/dark_wood — file https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/dark_wood/dark_wood_nor_gl_1k.jpg | 2026-09-24 | 1K JPG, OpenGL (+Y up) tangent normals, kept as data -> `node scripts/bake-environments.mjs --texture dark_wood_nor_gl_1k.jpg pattern-walnut-normal.jpg --data --quality 95` (4:4:4 chroma, no grayscale/tint; q95 because the map is nearly flat and the viewer amplifies it, so q90's 8x8 blocks showed). |
68
68
  | `pattern-walnut-rough.jpg` | Poly Haven "Dark Wood" (roughness; API key `Rough`) | https://polyhaven.com/a/dark_wood — file https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/dark_wood/dark_wood_rough_1k.jpg | 2026-09-24 | 1K JPG, grayscale data -> `node scripts/bake-environments.mjs --texture dark_wood_rough_1k.jpg pattern-walnut-rough.jpg --gray` (source is neutral grey; channel means unchanged). |
69
+ | `pattern-plywood-color.jpg` | Poly Haven `plywood` (colour; API key `Diffuse`) | https://polyhaven.com/a/plywood — file https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/plywood/plywood_diff_1k.jpg | 2026-09-30 | 1K JPG, sRGB colour -> `node scripts/bake-environments.mjs --texture plywood_diff_1k.jpg pattern-plywood-color.jpg --modulate 1.85,0.9 --flatten 0.55 --quality 85` (lightened 1.85x and saturation 0.9 from the scan's mid brown to a pale birch; grain contrast 0.55, since lightening doubles it). One continuous veneer, fine even grain running across the image (`grain: "u"`). Chosen 2026-09-30 from five approved CC0 candidates: ambientCG Wood090A, Wood090B, Wood091A and Wood091B (https://ambientcg.com/a/<id>) are edge-glued strip panels with visible parallel joints, not a veneer face; Wood087/088/089 (checked earlier the same day) show plywood edges. |
70
+ | `pattern-plywood-normal.jpg` | Poly Haven `plywood` (OpenGL normal map; API key `nor_gl`) | https://polyhaven.com/a/plywood — file https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/plywood/plywood_nor_gl_1k.jpg | 2026-09-30 | 1K JPG, OpenGL (+Y up) tangent normals, kept as data -> `node scripts/bake-environments.mjs --texture plywood_nor_gl_1k.jpg pattern-plywood-normal.jpg --data --quality 95` (4:4:4 chroma, no grayscale/tint). The map leans slightly (mean red 118/255, a ~4° tilt along the grain) as the source does; kept as authored. |
71
+ | `pattern-plywood-rough.jpg` | Poly Haven `plywood` (roughness; API key `Rough`) | https://polyhaven.com/a/plywood — file https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/plywood/plywood_rough_1k.jpg | 2026-09-30 | 1K JPG, grayscale data -> `node scripts/bake-environments.mjs --texture plywood_rough_1k.jpg pattern-plywood-rough.jpg --gray` (mean 191/255 = 0.749, the preset's `roughnessMean`). |
69
72
  | `ground-maple-color.jpg` | Poly Haven "Oak Veneer 01" (colour; API key `Diffuse`) | https://polyhaven.com/a/oak_veneer_01 — file https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/oak_veneer_01/oak_veneer_01_diff_1k.jpg | 2026-09-24 | Workshop floor, "unfinished maple": `node scripts/bake-environments.mjs --texture oak_veneer_01_diff_1k.jpg ground-maple-color.jpg --modulate 1.5,0.7 --quality 85` (lightened 1.5x, saturation 0.7). |
70
73
  | `ground-maple-normal.jpg` | Poly Haven "Oak Veneer 01" (OpenGL normal map; API key `nor_gl`) | https://polyhaven.com/a/oak_veneer_01 — file https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/oak_veneer_01/oak_veneer_01_nor_gl_1k.jpg | 2026-09-24 | Workshop floor relief: `node scripts/bake-environments.mjs --texture oak_veneer_01_nor_gl_1k.jpg ground-maple-normal.jpg --data --quality 92`. A separate file from `pattern-oak-normal.jpg` so the floor and the oak material never share (and dispose) one texture. |
71
74
  | `ground-pei.jpg` | None — procedural | n/a | n/a | Gold-brown fine-grain speckle, no external source. First attempt tinted ambientCG "Plastic 010"'s colour map (near-uniform, stdev < 1/channel — visibly flat, no grain, when actually viewed) gold-brown; that colour map carries no usable grain at all (its roughness map does, which is why `ground-pei-rough.jpg` below is still Plastic010's roughness). Checked ambientCG alternatives (Metal049A — also flat; Granite006A — real speckle but reads as rock, not powder-coat) and found nothing closer. Generated in-baker instead, same procedural precedent as `pattern-carbon.jpg`: per-pixel triangular-distributed noise around a `168,134,82` bronze-gold base (asymmetric per channel so flecks warm rather than grey), then a 0.5px blur to turn single-pixel noise into soft grains -> `node scripts/bake-environments.mjs --speckle ground-pei.jpg --tint 168,134,82`. (Re-baked 2026-09-24 from `196,154,82`: darker and less saturated, closer to a Bambu textured-PEI plate's muted bronze-gold.) |
@@ -76,9 +79,11 @@ the sliced primary and gain-map buffers now start with SOI (`FF D8`) and end wit
76
79
  | `pattern-carbon.jpg` | None — procedural | n/a | n/a | ambientCG has no CC0 carbon-fiber weave (searched "carbon"; only unrelated fabric hits). Generated in-baker: a 256x256 2x2 twill tile (32 threads/tile, alternating dark/light grey diagonal bands) -> `node scripts/bake-environments.mjs --carbon pattern-carbon.jpg`. No external source/licence to record. |
77
80
 
78
81
  ambientCG publishes every asset under CC0 (https://ambientcg.com/, "CC0 Public Domain
79
- license", checked 2026-09-24). The wood sets (`pattern-oak-*`, `pattern-walnut-*`,
80
- `ground-maple-*`) are Poly Haven textures, CC0 like its HDRIs above
81
- (https://polyhaven.com/license, checked 2026-09-24); the exact 1K JPG URLs came
82
+ license", checked 2026-09-24; https://ambientcg.com/license, "Creative Commons CC0 1.0
83
+ Universal License", checked again 2026-09-30 for the plywood candidates). The wood sets
84
+ (`pattern-oak-*`, `pattern-walnut-*`, `pattern-plywood-*`, `ground-maple-*`) are Poly Haven
85
+ textures, CC0 like its HDRIs above (https://polyhaven.com/license, checked 2026-09-24, and
86
+ again 2026-09-30 for `plywood`); the exact 1K JPG URLs came
82
87
  from `https://api.polyhaven.com/files/<slug>` (keys `Diffuse`, `nor_gl`, `Rough`).
83
88
  The presets tile them smaller than the scanned size — see the comment on `oak` in
84
89
  `presets.js` and on `workshop` in `environments.js`.
@@ -86,5 +91,6 @@ The presets tile them smaller than the scanned size — see the comment on `oak`
86
91
  ## Budgets
87
92
 
88
93
  Environments stay under 1.5 MB (largest: `env-workshop.jpg` at ~398 KB); ground/pattern
89
- textures stay under 400 KB (largest: `pattern-oak-rough.jpg` at ~218 KB). All committed
94
+ textures stay under 400 KB (largest: `ground-concrete-normal.jpg` at ~380 KB; the largest
95
+ pattern map is `pattern-plywood-normal.jpg` at ~337 KB). All committed
90
96
  outputs are well inside both ceilings — see `test/framework/materials-assets.test.js`.
@@ -25,6 +25,9 @@ const URLS = {
25
25
  "pattern-walnut-color.jpg": new URL("./assets/pattern-walnut-color.jpg", import.meta.url).href,
26
26
  "pattern-walnut-normal.jpg": new URL("./assets/pattern-walnut-normal.jpg", import.meta.url).href,
27
27
  "pattern-walnut-rough.jpg": new URL("./assets/pattern-walnut-rough.jpg", import.meta.url).href,
28
+ "pattern-plywood-color.jpg": new URL("./assets/pattern-plywood-color.jpg", import.meta.url).href,
29
+ "pattern-plywood-normal.jpg": new URL("./assets/pattern-plywood-normal.jpg", import.meta.url).href,
30
+ "pattern-plywood-rough.jpg": new URL("./assets/pattern-plywood-rough.jpg", import.meta.url).href,
28
31
  "pattern-carbon.jpg": new URL("./assets/pattern-carbon.jpg", import.meta.url).href,
29
32
  };
30
33
 
@@ -5,7 +5,10 @@
5
5
  // they move with the sub-part under animation and never slide when the camera
6
6
  // moves. Layer lines additionally map object space through `pfPrintFrame`
7
7
  // (print-frame.js: display→export), so they run the way the part will print.
8
+ // A laser-cut wood sheet's burn (applyBurn) composes over the wood pass in the sheet's
9
+ // canonical frame (sheet-look.js).
8
10
  import * as THREE from "three";
11
+ import { BURN, srgbToLinear } from "./sheet-look.js";
9
12
 
10
13
  const VERT_DECL = "varying vec3 vPfObjPos;\nvarying vec3 vPfObjNormal;\nvarying vec3 vPfPrintUpView;\nuniform mat4 pfPrintFrame;\n";
11
14
  // Wood's triplanar normal map builds its normal in OBJECT space; the fragment
@@ -354,3 +357,103 @@ export function applyBrushFrame(material) {
354
357
  material.needsUpdate = true;
355
358
  return material;
356
359
  }
360
+
361
+ // --- laser burns ------------------------------------------------------------------
362
+ // A laser-cut sheet in a wood shows what the laser did to it (sheet-look.js decides which
363
+ // sub-parts, and mount hands the viewer each one's canonical frame): cut walls charred,
364
+ // engrave and score floors scorched, faces untouched. Composes over the wood pass the way
365
+ // applyBrushFrame does, injecting at three points in three's meshphysical order:
366
+ // (a) colour + roughness, before <normal_fragment_begin>: after wood's body (injected
367
+ // after <roughnessmap_fragment>) and <metalnessmap_fragment>. A second replace of
368
+ // <roughnessmap_fragment> would land IN FRONT of wood's body, so it is not reused.
369
+ // pfBurnWall / pfBurnFloor are declared at main() scope, like pfLayer, for (b), (c).
370
+ // (b) the normal, before <emissivemap_fragment>: after wood's normal map and the
371
+ // clearcoat normals. Char flattens the wood's relief: the wood normal fades toward
372
+ // the geometric one (nonPerturbedNormal, set by <normal_fragment_begin>), keeping
373
+ // BURN.relief of it.
374
+ // (c) clearcoat, after <lights_physical_fragment>, which assigns material.clearcoat:
375
+ // walnut's lacquer comes off the char.
376
+ // Every snippet sits between `// pf-burn {` and `// } pf-burn` lines: that is how the tests
377
+ // prove a burning program is the plain wood program plus these and nothing else. The
378
+ // vertex stage is untouched — the pass reads the varyings every pattern already has. With
379
+ // pfSheetT = 0 (no frame delivered yet) it draws plain wood. No noise term: an unfiltered
380
+ // striation would shimmer at part scale (the layer-lines lesson above).
381
+ const glf = (x) => (Number.isInteger(x) ? x.toFixed(1) : String(x));
382
+ const CHARCOAL = srgbToLinear(BURN.charcoal);
383
+ const burnBlock = (glsl) => `// pf-burn {\n${glsl}\n// } pf-burn`;
384
+
385
+ const BURN_FRAG_DECL = burnBlock(`uniform mat4 pfSheetFrame;
386
+ uniform float pfSheetT;
387
+ uniform float pfPlies;
388
+ uniform vec3 pfFaceAvg;
389
+ // 1 on a cross ply (an odd one), 0 on a face-grain ply: the square wave box-filtered over
390
+ // the pixel (its integral, differenced across fwidth), so plies too thin to draw average
391
+ // out instead of shimmering.
392
+ float pfCrossPlyI(float x) { return floor(x * 0.5) + max(0.0, fract(x * 0.5) * 2.0 - 1.0); }
393
+ float pfCrossPly(float x) {
394
+ float w = max(fwidth(x), 1e-4);
395
+ return (pfCrossPlyI(x + 0.5 * w) - pfCrossPlyI(x - 0.5 * w)) / w;
396
+ }`);
397
+
398
+ const BURN_FRAG_BODY = burnBlock(`float pfBurnWall = 0.0;
399
+ float pfBurnFloor = 0.0;
400
+ if (pfSheetT > 0.0) {
401
+ vec3 pfC = (pfSheetFrame * vec4(vPfObjPos, 1.0)).xyz;
402
+ vec3 pfCn = normalize(mat3(pfSheetFrame) * vPfObjNormal);
403
+ pfBurnWall = 1.0 - smoothstep(${glf(BURN.wallNz[0])}, ${glf(BURN.wallNz[1])}, abs(pfCn.z));
404
+ pfBurnFloor = step(0.5, pfCn.z) * step(pfC.z, pfSheetT - ${glf(BURN.floorDepth)});
405
+ float pfK = mix(${glf(BURN.kThin)}, ${glf(BURN.kThick)}, clamp((pfSheetT - ${glf(BURN.tThin)}) / ${glf(BURN.tThick - BURN.tThin)}, 0.0, 1.0));
406
+ float pfZ = clamp(pfC.z / pfSheetT, 0.0, 1.0);
407
+ vec3 pfFace = diffuse * pfFaceAvg;
408
+ // Per channel, not the vector scaled by one luminance ratio (sheet-look.js's burnAlbedo,
409
+ // this shader's twin, explains why): a channel where charOfDark·face is already below raw
410
+ // charcoal chars toward ITS OWN darker value, so no channel can come out lighter than it
411
+ // went in.
412
+ vec3 pfChar = min(vec3(${CHARCOAL.map((c) => c.toFixed(6)).join(", ")}), ${glf(BURN.charOfDark)} * pfFace);
413
+ vec3 pfEdge = mix(pfFace, pfChar, min(1.0, pfK + ${glf(BURN.exit)} * (1.0 - pfZ)));
414
+ if (pfPlies > 0.0) pfEdge *= mix(1.0, ${glf(BURN.crossPly)}, pfCrossPly(pfZ * pfPlies));
415
+ vec3 pfMark = mix(pfFace, pfChar, pfK - ${glf(BURN.engraveLess)});
416
+ // The grain survives the char (sheet-look.js's burnTexel is the twin): pfEdge and pfMark
417
+ // are what the face AVERAGE chars to, and each texel keeps its ratio to that average,
418
+ // blended in by BURN.grain. At 1, a pure multiply, the average chars exactly as above and
419
+ // no texel comes out lighter than it went in.
420
+ vec3 pfGrain = mix(vec3(1.0), diffuseColor.rgb / max(pfFace, vec3(1e-4)), ${glf(BURN.grain)});
421
+ pfEdge *= pfGrain;
422
+ pfMark *= pfGrain;
423
+ diffuseColor.rgb = mix(diffuseColor.rgb, pfEdge, pfBurnWall);
424
+ diffuseColor.rgb = mix(diffuseColor.rgb, pfMark, pfBurnFloor);
425
+ roughnessFactor = mix(roughnessFactor, ${glf(BURN.roughness)}, max(pfBurnWall, pfBurnFloor));
426
+ }`);
427
+
428
+ const BURN_FRAG_NORMAL = burnBlock(`normal = normalize(mix(normal, nonPerturbedNormal, max(pfBurnWall, pfBurnFloor) * ${glf(1 - BURN.relief)}));`);
429
+
430
+ const BURN_FRAG_CLEARCOAT = burnBlock(`#ifdef USE_CLEARCOAT
431
+ material.clearcoat *= 1.0 - max(pfBurnWall, pfBurnFloor);
432
+ #endif`);
433
+
434
+ // `faceAvg` is the preset's own colour (0xRRGGBB) — its texture's average, never the tint:
435
+ // the tint arrives as `diffuse`, so the shader's face colour is diffuse · pfFaceAvg.
436
+ export function applyBurn(material, { faceAvg }) {
437
+ const uniforms = material.userData.patternUniforms;
438
+ if (!uniforms?.pfGrainSwap) return material; // wood only: the burn draws over the wood pass
439
+ Object.assign(uniforms, {
440
+ pfSheetFrame: { value: new THREE.Matrix4() },
441
+ pfSheetT: { value: 0 },
442
+ pfPlies: { value: 0 },
443
+ pfFaceAvg: { value: new THREE.Color(faceAvg) },
444
+ });
445
+ const prev = material.onBeforeCompile;
446
+ const prevKey = material.customProgramCacheKey;
447
+ material.onBeforeCompile = (shader, renderer) => {
448
+ prev?.call(material, shader, renderer); // applyPattern's hook copies `uniforms` — burn uniforms included
449
+ shader.fragmentShader = shader.fragmentShader
450
+ .replace("#include <common>", `#include <common>\n${BURN_FRAG_DECL}`)
451
+ .replace("#include <normal_fragment_begin>", `${BURN_FRAG_BODY}\n#include <normal_fragment_begin>`)
452
+ .replace("#include <emissivemap_fragment>", `${BURN_FRAG_NORMAL}\n#include <emissivemap_fragment>`)
453
+ .replace("#include <lights_physical_fragment>", `#include <lights_physical_fragment>\n${BURN_FRAG_CLEARCOAT}`);
454
+ };
455
+ const base = prevKey && prevKey !== THREE.Material.prototype.customProgramCacheKey ? prevKey.call(material) : "";
456
+ material.customProgramCacheKey = () => `${base}|pf-burn`;
457
+ material.needsUpdate = true;
458
+ return material;
459
+ }
@@ -5,7 +5,7 @@
5
5
  // disagree about which preset a sub-part is.
6
6
  import * as THREE from "three";
7
7
  import { cadAppearance, resolveMaterial } from "./resolve.js";
8
- import { applyBrushFrame, applyPattern } from "./patterns.js";
8
+ import { applyBrushFrame, applyBurn, applyPattern } from "./patterns.js";
9
9
  import { PATTERN_TEXTURES } from "./assets.js";
10
10
 
11
11
  const APPEARANCE_KEYS = ["color", "opacity", "material", "roughness", "metalness", "clearcoat", "clearcoatRoughness", "anisotropy", "textureScale"];
@@ -24,8 +24,11 @@ export function buildCadMaterial(display, base) {
24
24
 
25
25
  // Realistic-mode material. `loadTexture(fileName)` is injected (the viewer owns
26
26
  // a caching TextureLoader), so this stays unit-testable without a network.
27
+ // `burn` is the viewer's per-sub-part decision (sheet-look.js burnsFor): it adds the laser
28
+ // burn pass to a wood and changes no other program.
27
29
  export function buildPhysicalMaterial(display, opts = {}) {
28
- const m = buildPhysical(display, opts);
30
+ let m = buildPhysical(display, opts);
31
+ if (opts.burn) m = applyBurn(m, { faceAvg: resolveMaterial(display).preset.color });
29
32
  // brushed metal picks its brush direction per pixel (patterns.js applyBrushFrame)
30
33
  return m.userData.pfAnisotropic ? applyBrushFrame(m) : m;
31
34
  }
@@ -13,7 +13,8 @@ const P = (id, label, category, use, fields) => ({ id, label, category, use, tin
13
13
  export const PRESETS = {
14
14
  // Every record here is a documented, nameable material. A sub-part that names
15
15
  // none is drawn by resolve.js (the blue-grey CAD look; a PLA print in that
16
- // colour in realistic mode) — there is no hidden "default" record any more.
16
+ // colour in realistic mode, or a laser sheet part's stock look — sheet-look.js
17
+ // realisticDisplay) — there is no hidden "default" record any more.
17
18
 
18
19
  "machined-aluminum": P("machined-aluminum", "Machined aluminium", "aluminium", "Bare CNC-milled aluminium, fine tool marks, satin sheen.",
19
20
  { color: 0xf5f6f6, metalness: 1, roughness: 0.32 }),
@@ -73,17 +74,22 @@ export const PRESETS = {
73
74
  // mode the texture carries the colour itself; an explicit `color` tints it.
74
75
  // `roughnessMean` is the roughness map's mean, so the preset `roughness` (or an
75
76
  // override) sets the AVERAGE roughness and the map only varies it. `normalScale`
76
- // strengthens the normal map: these scans' tangent normals barely leave +Z (a
77
- // ~1% tilt), which reads as flat under an environment map. `grain` is the
77
+ // strengthens the normal map: the oak and walnut scans' tangent normals barely leave +Z (a
78
+ // ~1% tilt), which reads as flat under an environment map; plywood's carries about three
79
+ // times that, a sanded face's relief, so it keeps its own. `grain` is the
78
80
  // texture axis the scan's figure runs along — oak's runs up the image ("v"),
79
- // walnut's across it ("u") — so patterns.js can lay it along each sub-part's
80
- // long axis.
81
+ // walnut's and plywood's across it ("u") — so patterns.js can lay it along each sub-part's
82
+ // long axis. `laminated` (plywood) asks the burn pass (patterns.js applyBurn) for ply
83
+ // bands on laser-cut walls, at sheet-look.js's plyCount(t).
81
84
  oak: P("oak", "Oak", "natural", "Light oak with open grain.",
82
85
  { color: 0xa17e57, metalness: 0, roughness: 0.65, pattern: "wood", textureScale: 250,
83
86
  textures: { color: "pattern-oak-color.jpg", normal: "pattern-oak-normal.jpg", roughness: "pattern-oak-rough.jpg", roughnessMean: 0.53, normalScale: 2.5, grain: "v" } }),
84
87
  walnut: P("walnut", "Walnut", "natural", "Dark oiled walnut.",
85
88
  { color: 0x5d2a18, metalness: 0, roughness: 0.5, clearcoat: 0.3, clearcoatRoughness: 0.4, pattern: "wood", textureScale: 400,
86
89
  textures: { color: "pattern-walnut-color.jpg", normal: "pattern-walnut-normal.jpg", roughness: "pattern-walnut-rough.jpg", roughnessMean: 0.628, normalScale: 3, grain: "u" } }),
90
+ plywood: P("plywood", "Birch plywood", "natural", "Birch plywood sheet: a pale, fine-grained face; its plies show on laser-cut edges.",
91
+ { color: 0xe1c1a1, metalness: 0, roughness: 0.6, pattern: "wood", textureScale: 150, laminated: true,
92
+ textures: { color: "pattern-plywood-color.jpg", normal: "pattern-plywood-normal.jpg", roughness: "pattern-plywood-rough.jpg", roughnessMean: 0.749, normalScale: 1, grain: "u" } }),
87
93
  "carbon-fiber": P("carbon-fiber", "Carbon fibre", "natural", "2x2 twill carbon fibre under clear coat.",
88
94
  { color: 0x1b1c1e, metalness: 0.2, roughness: 0.35, clearcoat: 1, clearcoatRoughness: 0.05, pattern: "carbon", textureScale: 48 }),
89
95
  };
@@ -27,8 +27,10 @@ const isColor = (v) => Number.isInteger(v) && v >= 0 && v <= 0xffffff;
27
27
  // material and threemf.js's uncoloured-object colour are the same literal).
28
28
  // Realistic mode draws it as a PLA print — the pla-print finish, layer lines
29
29
  // and all — in that same colour, or the part's own `color`, so a part is the
30
- // same colour in both views, just printed. `declaresMaterials` still counts
31
- // only a NAMED material: this default never makes a part "declare" one.
30
+ // same colour in both views, just printed — except a laser sheet part, which
31
+ // realistic mode draws as its stock instead (sheet-look.js realisticDisplay,
32
+ // applied before this resolves). `declaresMaterials` still counts only a NAMED
33
+ // material: this default never makes a part "declare" one.
32
34
  export const NO_MATERIAL_COLOR = 0x9fb4cc;
33
35
  export const NO_MATERIAL_PRESET_ID = "pla-print";
34
36
  const NO_MATERIAL_CAD = { color: NO_MATERIAL_COLOR, metalness: 0.25, roughness: 0.55 };