feat(paint-slots): paintable slots for windows + doors (frame/glass, panel/glass)

Windows and doors build all visuals in their viewer systems from module-global
materials, so this threads per-node slot materials + userData.slotId tags
through those builders without restructuring them:

- window: 'frame' + 'glass' slots. door: 'panel' (body = casing + leaf) +
  'glass'; the opening reveal keeps its own material.
- Each system captures per-frame viewer state, then updateWindow/DoorMesh points
  the builder-facing base/glass materials at the node's resolved slot override
  (recomputed per node, so the next node resets without a restore). Meshes are
  auto-tagged in the shared addBox/addShape helpers by which material they got.
- Textures-off still collapses to the role material (escape hatch); a slot
  override only applies in colored mode.
- Editing a referenced scene material re-dirties the window/door (these systems
  aren't covered by GeometrySystem's scene-material re-dirty).
- New paint capabilities (resolve role from userData.slotId, preview by
  userData.slotId) + capabilities.slots; window/door dropped from the paint
  disabled list. Shared previewSlotByUserData helper.

Defaults unchanged: unpainted windows/doors render exactly as before (the slot
fallback is the existing frame/glass material), so no visual regression.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-17 08:54:18 -04:00
co-authored by Claude Opus 4.8
parent 737c4e9d1d
commit 9f1627e923
12 changed files with 266 additions and 1 deletions
@@ -5,6 +5,8 @@ import {
DoorNode as DoorNodeSchema,
getDoorRenderOpenAmount,
getEffectiveNode,
type SceneMaterial,
type SceneMaterialId,
sceneRegistry,
useInteractive,
useLiveNodeOverrides,
@@ -14,9 +16,12 @@ import { useFrame } from '@react-three/fiber'
import { useEffect, useRef } from 'react'
import * as THREE from 'three'
import {
type ColorPreset,
createSurfaceRoleMaterial,
glassMaterial as defaultGlassMaterial,
baseMaterial as getBaseMaterial,
type RenderShading,
resolveMaterialRef,
} from '../../lib/materials'
import useViewer from '../../store/use-viewer'
@@ -26,6 +31,12 @@ const defaultRevealMaterial = new THREE.MeshBasicMaterial({ color: '#7f766c' })
let baseMaterial = getBaseMaterial()
let revealMaterial: THREE.Material = defaultRevealMaterial
let glassMaterial: THREE.Material = defaultGlassMaterial
// Per-frame viewer state, captured so the per-node mesh builder (which runs
// outside React) can resolve each door's slot materials.
let currentShading: RenderShading = 'rendered'
let currentTextures = true
let currentColorPreset: ColorPreset = 'clay'
let currentSceneMaterials: Record<SceneMaterialId, SceneMaterial> | undefined
const DOOR_RENDER_DEFAULTS = DoorNodeSchema.parse({ id: 'door_render_default' })
const MAX_DOOR_REBUILDS_PER_FRAME = 16
@@ -50,6 +61,7 @@ export const DoorSystem = () => {
const shading = useViewer((state) => state.shading)
const textures = useViewer((state) => state.textures)
const colorPreset = useViewer((state) => state.colorPreset)
const sceneMaterials = useScene((state) => state.materials)
const materialRevisionRef = useRef<string | null>(null)
// Subscribe so an override-only update (no scene write) still re-runs
// the component, letting the gate below pick up the latest dirtyNodes
@@ -75,12 +87,27 @@ export const DoorSystem = () => {
}
})
// Editing a scene material a door slot references must rebuild that door
// (door meshes are built by this system, not <GeometrySystem>).
useEffect(() => {
const nodes = useScene.getState().nodes
for (const node of Object.values(nodes)) {
if (node?.type !== 'door') continue
if (!nodeReferencesSceneMaterial(node)) continue
useScene.getState().dirtyNodes.add(node.id as AnyNodeId)
}
}, [sceneMaterials])
useFrame(() => {
if (dirtyNodes.size === 0) return
const frameJoineryMaterial = createSurfaceRoleMaterial('joinery', colorPreset)
baseMaterial = textures ? getBaseMaterial(shading) : frameJoineryMaterial
revealMaterial = textures ? defaultRevealMaterial : frameJoineryMaterial
glassMaterial = textures ? defaultGlassMaterial : frameJoineryMaterial
currentShading = shading
currentTextures = textures
currentColorPreset = colorPreset
currentSceneMaterials = sceneMaterials
const nodes = useScene.getState().nodes
const dirtyDoorIds: AnyNodeId[] = []
@@ -134,6 +161,40 @@ export const DoorSystem = () => {
return null
}
// A door exposes two slots: `panel` (the door body — frame casing + leaf, all
// built with `baseMaterial`) and `glass`. The reveal (opening depth) is left on
// its own material and isn't painted. Tag each mesh by which material it got.
function tagDoorSlot(mesh: THREE.Mesh): THREE.Mesh {
if (mesh.material === glassMaterial) mesh.userData.slotId = 'glass'
else if (mesh.material === baseMaterial) mesh.userData.slotId = 'panel'
return mesh
}
function nodeReferencesSceneMaterial(node: { slots?: Record<string, string> }): boolean {
const slots = node.slots
if (!slots) return false
for (const ref of Object.values(slots)) {
if (typeof ref === 'string' && ref.startsWith('scene:')) return true
}
return false
}
function doorSlotDefault(slotId: 'panel' | 'glass'): THREE.Material {
if (!currentTextures) return createSurfaceRoleMaterial('joinery', currentColorPreset)
return slotId === 'glass' ? defaultGlassMaterial : getBaseMaterial(currentShading)
}
// Resolve a door's slot to a material: the `node.slots` override (colored mode
// only) → the body/glass default. Textures-off ignores overrides — the
// monochrome escape hatch.
function resolveDoorSlotMaterial(node: DoorNode, slotId: 'panel' | 'glass'): THREE.Material {
const fallback = doorSlotDefault(slotId)
if (!currentTextures) return fallback
const ref = node.slots?.[slotId]
if (!ref) return fallback
return resolveMaterialRef(ref, currentSceneMaterials, currentShading) ?? fallback
}
function addBox(
parent: THREE.Object3D,
material: THREE.Material,
@@ -146,6 +207,7 @@ function addBox(
) {
const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material)
m.position.set(x, y, z)
tagDoorSlot(m)
parent.add(m)
}
@@ -163,6 +225,7 @@ function addRotatedBox(
const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material)
m.position.set(x, y, z)
m.rotation.y = rotationY
tagDoorSlot(m)
parent.add(m)
}
@@ -180,6 +243,7 @@ function addBoxWithRotation(
const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material)
m.position.set(x, y, z)
m.rotation.set(rotation[0], rotation[1], rotation[2])
tagDoorSlot(m)
parent.add(m)
}
@@ -196,6 +260,7 @@ function addShape(
})
geometry.translate(0, 0, -depth / 2)
const mesh = new THREE.Mesh(geometry, material)
tagDoorSlot(mesh)
parent.add(mesh)
}
@@ -215,6 +280,7 @@ function addShapeAt(
})
geometry.translate(x, y, z - depth / 2)
const mesh = new THREE.Mesh(geometry, material)
tagDoorSlot(mesh)
parent.add(mesh)
}
@@ -1968,6 +2034,12 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) {
mesh.remove(child)
}
// Point the builder-facing body/glass materials at this door's slot overrides
// for the duration of its build (recomputed per node, so the next door resets
// cleanly without a restore). Reveal keeps its own material.
baseMaterial = resolveDoorSlotMaterial(node, 'panel')
glassMaterial = resolveDoorSlotMaterial(node, 'glass')
const {
width,
height,
@@ -1,6 +1,8 @@
import {
type AnyNodeId,
getEffectiveNode,
type SceneMaterial,
type SceneMaterialId,
sceneRegistry,
useInteractive,
useLiveNodeOverrides,
@@ -11,9 +13,12 @@ import { useFrame } from '@react-three/fiber'
import { useEffect, useRef } from 'react'
import * as THREE from 'three'
import {
type ColorPreset,
createSurfaceRoleMaterial,
glassMaterial as defaultGlassMaterial,
baseMaterial as getBaseMaterial,
type RenderShading,
resolveMaterialRef,
} from '../../lib/materials'
import useViewer from '../../store/use-viewer'
@@ -21,6 +26,12 @@ import useViewer from '../../store/use-viewer'
const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false })
let baseMaterial = getBaseMaterial()
let glassMaterial: THREE.Material = defaultGlassMaterial
// Per-frame viewer state, captured so the per-node mesh builder (which runs
// outside React) can resolve each window's slot materials.
let currentShading: RenderShading = 'rendered'
let currentTextures = true
let currentColorPreset: ColorPreset = 'clay'
let currentSceneMaterials: Record<SceneMaterialId, SceneMaterial> | undefined
export const CASEMENT_WINDOW_SASH_NAME = 'casement-window-sash'
export const FRENCH_CASEMENT_LEFT_SASH_NAME = 'french-casement-left-sash'
export const FRENCH_CASEMENT_RIGHT_SASH_NAME = 'french-casement-right-sash'
@@ -42,6 +53,7 @@ export const WindowSystem = () => {
const shading = useViewer((state) => state.shading)
const textures = useViewer((state) => state.textures)
const colorPreset = useViewer((state) => state.colorPreset)
const sceneMaterials = useScene((state) => state.materials)
const materialRevisionRef = useRef<string | null>(null)
// Subscribe so override-only updates re-run this component. Mirrors
// WallSystem + DoorSystem.
@@ -67,6 +79,18 @@ export const WindowSystem = () => {
}
})
// Editing a scene material a window slot references must rebuild that window
// (window meshes are built by this system, not <GeometrySystem>, so its
// scene-material re-dirty doesn't cover them).
useEffect(() => {
const nodes = useScene.getState().nodes
for (const node of Object.values(nodes)) {
if (node?.type !== 'window') continue
if (!nodeReferencesSceneMaterial(node)) continue
useScene.getState().dirtyNodes.add(node.id as AnyNodeId)
}
}, [sceneMaterials])
useFrame(() => {
if (dirtyNodes.size === 0) return
baseMaterial = textures
@@ -75,6 +99,10 @@ export const WindowSystem = () => {
glassMaterial = textures
? defaultGlassMaterial
: createSurfaceRoleMaterial('glazing', colorPreset)
currentShading = shading
currentTextures = textures
currentColorPreset = colorPreset
currentSceneMaterials = sceneMaterials
const nodes = useScene.getState().nodes
const dirtyWindowIds: AnyNodeId[] = []
@@ -128,6 +156,46 @@ export const WindowSystem = () => {
return null
}
// A window exposes two slots: `frame` (every joinery member) and `glass`. The
// builders pass `baseMaterial` / `glassMaterial`, so tag each mesh by which one
// it got — that's what `(nodeId, slotId)` paint resolves against.
function tagWindowSlot(mesh: THREE.Mesh): THREE.Mesh {
if (mesh.material === glassMaterial) mesh.userData.slotId = 'glass'
else if (mesh.material === baseMaterial) mesh.userData.slotId = 'frame'
return mesh
}
function nodeReferencesSceneMaterial(node: { slots?: Record<string, string> }): boolean {
const slots = node.slots
if (!slots) return false
for (const ref of Object.values(slots)) {
if (typeof ref === 'string' && ref.startsWith('scene:')) return true
}
return false
}
function windowSlotDefault(slotId: 'frame' | 'glass'): THREE.Material {
if (slotId === 'glass') {
return currentTextures
? defaultGlassMaterial
: createSurfaceRoleMaterial('glazing', currentColorPreset)
}
return currentTextures
? getBaseMaterial(currentShading)
: createSurfaceRoleMaterial('joinery', currentColorPreset)
}
// Resolve a window's slot to a material: the `node.slots` override (colored mode
// only) → the role/base default. Textures-off ignores overrides — the monochrome
// escape hatch.
function resolveWindowSlotMaterial(node: WindowNode, slotId: 'frame' | 'glass'): THREE.Material {
const fallback = windowSlotDefault(slotId)
if (!currentTextures) return fallback
const ref = node.slots?.[slotId]
if (!ref) return fallback
return resolveMaterialRef(ref, currentSceneMaterials, currentShading) ?? fallback
}
function addBox(
parent: THREE.Object3D,
material: THREE.Material,
@@ -140,6 +208,7 @@ function addBox(
) {
const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material)
m.position.set(x, y, z)
tagWindowSlot(m)
parent.add(m)
}
@@ -157,6 +226,7 @@ function addShape(
})
geometry.translate(0, 0, -depth / 2 + z)
const mesh = new THREE.Mesh(geometry, material)
tagWindowSlot(mesh)
parent.add(mesh)
}
@@ -3170,6 +3240,12 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
mesh.remove(child)
}
// Point the builder-facing frame/glass materials at this window's slot
// overrides for the duration of its build (recomputed per node, so the next
// window resets cleanly without a restore).
baseMaterial = resolveWindowSlotMaterial(node, 'frame')
glassMaterial = resolveWindowSlotMaterial(node, 'glass')
const {
width,
height,