partforge 0.121.1 → 0.122.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.
Files changed (58) hide show
  1. package/docs/AUTHORING-PARTS.md +164 -3
  2. package/package.json +3 -1
  3. package/src/app-materials.js +23 -0
  4. package/src/framework/app.css +16 -1
  5. package/src/framework/cutaway.js +12 -0
  6. package/src/framework/geometry/threemf.js +32 -3
  7. package/src/framework/jobs.js +2 -1
  8. package/src/framework/lint/index.js +2 -1
  9. package/src/framework/lint/rules-materials.js +56 -0
  10. package/src/framework/materials/assets/SOURCES.md +90 -0
  11. package/src/framework/materials/assets/env-outdoor.jpg +0 -0
  12. package/src/framework/materials/assets/env-print-bed.jpg +0 -0
  13. package/src/framework/materials/assets/env-studio.jpg +0 -0
  14. package/src/framework/materials/assets/env-workshop.jpg +0 -0
  15. package/src/framework/materials/assets/ground-concrete-normal.jpg +0 -0
  16. package/src/framework/materials/assets/ground-concrete-rough.jpg +0 -0
  17. package/src/framework/materials/assets/ground-concrete.jpg +0 -0
  18. package/src/framework/materials/assets/ground-maple-color.jpg +0 -0
  19. package/src/framework/materials/assets/ground-maple-normal.jpg +0 -0
  20. package/src/framework/materials/assets/ground-paper-normal.jpg +0 -0
  21. package/src/framework/materials/assets/ground-paper-rough.jpg +0 -0
  22. package/src/framework/materials/assets/ground-paper.jpg +0 -0
  23. package/src/framework/materials/assets/ground-pei-rough.jpg +0 -0
  24. package/src/framework/materials/assets/ground-pei.jpg +0 -0
  25. package/src/framework/materials/assets/pattern-carbon.jpg +0 -0
  26. package/src/framework/materials/assets/pattern-oak-color.jpg +0 -0
  27. package/src/framework/materials/assets/pattern-oak-normal.jpg +0 -0
  28. package/src/framework/materials/assets/pattern-oak-rough.jpg +0 -0
  29. package/src/framework/materials/assets/pattern-walnut-color.jpg +0 -0
  30. package/src/framework/materials/assets/pattern-walnut-normal.jpg +0 -0
  31. package/src/framework/materials/assets/pattern-walnut-rough.jpg +0 -0
  32. package/src/framework/materials/assets.js +39 -0
  33. package/src/framework/materials/contact-shadow.js +155 -0
  34. package/src/framework/materials/environment.js +157 -0
  35. package/src/framework/materials/environments.js +82 -0
  36. package/src/framework/materials/patterns.js +195 -0
  37. package/src/framework/materials/physical.js +82 -0
  38. package/src/framework/materials/presets.js +89 -0
  39. package/src/framework/materials/print-bed.js +169 -0
  40. package/src/framework/materials/print-frame.js +18 -0
  41. package/src/framework/materials/resolve.js +117 -0
  42. package/src/framework/materials/tonemap-readback.js +36 -0
  43. package/src/framework/materials/uv.js +23 -0
  44. package/src/framework/mount.js +121 -4
  45. package/src/framework/realistic-controls.js +78 -0
  46. package/src/framework/selection/feature-highlight.js +3 -0
  47. package/src/framework/view-state.js +27 -1
  48. package/src/framework/viewer.js +623 -34
  49. package/src/materials-worker.js +3 -0
  50. package/src/parts/bracket.js +2 -1
  51. package/src/parts/hinged-box.js +3 -1
  52. package/src/parts/material-swatches.js +40 -0
  53. package/src/parts/planter.js +2 -1
  54. package/src/parts/propeller.js +2 -1
  55. package/src/parts/scott-label.js +3 -2
  56. package/src/parts/screw.js +2 -1
  57. package/types/index.d.ts +48 -3
  58. package/types/part.d.ts +15 -2
@@ -69,7 +69,7 @@ A part is a default-exported object. Full shape (optional fields marked `?`):
69
69
 
70
70
  ```js
71
71
  export default {
72
- meta: { title, units, background? }, // title string; units e.g. "mm"; background = 0xRRGGBB scene colour
72
+ meta: { title, units, background?, environment? }, // title string; units e.g. "mm"; background = 0xRRGGBB scene colour
73
73
  parameters, // the control-panel schema (array of sections — see below)
74
74
  defaults, // flat { paramKey: value } — seeds params + control values
75
75
  fonts?, // { name: source } — or (p) => ({ name: source }) when a control drives the typeface
@@ -83,7 +83,7 @@ export default {
83
83
  place?: (solid, { view, purpose, p, d }) => Solid, // optional reposition; default identity
84
84
  views, // string[] — which views show this sub-part
85
85
  enabled?: (p) => boolean, // optional — gate a conditional sub-part
86
- display?: { color?, opacity? }, // optional viewer-only override (0xRRGGBB / 0..1) — e.g. a reference/ghost part
86
+ display?: { color?, opacity?, material?, …overrides }, // viewer-only appearance — see "Materials and appearance"
87
87
  export?: { name }, // filename/object name on export; defaults to the key
88
88
  reference?: string, // name of a declared import — measure() computes a deviation fact against it (see below)
89
89
  },
@@ -294,6 +294,97 @@ and asks for the existing positional view argument
294
294
 
295
295
  ---
296
296
 
297
+ ## Materials and appearance
298
+
299
+ A sub-part's `display` block says how it LOOKS. Appearance never changes
300
+ geometry, exports (other than 3MF colour, below), `measure` or `verify`, and a
301
+ mistake in it never fails a build — lint warns and the viewer falls back.
302
+
303
+ ```js
304
+ parts: {
305
+ body: { build: …, display: { material: "anodized-aluminum", color: 0xb3261e } },
306
+ knob: { build: …, display: { material: "abs-plastic", color: 0x111111, roughness: 0.25 } },
307
+ gasket: { build: …, display: { material: "rubber" } },
308
+ },
309
+ meta: { title: "…", units: "mm", environment: "studio" },
310
+ ```
311
+
312
+ - `material` — a preset id from the table below. Without one the sub-part keeps
313
+ the default look.
314
+ - `color` — the base colour (`0xRRGGBB`). With a preset it is the TINT. Presets
315
+ marked tintable are normally coloured this way (anodizing, plastic, paint);
316
+ the others have an intrinsic colour (brass) but still accept one.
317
+ - Overrides — each clamped to its range: `roughness` (0–1), `metalness` (0–1),
318
+ `clearcoat` (0–1), `clearcoatRoughness` (0–1), `anisotropy` (0–1), `textureScale` (0.01–1000).
319
+ Transmission, index of refraction, sheen and iridescence come only from a preset.
320
+ - `opacity` — unchanged: a ghost stays a ghost and casts no shadow.
321
+ - **One material per sub-part.** Something that needs two finishes (a knurled
322
+ grip in rubber on an aluminium body) is two sub-parts.
323
+
324
+ **Where it shows.** The CAD view (with feature lines) shows each material's
325
+ colour, flattened so dark materials stay readable. **Realistic** mode — the
326
+ viewer's toggle — shows the full material under environment lighting, with a
327
+ ground and soft shadow and no feature lines. The part never moves when the
328
+ mode changes.
329
+
330
+ **3D-print layer lines** (`pla-print`, `petg-print`) run perpendicular to the
331
+ **export** pose's +Z — the way the part will be printed, not the way it is
332
+ displayed. If the lines run the wrong way, fix the export pose with `place()`
333
+ (`purpose: "export"`), not the material. Wood, carbon fibre and SLS grain are
334
+ fixed to the sub-part, so they never slide when the camera or an animation
335
+ moves.
336
+
337
+ **`textureScale` is millimetres, and what it measures depends on the preset's
338
+ pattern** — so a value copied from one preset family is wrong on another (0.2
339
+ on oak shrinks the grain to a 0.2 mm tile, i.e. invisible noise):
340
+
341
+ | Pattern | Presets | `textureScale` means | Default |
342
+ | --- | --- | --- | --- |
343
+ | layer lines | `pla-print`, `petg-print` | layer height | 0.2 |
344
+ | SLS grain | `nylon-sls` | grain size | 0.15 |
345
+ | wood | `oak`, `walnut` | size of one texture tile (the grain repeats every this many mm) | 250 (`oak`), 400 (`walnut`) |
346
+ | carbon weave | `carbon-fiber` | size of one texture tile | 48 |
347
+
348
+ Presets without a pattern ignore it.
349
+
350
+ **3MF export** carries each sub-part's colour (its `color`, else its preset's
351
+ colour), so a multi-colour print opens in a slicer already split and coloured.
352
+ A part with no `color` or `material` exports exactly as before.
353
+
354
+ | Preset | Name | Tintable | Use |
355
+ | --- | --- | --- | --- |
356
+ | `machined-aluminum` | Machined aluminium | — | Bare CNC-milled aluminium, fine tool marks, satin sheen. |
357
+ | `brushed-aluminum` | Brushed aluminium | — | Directionally brushed aluminium panels and enclosures. |
358
+ | `anodized-aluminum` | Anodized aluminium | yes | Dyed anodized aluminium; tint with `color` (e.g. red, black, blue). |
359
+ | `bead-blasted-aluminum` | Bead-blasted aluminium | — | Matte, even-textured aluminium (laptop-shell finish). |
360
+ | `brushed-stainless` | Brushed stainless steel | — | Brushed 304 stainless: kitchen, marine and fastener hardware. |
361
+ | `polished-chrome` | Polished chrome | — | Mirror chrome plating; shows the environment strongly. |
362
+ | `black-oxide-steel` | Black-oxide steel | — | Blackened steel tooling and fasteners with a slight oily sheen. |
363
+ | `cast-iron` | Cast iron | — | Raw sand-cast iron: dark, rough and matte. |
364
+ | `titanium` | Titanium | — | Bare titanium: slightly warm grey, satin. |
365
+ | `brass` | Brass | — | Yellow brass fittings and decorative hardware. |
366
+ | `copper` | Copper | — | Bare copper: busbars, heat sinks, decorative parts. |
367
+ | `bronze` | Bronze | — | Cast bronze bushings and sculpture. |
368
+ | `powder-coat` | Powder coat | yes | Durable textured paint over metal; tint with `color`. |
369
+ | `painted-metal` | Painted metal | yes | Glossy enamel or automotive-style paint; tint with `color`. |
370
+ | `pla-print` | PLA print | yes | FDM-printed PLA with visible layer lines; tint with `color`. |
371
+ | `petg-print` | PETG print | yes | FDM-printed PETG: glossier than PLA, visible layers; tint with `color`. |
372
+ | `resin-print` | Resin print | yes | SLA/MSLA resin print: smooth, faintly waxy; tint with `color`. |
373
+ | `nylon-sls` | Nylon SLS | yes | Powder-bed nylon: matte, grainy, usually white or dyed black. |
374
+ | `abs-plastic` | ABS plastic | yes | Injection-moulded ABS housings and knobs; tint with `color`. |
375
+ | `clear-acrylic` | Clear acrylic | yes | Transparent PMMA/polycarbonate windows and covers; tint for coloured acrylic. |
376
+ | `rubber` | Rubber | yes | Matte elastomer: gaskets, feet, grips; tint with `color`. |
377
+ | `oak` | Oak | — | Light oak with open grain. |
378
+ | `walnut` | Walnut | — | Dark oiled walnut. |
379
+ | `carbon-fiber` | Carbon fibre | — | 2x2 twill carbon fibre under clear coat. |
380
+
381
+ **Environments** (`meta.environment`, default `studio`; viewers can switch):
382
+ `studio` (neutral soft boxes, paper sweep), `workshop` (warm interior, unfinished
383
+ maple table), `print-bed` (a dim, hard-lit enclosure over a standard-size PEI build plate (180, 220, 256 or 350 mm) marked with its size), `outdoor` (overcast sky,
384
+ concrete).
385
+
386
+ ---
387
+
297
388
  ## Geometry: the kernel / `Solid` API
298
389
 
299
390
  `build` receives a backend-agnostic `kernel` (`k`). It returns and combines `Solid`
@@ -2498,7 +2589,7 @@ stylesheet). `mount` looks up these element IDs:
2498
2589
  | `#part` | view-tab bar — leave the div **empty**; `mount` generates one button per entry in `part.views` and opens the resolved default (see the "Which view the viewer opens on" rule above) |
2499
2590
  | `#download-step` / `#download` / `#download-3mf` | STEP / STL / 3MF export buttons |
2500
2591
  | `#status`, `#busy`, `#phase` | status line + busy overlay |
2501
- | `#viewbar` with `#annotate` / `#measure` / `#cutaway` / `#reframe` / `#theme` | optional viewer controls (omit any you don't want) |
2592
+ | `#viewbar` with `#annotate` / `#measure` / `#cutaway` / `#reframe` / `#realistic` / `#environment` / `#theme` | optional viewer controls (omit any you don't want) |
2502
2593
  | `#panel` | the full-height controls rail (`class="pf-rail"`); programmatic hosts pass `elements.rail` instead |
2503
2594
  | `#rail-toggle` | optional — collapses/restores the rail; resolved the same way as `#reframe`/`#theme`. A sibling of `#viewbar`, not a child of it: give it `class="pf-float-rail-toggle"` and it floats at the stage's top right |
2504
2595
 
@@ -2506,6 +2597,17 @@ Copy `demo.html` and change the title, the panel heading, and the `<script src>`
2506
2597
  workers are spawned from your one worker entry (`name` = `"manifold"` for preview/STL/3MF,
2507
2598
  `"occt"` for STEP — handled for you).
2508
2599
 
2600
+ **`#realistic` (a `<button>`) / `#environment` (an empty `<select>`) are the
2601
+ realistic-mode viewbar controls**, resolved by id or as `elements.chrome.realistic`
2602
+ / `elements.chrome.environment`. Both optional and independent: supply `#realistic`
2603
+ alone for a bare toggle, or add `#environment` too and `mount` fills it with the
2604
+ part's available environments (see "Materials and appearance" above) and keeps it
2605
+ in sync — hidden while the view is in CAD mode, showing the environment in effect
2606
+ once realistic lands. Omit either and drive `runtime.renderMode` /
2607
+ `runtime.environment` from your own UI instead (see below). Both preferences
2608
+ persist across reloads the same way the theme does, and are carried in
2609
+ `viewerState` (below), which outranks what is stored.
2610
+
2509
2611
  **`#reframe` is supported but no longer shipped.** The framework's own pages dropped
2510
2612
  the button on 2026-08-20: clicking a face, edge or corner on the view cube reframes
2511
2613
  too, so a separate control was one more thing in a crowded bottom-right corner. The
@@ -2660,6 +2762,58 @@ them, and the sketch that gets sent is misaligned, not merely mis-labelled.
2660
2762
  Deliberately unguarded, the same way it's always been free to call
2661
2763
  `setCameraState` during Sketch.
2662
2764
 
2765
+ ### `runtime.renderMode`, `runtime.environment`, `runtime.renderViews`, `runtime.declaresMaterials`
2766
+
2767
+ For an embedder driving realistic mode from its own UI instead of (or in
2768
+ addition to) the `#realistic` / `#environment` viewbar controls above:
2769
+
2770
+ - `runtime.renderMode` — `{ get(), set(mode), onChange(cb) }` where `mode` is
2771
+ `"cad"` or `"realistic"`. `set()` resolves to the mode actually in effect —
2772
+ `"cad"` if the realistic environment's assets fail to load — and
2773
+ `onChange` receives `{ mode, busy, error }` so a host can show its own
2774
+ loading/error state. Same shape as `runtime.projection`. It drives the live
2775
+ view, and `runtime.captureCurrent()` follows it by default (a "capture from
2776
+ viewer" captures what the user sees). It never changes what an agent sees:
2777
+ `runtime.captureViews()` is always CAD, in either live mode, and a
2778
+ realistic agent render is only ever an explicit
2779
+ `renderViews(…, { renderMode: "realistic" })`.
2780
+ - `runtime.captureCurrent({ renderMode })` — pins the showcase capture's look
2781
+ instead of following the live view. `"cad"` always works. `"realistic"` from
2782
+ a CAD view works only once the current environment's assets have loaded
2783
+ (the call is synchronous and can't wait for them); before that it falls
2784
+ back to the live look. Use `renderViews` when realistic must be guaranteed.
2785
+ - `runtime.environment` — `{ get(), set(id), onChange(cb), list() }` for the
2786
+ realistic environment (`"studio"` | `"workshop"` | `"print-bed"` |
2787
+ `"outdoor"`, per "Materials and appearance" above). `list()` returns every
2788
+ environment as `[{ id, label }]`, for building your own picker. `set()`
2789
+ resolves to the id actually in effect; a failed switch while realistic is
2790
+ showing reverts to the environment still on screen, and `onChange` hears the
2791
+ revert too.
2792
+ - `runtime.declaresMaterials` — `true` when any sub-part names a
2793
+ `display.material`. A part with none still supports realistic mode (every
2794
+ sub-part just renders under the library's `default` look), so use this to
2795
+ decide whether to surface the realistic toggle at all, not whether it works.
2796
+ - `await runtime.renderViews(viewNames, { renderMode? })` — the appearance-aware
2797
+ sibling of `runtime.captureViews` (canonical angles, framed to the visible
2798
+ assembly, grid hidden): `{ renderMode: "cad" }` (the default) is exactly
2799
+ `captureViews` — CAD whatever the live view shows — and `{ renderMode: "realistic" }` borrows the realistic look
2800
+ for the capture — waiting on the chosen environment's assets — **without**
2801
+ switching the live view. Rejects if the realistic assets fail to load.
2802
+
2803
+ Both preferences round-trip through `mount()`'s `viewerState`: a
2804
+ previous mount's `runtime.getViewerState()` carries `viewerState.renderMode`
2805
+ (`"cad"` or `"realistic"`) and, only when the viewer's environment was
2806
+ actually CHOSEN rather than merely defaulted from `meta.environment`,
2807
+ `viewerState.environment`. `renderMode` reports the mode the user is **headed
2808
+ for**, not only the one on screen: a realistic restore or switch that's still
2809
+ loading reports `"realistic"`, so a host that remounts on every edit (as an
2810
+ embedder applying edits by remounting typically does) doesn't drop the
2811
+ in-flight choice — a load that ultimately fails still settles back to
2812
+ `"cad"`. Pass `viewerState` back into the next `mount()` call to resume both
2813
+ where the previous mount left them; omit it on a first mount and the viewer
2814
+ restores its own persisted choice instead, the same way it does for the
2815
+ camera and projection.
2816
+
2663
2817
  ### The annotation payload's camera block
2664
2818
 
2665
2819
  `onAnnotationSend(payload)` receives a `camera` block in two frames — `world`
@@ -3121,6 +3275,13 @@ motion — translate/rotate — never a reshape) (both errors). An untrusted pro
3121
3275
  nothing either way and stays silent, matching `animation-track-rebuilds`'s own
3122
3276
  trust handling.
3123
3277
 
3278
+ **Appearance** (all warnings) — `unknown-material` (a `display.material` the
3279
+ library does not know; the viewer shows the default look), `unknown-environment`
3280
+ (`meta.environment` not known; realistic mode uses `studio`),
3281
+ `material-key-unknown` (a `display` key that is not colour, opacity, material
3282
+ or one of the six overrides; ignored), `material-override-clamped` (an override
3283
+ outside its range, or not a number; clamped).
3284
+
3124
3285
  **Geometry imports** — `import-unknown-name` (a build calls `k.import` with a
3125
3286
  name the part's `imports` field doesn't declare — this throws at build time;
3126
3287
  lint reaches it in microseconds instead), `import-mesh-on-occt` (a declared
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.121.1",
3
+ "version": "0.122.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",
@@ -132,11 +132,13 @@
132
132
  "@eslint/js": "^10.0.1",
133
133
  "@fontsource-variable/geist": "^5.3.0",
134
134
  "@fontsource-variable/geist-mono": "^5.3.0",
135
+ "@monogrid/gainmap-js": "^3.4.0",
135
136
  "eslint": "^10.10.0",
136
137
  "globals": "^17.12.0",
137
138
  "happy-dom": "^20.10.6",
138
139
  "knip": "^6.35.1",
139
140
  "playwright": "^1.49.0",
141
+ "sharp": "^0.35.4",
140
142
  "typescript": "^5.9.3",
141
143
  "vite": "^8.0.16",
142
144
  "vitest": "^4.1.9"
@@ -0,0 +1,23 @@
1
+ // Self-hosted Geist + Geist Mono for the dev demos, so a standalone forge looks
2
+ // like the product. Dev-only: --pf-sans/--pf-mono fall back to system stacks for
3
+ // any consumer that doesn't load them (spec §2.2).
4
+ import "@fontsource-variable/geist";
5
+ import "@fontsource-variable/geist-mono";
6
+ import materialSwatchesPart from "./parts/material-swatches.js";
7
+ import { mount } from "./framework/index.js";
8
+
9
+ // Dev-only contact sheet app for the material library (parts/material-swatches.js).
10
+ // Identical wiring to app.js — the only thing that differs per part is which
11
+ // definition you import and which worker entry you point at. `npm run dev`, then
12
+ // open /materials.html. Not part of the production build — check materials.html
13
+ // by eye after any preset or shader change (see AGENTS.md "Architecture").
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.
16
+ window.__pfRuntime = mount(materialSwatchesPart, {
17
+ createWorker: (name) =>
18
+ new Worker(new URL("./materials-worker.js", import.meta.url), { type: "module", name }),
19
+ onAnnotationSend: (payload) => {
20
+ window.__pfLastAnnotation = payload;
21
+ console.log("annotation payload", payload);
22
+ },
23
+ });
@@ -476,7 +476,7 @@ button.action:disabled { opacity: .5; cursor: default; }
476
476
  /* keyboard focus ring shared across the panel's interactive controls */
477
477
  .seg button:focus-visible, select.preset:focus-visible, .dl-row button:focus-visible,
478
478
  button.action:focus-visible, .adv-toggle:focus-visible, .sec-title:focus-visible, #viewbar button:focus-visible,
479
- .pf-view-select:focus-visible,
479
+ #viewbar select:focus-visible, .pf-view-select:focus-visible,
480
480
  .pf-viewcube-toggle:focus-visible, .pf-float-rail-toggle:focus-visible, .pf-sketch-toolbar button:focus-visible {
481
481
  outline: none;
482
482
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--pf-accent) 35%, transparent);
@@ -595,6 +595,21 @@ button.action:focus-visible, .adv-toggle:focus-visible, .sec-title:focus-visible
595
595
  #viewbar button:disabled:hover { color: var(--pf-muted-2); background: transparent; }
596
596
  #viewbar button:hover { color: var(--pf-text); background: var(--pf-surface-2); }
597
597
  #viewbar button.on { background: var(--pf-accent); color: var(--pf-on-accent); }
598
+ /* Realistic view (realistic-controls.js). The toggle is an ordinary viewbar
599
+ button that pulses while its environment loads; the environment picker is a
600
+ native <select> — the accessible choice on a phone — dressed as a viewbar
601
+ button, shown only while realistic. Keyed on `#viewbar select` rather than
602
+ an id so a host's own element in the pill gets the same look. */
603
+ #viewbar button[data-busy] { animation: pf-busy-pulse .8s ease-in-out infinite alternate; }
604
+ @keyframes pf-busy-pulse { from { opacity: 1; } to { opacity: .45; } }
605
+ @media (prefers-reduced-motion: reduce) { #viewbar button[data-busy] { animation: none; opacity: .6; } }
606
+ #viewbar select {
607
+ height: 34px; padding: 0 8px; border: 0; border-radius: var(--pf-radius-control);
608
+ background: transparent; color: var(--pf-muted-2); cursor: pointer;
609
+ font: inherit; font-size: 13px;
610
+ }
611
+ #viewbar select:hover { color: var(--pf-text); background: var(--pf-surface-2); }
612
+ #viewbar select[hidden] { display: none; }
598
613
 
599
614
  /* Sketch-mode toolbar: the #viewbar pill idiom, top-centre, owning the mode
600
615
  while the viewbar itself is hidden (mount toggles both).
@@ -493,6 +493,17 @@ export function createCutaway({
493
493
  return true;
494
494
  }
495
495
 
496
+ // Swap one sub-part's SOURCE material (realistic mode). The render set
497
+ // re-derives its clipped clone and cap colour from it and reassigns
498
+ // mesh.material for the current enabled state.
499
+ function refreshSubpartMaterial(name, meshMaterial) {
500
+ if (disposed) return false;
501
+ const entry = renderSets.get(name);
502
+ if (!entry) return false;
503
+ entry.renderSet.refreshSourceMaterial(meshMaterial);
504
+ return true;
505
+ }
506
+
496
507
  // Per-frame maintenance while the cutaway is on: the gizmo rescales for the
497
508
  // camera, and every visible section re-slices its outline if anything it
498
509
  // depends on moved. Both are cheap no-ops when nothing changed.
@@ -581,6 +592,7 @@ export function createCutaway({
581
592
  setVisible,
582
593
  setEnabled,
583
594
  resyncSubpart,
595
+ refreshSubpartMaterial,
584
596
  reset,
585
597
  flip,
586
598
  getState,
@@ -1,6 +1,7 @@
1
1
  // Minimal 3MF writer. 3MF is an OPC package (a zip) holding an XML model; it's a
2
2
  // mesh format like STL but supports units and multiple named objects in one file,
3
3
  // so a multi-part view exports as a single .3mf. Built from indexed meshes.
4
+ // Each object may carry an optional colour (0xRRGGBB).
4
5
  import { zipSync, strToU8 } from "fflate";
5
6
 
6
7
  const CONTENT_TYPES =
@@ -67,16 +68,44 @@ function weld(positions, indices) {
67
68
  return { verts, tris };
68
69
  }
69
70
 
70
- // parts: [{ name, positions: Float32Array (x,y,z per vertex), indices: Uint32Array (3 per triangle) }]
71
- // → ArrayBuffer of the .3mf zip (millimetre units; one <object> + <build> item per part).
71
+ // Default for an uncoloured object in an export where some other object IS
72
+ // coloured: the viewer's own default look (presets.js "default"). Written as a
73
+ // literal so this module keeps its fflate-only import closure.
74
+ const DEFAULT_DISPLAY_COLOR = 0x9fb4cc;
75
+ // Base-material group id: outside the object id range (objects are 1..n).
76
+ const BASE_MATERIALS_ID = 1000;
77
+ const hexColor = (c) => `#${c.toString(16).padStart(6, "0").toUpperCase()}FF`;
78
+
79
+ // parts: [{ name, positions, indices, color? }] → ArrayBuffer of the .3mf zip.
80
+ // `color` (0xRRGGBB) is optional per part. When NO part carries one, the model is
81
+ // byte-for-byte what it was before colour support — no <basematerials>, no pid.
82
+ // When any does, every object points (pid/pindex) into one core-spec
83
+ // <basematerials> group, one <base> per distinct colour, so a slicer opens the
84
+ // file pre-split into coloured objects.
72
85
  export function meshTo3MF(parts) {
86
+ const colored = parts.some((p) => p.color != null);
87
+ const palette = [];
88
+ const pindexOf = (c) => {
89
+ const color = c ?? DEFAULT_DISPLAY_COLOR;
90
+ let i = palette.indexOf(color);
91
+ if (i < 0) { i = palette.length; palette.push(color); }
92
+ return i;
93
+ };
94
+ const pindex = colored ? parts.map((p) => pindexOf(p.color)) : [];
95
+
73
96
  const out = [
74
97
  '<?xml version="1.0" encoding="UTF-8"?>',
75
98
  '<model unit="millimeter" xml:lang="en-US" xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02">',
76
99
  "<resources>",
77
100
  ];
101
+ if (colored) {
102
+ out.push(`<basematerials id="${BASE_MATERIALS_ID}">`);
103
+ palette.forEach((c, i) => out.push(`<base name="color-${i + 1}" displaycolor="${hexColor(c)}"/>`));
104
+ out.push("</basematerials>");
105
+ }
78
106
  parts.forEach((p, i) => {
79
- out.push(`<object id="${i + 1}" type="model" name="${xmlEsc(p.name)}"><mesh><vertices>`);
107
+ const mat = colored ? ` pid="${BASE_MATERIALS_ID}" pindex="${pindex[i]}"` : "";
108
+ out.push(`<object id="${i + 1}" type="model" name="${xmlEsc(p.name)}"${mat}><mesh><vertices>`);
80
109
  const { verts: v, tris: t } = weld(p.positions, p.indices);
81
110
  for (let k = 0; k < v.length; k += 3) out.push(`<vertex x="${v[k]}" y="${v[k + 1]}" z="${v[k + 2]}"/>`);
82
111
  out.push("</vertices><triangles>");
@@ -3,6 +3,7 @@
3
3
  // the oracle and the collision check can share it without importing this async,
4
4
  // kernel-bound module back.
5
5
  import { meshTo3MF } from "./geometry/threemf.js";
6
+ import { printColor } from "./materials/resolve.js";
6
7
  import { exportablePartNames } from "./export-select.js";
7
8
  import { fontControlAllows, fontSourceAllowed, isNoFontSource } from "./font-source.js";
8
9
  import { fontsFor, resolveFonts } from "./fonts.js";
@@ -449,7 +450,7 @@ export async function handle(kernel, part, msg, post, opts = {}) {
449
450
  const meshes = names.map((name) => {
450
451
  onProgress(`building ${label(name)}`);
451
452
  const { positions, indices } = posed(name, "export", onProgress).toIndexedMesh({ quality: msg.quality ?? "print" });
452
- return { name: exportName(name), positions, indices };
453
+ return { name: exportName(name), positions, indices, color: printColor(part.parts[name]?.display) };
453
454
  });
454
455
  onProgress("writing 3MF file");
455
456
  const data = meshTo3MF(meshes);
@@ -20,8 +20,9 @@ import { FONT_RULES } from "./rules-fonts.js";
20
20
  import { IMAGE_RULES } from "./rules-images.js";
21
21
  import { SOURCE_RULES } from "./rules-source.js";
22
22
  import { VECTOR_RULES } from "./rules-vector.js";
23
+ import { MATERIAL_RULES } from "./rules-materials.js";
23
24
 
24
- export const RULES = [...SHAPE_RULES, ...SCHEMA_RULES, ...BUILD_RULES, ...VERIFY_RULES, ...ANIMATION_RULES, ...PLACE_RULES, ...IMPORT_RULES, ...FONT_RULES, ...IMAGE_RULES, ...SOURCE_RULES, ...VECTOR_RULES];
25
+ export const RULES = [...SHAPE_RULES, ...SCHEMA_RULES, ...BUILD_RULES, ...VERIFY_RULES, ...ANIMATION_RULES, ...PLACE_RULES, ...IMPORT_RULES, ...FONT_RULES, ...IMAGE_RULES, ...SOURCE_RULES, ...VECTOR_RULES, ...MATERIAL_RULES];
25
26
 
26
27
  // A usable sources input, or null. Deliberately forgiving: lintPart's callers
27
28
  // include hosted paths handing over user/LLM-authored trees, so a malformed
@@ -0,0 +1,56 @@
1
+ // src/framework/lint/rules-materials.js
2
+ // Appearance findings. All WARNINGS by design: a misspelled material must never
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).
5
+ import { warn } from "./finding.js";
6
+ import { PRESETS, DEFAULT_PRESET_ID } from "../materials/presets.js";
7
+ import { ENVIRONMENTS } from "../materials/environments.js";
8
+ import { resolveMaterial, resolveEnvironmentId, OVERRIDE_RANGES } from "../materials/resolve.js";
9
+ import { suggest } from "../geometry/op-options.js";
10
+
11
+ const listedPresets = () => Object.keys(PRESETS).filter((id) => id !== DEFAULT_PRESET_ID);
12
+ const subParts = (part) => Object.entries(part?.parts ?? {}).filter(([, sp]) => sp && typeof sp === "object");
13
+ const issuesOf = (part, kind) => subParts(part).flatMap(([name, sp]) =>
14
+ resolveMaterial(sp.display).issues.filter((i) => i.kind === kind).map((i) => ({ name, ...i })));
15
+
16
+ export const MATERIAL_RULES = [
17
+ {
18
+ id: "unknown-material",
19
+ run: ({ part }) => issuesOf(part, "unknown-material").map(({ name, value }) => {
20
+ const near = typeof value === "string" ? suggest(value, listedPresets()) : null;
21
+ return warn("unknown-material",
22
+ `sub-part "${name}" names material ${JSON.stringify(value)}, which is not in the library`,
23
+ `${near ? `Did you mean "${near}"? ` : ""}The viewer shows the default look instead. Known materials: ${listedPresets().join(", ")}.`,
24
+ `parts.${name}.display.material`);
25
+ }),
26
+ },
27
+ {
28
+ id: "unknown-environment",
29
+ run: ({ part }) => {
30
+ const env = part?.meta?.environment;
31
+ if (resolveEnvironmentId(env).known) return [];
32
+ return [warn("unknown-environment",
33
+ `meta.environment ${JSON.stringify(env)} is not a known environment`,
34
+ `Realistic mode uses "studio" instead. Known environments: ${Object.keys(ENVIRONMENTS).join(", ")}.`,
35
+ "meta.environment")];
36
+ },
37
+ },
38
+ {
39
+ id: "material-key-unknown",
40
+ run: ({ part }) => issuesOf(part, "material-key-unknown").map(({ name, key }) =>
41
+ warn("material-key-unknown",
42
+ `sub-part "${name}" display has unknown key "${key}"`,
43
+ `It is ignored. display accepts color, opacity, material and the overrides ${Object.keys(OVERRIDE_RANGES).join(", ")}; transmission, IOR, sheen and iridescence come only from a preset.`,
44
+ `parts.${name}.display.${key}`)),
45
+ },
46
+ {
47
+ id: "material-override-clamped",
48
+ run: ({ part }) => issuesOf(part, "material-override-clamped").map(({ name, key, value, clampedTo }) => {
49
+ const [lo, hi] = OVERRIDE_RANGES[key];
50
+ return warn("material-override-clamped",
51
+ `sub-part "${name}" display.${key} = ${JSON.stringify(value)} is outside ${lo}..${hi}; using ${clampedTo}`,
52
+ `Give ${key} a number from ${lo} to ${hi}.`,
53
+ `parts.${name}.display.${key}`);
54
+ }),
55
+ },
56
+ ];
@@ -0,0 +1,90 @@
1
+ # Asset sources
2
+
3
+ All source assets are CC0 (public domain dedication — no attribution required). Each
4
+ source page was checked for "CC0" at download time (2026-09-24). Baked outputs are
5
+ produced by `scripts/bake-environments.mjs`.
6
+
7
+ Environments and ground/pattern textures are keyed by the file names `assets.js`
8
+ resolves — see that module for the name -> URL map, and `environments.js` for which
9
+ file each render environment uses.
10
+
11
+ ## Environments (HDRIs -> UltraHDR gainmap JPEGs)
12
+
13
+ | File | Source asset | Source URL | Fetched | Processing |
14
+ | --- | --- | --- | --- | --- |
15
+ | `env-studio.jpg` | Poly Haven HDRI "Studio Small 09" | https://polyhaven.com/a/studio_small_09 | 2026-09-24 | 2K `.hdr` -> `node scripts/bake-environments.mjs studio_small_09_2k.hdr env-studio.jpg` |
16
+ | `env-workshop.jpg` | Poly Haven HDRI "Industrial Workshop Foundry" | https://polyhaven.com/a/industrial_workshop_foundry | 2026-09-24 | 2K `.hdr` -> `node scripts/bake-environments.mjs industrial_workshop_foundry_2k.hdr env-workshop.jpg` |
17
+ | `env-print-bed.jpg` | Poly Haven HDRI "Brown Photostudio 02" | https://polyhaven.com/a/brown_photostudio_02 | 2026-09-24 | 2K `.hdr` -> `node scripts/bake-environments.mjs brown_photostudio_02_2k.hdr env-print-bed.jpg --contrast 2`. The contrast curve (luminance raised to the power 2 about the image's log-average) darkens the room and sharpens its lights, for the inside of a printer enclosure; the environment record adds a hard overhead key light and dims the backdrop further. |
18
+ | `env-outdoor.jpg` | Poly Haven HDRI "Kloofendal Overcast" | https://polyhaven.com/a/kloofendal_overcast | 2026-09-24 | 2K `.hdr` -> `node scripts/bake-environments.mjs kloofendal_overcast_2k.hdr env-outdoor.jpg` |
19
+
20
+ Poly Haven publishes every asset under CC0 (https://polyhaven.com/license, checked
21
+ 2026-09-24). Licence text at fetch time also confirmed per-asset via
22
+ `https://api.polyhaven.com/info/<slug>` (categories/metadata only — the site-wide
23
+ license statement is what governs).
24
+
25
+ ### Environment baking detail
26
+
27
+ `ultrahdr_app` (libultrahdr 2.0.2, `brew install libultrahdr`) only writes the newer
28
+ ISO 21496-1 binary-box gainmap metadata, not the legacy Adobe XMP ("hdrgm"-prefixed)
29
+ block. The baker therefore: (1) decodes the `.hdr` with three's own `HDRLoader` (pure
30
+ JS) into a raw RGBA half-float buffer, (2) hands that to `ultrahdr_app -m 0` to derive
31
+ the SDR rendition and gain map (`-a 4 -t 0`, sdr/gainmap quality 80, gainmap
32
+ downsampled 2x), (3) splits the two embedded JPEG streams `ultrahdr_app` produced back
33
+ apart, and (4) re-merges them with `@monogrid/gainmap-js`'s `encodeJPEGMetadata` (pure
34
+ JS — no WebGL, only JPEG marker writing) using the boost/offset/gamma values read back
35
+ off `ultrahdr_app`'s own probe of its ISO output, so both metadata conventions agree.
36
+ A re-probe of each committed file (`ultrahdr_app -m 1 -j <file> -P`) round-trips the
37
+ same boost numbers back out, confirming the legacy block is self-consistent. See
38
+ `scripts/bake-environments.mjs`'s header comment for the fuller account, including why
39
+ `@monogrid/gainmap-js`'s WebGL-based `encode()`/`findTextureMinMax()` path (the one the
40
+ task brief originally sketched) is impractical in plain Node — no WebGL context is
41
+ available, and this tree has no headless-GL shim.
42
+
43
+ Before the re-merge, the baker also strips `ultrahdr_app`'s own APP2 MPF and ISO
44
+ 21496-1 metadata segments out of both the SDR and gain-map halves
45
+ (`stripAppSegments` in the baker). Without this, `encodeJPEGMetadata` prepends its
46
+ own freshly-computed MPF/XMP but leaves `ultrahdr_app`'s originals sitting later in
47
+ the same file; three's `UltraHDRLoader` walks every top-level JPEG section across the
48
+ whole concatenated file and keeps whichever MPF/ISO segment it sees LAST, so the
49
+ stale one silently won — its offsets were computed for the old, smaller file, so the
50
+ primary/gain-map slices it produced didn't even end on a real EOI marker. Verified
51
+ fixed by driving three's actual `UltraHDRLoader.parse()` (via a `happy-dom` DOMParser
52
+ polyfill, short-circuiting before the GPU decode step) against each committed file:
53
+ the sliced primary and gain-map buffers now start with SOI (`FF D8`) and end with EOI
54
+ (`FF D9`), and there is exactly one MPF segment and no leftover ISO box in the file.
55
+
56
+ ## Ground and pattern textures
57
+
58
+ | File | Source asset | Source URL | Fetched | Processing |
59
+ | --- | --- | --- | --- | --- |
60
+ | `ground-paper.jpg` | ambientCG "Paper 001" (Color) | https://ambientcg.com/a/Paper001 | 2026-09-24 | 1K JPG -> `node scripts/bake-environments.mjs --texture Paper001_1K-JPG_Color.jpg ground-paper.jpg` |
61
+ | `ground-paper-normal.jpg` | ambientCG "Paper 001" (NormalGL) | https://ambientcg.com/a/Paper001 | 2026-09-24 | 2K JPG -> `node scripts/bake-environments.mjs --texture Paper001_2K-JPG_NormalGL.jpg ground-paper-normal.jpg --data --size 1024 --quality 70`. The paper's grain lives in its relief; the colour map is nearly flat. |
62
+ | `ground-paper-rough.jpg` | ambientCG "Paper 001" (Roughness) | https://ambientcg.com/a/Paper001 | 2026-09-24 | 2K JPG, grayscale -> `node scripts/bake-environments.mjs --texture Paper001_2K-JPG_Roughness.jpg ground-paper-rough.jpg --gray --size 1024` |
63
+ | `pattern-oak-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 | 1K JPG, sRGB colour -> `node scripts/bake-environments.mjs --texture oak_veneer_01_diff_1k.jpg pattern-oak-color.jpg` (q82). Scanned tile 1830 mm (`api.polyhaven.com/info/oak_veneer_01` dimensions). |
64
+ | `pattern-oak-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 | 1K JPG, OpenGL (+Y up) tangent normals, kept as data -> `node scripts/bake-environments.mjs --texture oak_veneer_01_nor_gl_1k.jpg pattern-oak-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). |
65
+ | `pattern-oak-rough.jpg` | Poly Haven "Oak Veneer 01" (roughness; API key `Rough`) | https://polyhaven.com/a/oak_veneer_01 — file https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/oak_veneer_01/oak_veneer_01_rough_1k.jpg | 2026-09-24 | 1K JPG, grayscale data -> `node scripts/bake-environments.mjs --texture oak_veneer_01_rough_1k.jpg pattern-oak-rough.jpg --gray` (source is neutral grey; channel means unchanged). |
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
+ | `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
+ | `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
+ | `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
+ | `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
+ | `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 `196,154,82` gold-brown 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 196,154,82`. |
72
+ | `ground-pei-rough.jpg` | ambientCG "Plastic 010" (Roughness) | https://ambientcg.com/a/Plastic010 | 2026-09-24 | 1K JPG, grayscale -> `node scripts/bake-environments.mjs --texture Plastic010_1K-JPG_Roughness.jpg ground-pei-rough.jpg --gray` |
73
+ | `ground-concrete.jpg` | ambientCG "Concrete 047 A" (Color) | https://ambientcg.com/a/Concrete047A | 2026-09-24 | 2K JPG -> `node scripts/bake-environments.mjs --texture Concrete047A_2K-JPG_Color.jpg ground-concrete.jpg --size 1024 --modulate 1.35,0 --flatten 0.7 --quality 82` (grey, brightened to the old map's mean so the concrete material preset keeps its tone; `--flatten 0.7` is 30% less grain contrast). Replaced ambientCG "Concrete 034", whose colour map was nearly flat (stdev ~5/255) and read as blur up close; 047 A's exposed aggregate keeps fine grain. |
74
+ | `ground-concrete-rough.jpg` | ambientCG "Concrete 047 A" (Roughness) | https://ambientcg.com/a/Concrete047A | 2026-09-24 | 2K JPG, grayscale -> `node scripts/bake-environments.mjs --texture Concrete047A_2K-JPG_Roughness.jpg ground-concrete-rough.jpg --gray --size 1024` |
75
+ | `ground-concrete-normal.jpg` | ambientCG "Concrete 047 A" (NormalGL) | https://ambientcg.com/a/Concrete047A | 2026-09-24 | 2K JPG -> `node scripts/bake-environments.mjs --texture Concrete047A_2K-JPG_NormalGL.jpg ground-concrete-normal.jpg --data --size 1024 --quality 85` |
76
+ | `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
+
78
+ 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
+ from `https://api.polyhaven.com/files/<slug>` (keys `Diffuse`, `nor_gl`, `Rough`).
83
+ The presets tile them smaller than the scanned size — see the comment on `oak` in
84
+ `presets.js` and on `workshop` in `environments.js`.
85
+
86
+ ## Budgets
87
+
88
+ 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
90
+ outputs are well inside both ceilings — see `test/framework/materials-assets.test.js`.