feat(paint-slots): paintable slots on the procedural shelf (phase 5)

Proves the unified (nodeId, slotId) slot contract on a procedural generator,
beyond items and walls. A shelf now exposes three paintable slots — shelves /
frame / back — painted through the same PaintCapability dispatch and the same
node.slots: Record<slotId, MaterialRef> shape items use.

Foundation (shared, reusable by future procedural kinds):
- core: SlotDeclaration type + capabilities.slots(node) registry declaration;
  GeometryContext gains `materials` so a pure builder can resolve scene:<id>
  slot refs without importing useScene.
- viewer GeometrySystem: threads the scene material library into every builder
  ctx, and re-dirties (bypassing the geometryKey skip) any geometry node that
  references a scene material when that material changes — so editing a custom
  colour propagates to every shelf using it, matching items.

Shelf:
- schema: slots: Record<string, MaterialRef> (mirrors ItemNode).
- geometry: per-slot material resolution (slot override -> legacy whole-shelf
  -> declared default colour); every mesh stamped with userData.slotId;
  DEFAULT_SHELF_MATERIAL retired (declared default gives identical off-white).
- paint.ts: PaintCapability (resolveRole from userData.slotId, scene-material
  commit for one-off colours, preview restricted to __fromGeometry meshes so
  hosted items aren't ghosted, getEffectiveMaterial incl. legacy fallback).
- definition: paint + slots capabilities; slots folded into geometryKey.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-16 10:59:59 -04:00
co-authored by Claude Opus 4.8
parent 89a7232a67
commit 101341d98d
10 changed files with 455 additions and 87 deletions
@@ -58,6 +58,10 @@ export const GeometrySystem = () => {
const textures = useViewer((s) => s.textures)
const colorPreset = useViewer((s) => s.colorPreset)
const sceneTheme = useViewer((s) => s.sceneTheme)
// The shared scene-material library, threaded into each builder's ctx so
// pure geometry builders can resolve `scene:<id>` slot refs without
// importing `useScene`.
const sceneMaterials = useScene((s) => s.materials)
// Per-node cache of the last-built geometry key (for kinds that declare
// `def.geometryKey`). Lets us skip a dispose+rebuild when a node is dirty
// but its geometry inputs are unchanged — e.g. an item reparenting onto a
@@ -74,6 +78,23 @@ export const GeometrySystem = () => {
}
}, [shading, textures, colorPreset, sceneTheme])
// Editing a scene material must re-colour every geometry node that
// references it through a `scene:<id>` slot ref. Such a node's
// `geometryKey` is unchanged (only the referenced material's contents
// moved), so clear its cached key to defeat the skip in the rebuild loop,
// then mark it dirty. Scoped to nodes carrying a `scene:` ref so an
// unrelated material edit doesn't churn the whole scene.
useEffect(() => {
const nodes = useScene.getState().nodes
for (const node of Object.values(nodes)) {
const def = nodeRegistry.get(node.type)
if (!def?.geometry) continue
if (!nodeReferencesSceneMaterial(node)) continue
builtGeometryKeyRef.current.delete(node.id)
useScene.getState().markDirty(node.id as AnyNodeId)
}
}, [sceneMaterials])
useFrame(() => {
if (dirtyNodes.size === 0) return
const nodes = useScene.getState().nodes
@@ -168,7 +189,7 @@ export const GeometrySystem = () => {
const parentId = (node.parentId ?? null) as AnyNodeId | null
const key: BatchKey = `${node.type}::${parentId ?? ''}`
const levelData = levelDataByBatch.get(key)
const ctx = buildGeometryContext(effectiveNode, nodes, levelData)
const ctx = buildGeometryContext(effectiveNode, nodes, levelData, sceneMaterials)
// The builder is typed against the kind's specific node — at the
// generic system level we lose that refinement, so the cast lands
@@ -221,10 +242,20 @@ export const GeometrySystem = () => {
return null
}
function nodeReferencesSceneMaterial(node: AnyNode): boolean {
const slots = (node as { slots?: Record<string, string> }).slots
if (!slots) return false
for (const ref of Object.values(slots)) {
if (typeof ref === 'string' && ref.startsWith('scene:')) return true
}
return false
}
function buildGeometryContext(
node: AnyNode,
nodes: Record<string, AnyNode>,
levelData: unknown,
materials: GeometryContext['materials'],
): GeometryContext {
const resolve = <N = AnyNode>(id: AnyNodeId): N | undefined => nodes[id] as N | undefined
@@ -255,7 +286,7 @@ function buildGeometryContext(
}
}
return { resolve, children, siblings, parent, levelData }
return { resolve, children, siblings, parent, levelData, materials }
}
function disposeChildren(group: Group) {