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:
co-authored by
Claude Opus 4.8
parent
89a7232a67
commit
101341d98d
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user