Files
editor/wiki/architecture/materials-and-themes.md
T
Wassim SAMADandClaude Opus 4.8 c726721ec6 docs(architecture): add item-authoring guide for paintable GLBs
Documents the content-author contract for catalog item GLBs: slot_
material naming + deriveSlotId rules, authored defaults and
pascal_material extras, the cutout reserved mesh, the ~1 UV unit/m world
scale, vertex colours for fixed detail, and the validated Blender + glTF
export recipe (Custom Properties export gotcha, gltfpack -ke extras
preservation). Indexes the page in the architecture README and links it
from materials-and-themes.md (previously a dangling "phase-6 Blender
recipe" reference).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 09:04:42 -04:00

6.3 KiB
Raw Blame History

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.

  • 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.tsxapplyDefaultSurfaceRole (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 Blender recipe in item-authoring. 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.