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
+8 -10
View File
@@ -13,11 +13,9 @@ import type { ShelfNode } from './schema'
* then walks the result, **clones** each mesh's material, and mutates
* the clone for a translucent ghost.
*
* Cloning is non-negotiable: `getShelfMaterial` caches the default
* material instance in a module-scoped map keyed on
* `material` / `materialPreset`, so every unpainted shelf in the scene
* shares the same material. Mutating `mat.transparent = true` here
* would leak into every committed shelf and render them all see-through.
* Cloning is non-negotiable: shelf geometry may receive materials from
* shared viewer caches, so mutating `mat.transparent = true` here would
* leak into committed shelves using the same material.
*
* Building the full geometry tree per-frame would be wasteful, so we
* memoize the group + dispose the per-mesh material clones on unmount.
@@ -44,9 +42,9 @@ const ShelfPreview = ({ node }: { node: ShelfNode }) => {
;(obj as unknown as { raycast: () => void }).raycast = () => {}
// `Mesh.material` is typed as `Material | Material[]` upstream;
// every shelf board carries a material from
// `getShelfMaterial`. Access through a structural cast keeps the
// assignment well-typed without depending on the Mesh union.
// every shelf board carries a material from the geometry builder.
// Access through a structural cast keeps the assignment well-typed
// without depending on the Mesh union.
const mesh = obj as {
material?: Material | Material[]
}
@@ -70,8 +68,8 @@ const ShelfPreview = ({ node }: { node: ShelfNode }) => {
return () => {
// Dispose only the clones we made — never the shared cached
// material returned by `getShelfMaterial`, which other shelves in
// the scene still reference. Geometry is left alone for the same
// material returned by the builder, which other shelves in the
// scene may still reference. Geometry is left alone for the same
// reason; the builder may move to a cached strategy in future.
for (const c of cloned) c.dispose()
built.traverse((obj) => {