Pool slabs (generatePoolGeometry) were the one procedural surface emitting normalized [0..1] floor UVs, so a textured finish stretched to fit the pool instead of tiling at real-world scale like every other surface. Switch the floor to shape-space metres (x, -z) — the same mapping generatePositiveSlab- Geometry already uses for its caps. Pool walls were already in metres. Also document the contract in wiki/architecture/materials-and-themes.md: every procedural surface generates UVs in metres (1 UV unit = 1 m), GLB slots follow the same ~1 unit/m authoring convention, and a catalog material's `repeat` is therefore a per-material world-scale setting (tiles per metre), identical for every surface that uses it — never per-item. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
97 lines
6.3 KiB
Markdown
97 lines
6.3 KiB
Markdown
# Materials & themes (surface colour)
|
||
|
||
How a node's surfaces get their colour. Applies to: `packages/viewer/src/lib/{materials.ts,scene-themes.ts}`, the per-kind material logic in `packages/viewer/src/systems/<kind>/` and `packages/nodes/src/<kind>/`, and the appearance state in `packages/viewer/src/store/use-viewer.ts`.
|
||
|
||
## The axes
|
||
|
||
Appearance is a set of orthogonal axes, all held in `useViewer`:
|
||
|
||
| State | Values | What it controls |
|
||
|---|---|---|
|
||
| `shading` | `'solid' \| 'rendered'` | `solid` = `MeshLambertNodeMaterial`, no SSGI/AO. `rendered` = `MeshStandardNodeMaterial` + SSGI/AO. |
|
||
| `textures` | `boolean` | Whether surfaces that have a real material/preset show their texture. |
|
||
| `colorPreset` | `'clay' \| 'white' \| 'mono' \| 'blueprint'` | The per-role base palette for untextured surfaces. |
|
||
| `sceneTheme` | theme id (`studio`, `mediterranean`, `night`, `verdant`, …) | Lighting + background + ground + per-role colour tints. See [scene themes](#scene-themes). |
|
||
| `shadows` | `boolean` | Directional shadow casting (always-on key light; see `lights.tsx`). |
|
||
| `edges` | `'off' \| 'soft' \| 'strong'` | Screen-space ink outline in `post-processing.tsx` (`lib/ink-edges.ts`). |
|
||
|
||
`shading`/`textures`/`colorPreset` are persisted per-context; `shadingByContext` lets the editor default to `solid` and the community viewer to `rendered`.
|
||
|
||
## Surface roles
|
||
|
||
Every registry kind may declare one token on its `NodeDefinition` (`packages/core/src/registry/types.ts`):
|
||
|
||
```ts
|
||
surfaceRole?: 'wall' | 'floor' | 'ceiling' | 'roof' | 'joinery' | 'glazing' | 'furnishing'
|
||
```
|
||
|
||
`core` only stores the token — it carries no colour and never imports three.js. The token is what lets a wall, a slab, a column, etc. each resolve a *different* colour from the same palette.
|
||
|
||
## Resolving a colour
|
||
|
||
The single source of truth is in `packages/viewer/src/lib/materials.ts`:
|
||
|
||
```ts
|
||
resolveSurfaceColor(role, colorPreset, sceneThemeId?)
|
||
// = getSceneTheme(sceneThemeId).clayTints?.[role] // theme override, if any
|
||
// ?? PRESET_PALETTES[colorPreset][role] // else the preset palette
|
||
```
|
||
|
||
`createSurfaceRoleMaterial(role, colorPreset, side?, sceneThemeId?)` wraps that in a lit `MeshLambertNodeMaterial`, **cached by `role-preset-side-sceneTheme`**. The cache key is why every consumer must thread `sceneTheme` through — otherwise switching themes returns a stale cached material.
|
||
|
||
## The rule: untextured surfaces are theme-coloured in both modes
|
||
|
||
This is the important invariant. A surface is "textured" only if its node has an explicit `materialPreset` or `material`.
|
||
|
||
- **`textures` off** → every surface uses `resolveSurfaceColor(role, …)`.
|
||
- **`textures` on** → textured surfaces show their texture; **untextured surfaces still use `resolveSurfaceColor`** (not a hardcoded white/grey default).
|
||
|
||
So picking the Mediterranean theme gives a blue roof + warm walls without touching the textures toggle. There is no "all white" mode — untextured always means "themed role colour".
|
||
|
||
### Where it's wired per kind
|
||
|
||
| Kind | Where the role colour is applied |
|
||
|---|---|
|
||
| wall | `systems/wall/wall-materials.ts` (`getMaterialsForWall`), re-applied each frame by `wall-cutout.tsx` |
|
||
| roof / roof-segment | `systems/roof/roof-materials.ts` (`getRoofMaterialArray`) |
|
||
| slab | `nodes/slab/geometry.ts` (`getSlabMaterial`) |
|
||
| ceiling | `nodes/ceiling/renderer.tsx` |
|
||
| generic registry kinds | `systems/geometry/geometry-system.tsx` → `applyDefaultSurfaceRole` (textures-off) |
|
||
| door / window | `systems/{door,window}/*-system.tsx` |
|
||
| stair / column / item / elevator | `nodes/<kind>/renderer.tsx` |
|
||
|
||
Each of these reads `shading`/`textures`/`colorPreset`/`sceneTheme` from `useViewer` (or receives them threaded from `GeometrySystem`) and **must include `sceneTheme` in its material cache key and its rebuild dependency array**, or theme switches won't re-colour. `GeometrySystem` marks every geometry node dirty on any of those changing.
|
||
|
||
## Scene themes
|
||
|
||
A `SceneTheme` (`lib/scene-themes.ts`) bundles everything that defines a "look":
|
||
|
||
| Field | Drives |
|
||
|---|---|
|
||
| `appearance: 'light' \| 'dark'` | 2D scene chrome — canvas backdrop, grid line colours, measurement-label/cursor contrast. (There is **no** separate light/dark toggle; the theme owns this.) |
|
||
| `background` | The 3D backdrop, mixed in `post-processing.tsx` where there is no geometry. |
|
||
| `ground` | The site ground fill (`nodes/site/renderer.tsx`) and the infinite ground-occluder plane (`viewer/ground-occluder.tsx`). Kept separate from `background` so dark themes get a lit mid-tone ground instead of near-black. |
|
||
| `lights` / `ambient` / `hemi` | The light rig (`lights.tsx`). One key light casts shadows. |
|
||
| `toneMappingExposure` | Renderer exposure. |
|
||
| `clayTints?` | Per-`SurfaceRole` colour overrides layered on top of `colorPreset` (see [resolving a colour](#resolving-a-colour)). |
|
||
|
||
The editor UI chrome is always dark (a fixed `document.body.classList.add('dark')`) and is independent of `appearance`.
|
||
|
||
## Adding a theme
|
||
|
||
Append a `SceneTheme` to `SCENE_THEMES` with all required fields. `clayTints` is a `Partial` — any role you omit falls back to the active `colorPreset`. The theme pickers (toolbar + community overlay) render a 2×2 swatch from `clayTints` over `background`, so populate at least `wall`/`roof`/`floor`/`glazing` for a good swatch.
|
||
|
||
## Texture world scale (UVs in metres)
|
||
|
||
Every procedural surface generates UVs in metres: 1 UV unit = 1 m.
|
||
|
||
This contract is shared by wall `systems/wall/wall-system.tsx` (`ExtrudeGeometry`), slab `systems/slab/slab-system.tsx` (`generatePositiveSlabGeometry`, and `generatePoolGeometry`), ceiling `systems/ceiling/ceiling-system.tsx`, roof `systems/roof/roof-system.tsx`, and chimney/dormer `nodes/src/chimney/geometry.ts`.
|
||
|
||
GLB item slots follow the same ~1 UV unit/m authoring convention, enforced by the slot validator's UV-presence check and the phase-6 Blender recipe. This is an authoring requirement, not a render-time correction.
|
||
|
||
A catalog material's `repeat` (`mapProperties.repeatX/repeatY` in `packages/core/src/material-library.ts`) is therefore a per-material world-scale setting: tiles per metre.
|
||
|
||
`repeat: 1` means 1 tile/m, `0.4` means one tile every 2.5 m, and `1.5` means 1.5 tiles/m.
|
||
|
||
Repeat is a property of the material, identical for every surface that uses it, never per-item or per-surface. Custom repeat values are intentional material scale, not per-surface hacks.
|