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>
6.3 KiB
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. |
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):
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:
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.
texturesoff → every surface usesresolveSurfaceColor(role, …).textureson → textured surfaces show their texture; untextured surfaces still useresolveSurfaceColor(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). |
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.