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
+1
View File
@@ -103,6 +103,7 @@ export type {
ScalableConfig,
SceneApi,
SelectableConfig,
SlotDeclaration,
SnapPointKind,
SnappableConfig,
SnapServicesLike,
+33
View File
@@ -2,6 +2,7 @@ import type { ComponentType } from 'react'
import type { BufferGeometry, Object3D } from 'three'
import type { ZodObject, z } from 'zod'
import type { MaterialSchema } from '../schema/material'
import type { SceneMaterial, SceneMaterialId } from '../schema/scene-material'
import type { AnyNode, AnyNodeId } from '../schema/types'
import type { HandleList } from './handles'
import type { CloneNodesIntoOptions, Subtree } from './subtree'
@@ -43,6 +44,15 @@ export type GeometryContext = {
* has cheap access to siblings through `ctx.siblings`).
*/
levelData?: unknown
/**
* The scene's shared material library (`useScene.materials`), passed so a
* pure geometry builder can resolve `scene:<id>` slot refs without importing
* `useScene`. Populated by `<GeometrySystem>` for every `def.geometry` call;
* undefined for `def.floorplan`. `library:<id>` refs resolve against the
* static catalog and need no store, so builders only consult this for
* `scene:` refs.
*/
materials?: Record<SceneMaterialId, SceneMaterial>
/**
* Optional view state — only populated for `def.floorplan` builders. The
* 2D floor-plan layer surfaces selection / hover here so kinds can vary
@@ -1023,6 +1033,16 @@ export type Capabilities = {
*/
ceilingCut?: CeilingCutCapability
paint?: PaintCapability
/**
* Declares the kind's paintable slots — the `{ slotId, label, default }`
* contract shared by items (scanned from the GLB) and procedural kinds
* (declared here). Procedural generators tag their emitted geometry with
* `userData.slotId` and resolve each slot's material from
* `node.slots[slotId]` → this declaration's `default` → role colour. The
* declaration is a function of the node because a kind's slot set can depend
* on its parameters (a shelf has a `back` slot only when it has a back).
*/
slots?: (node: AnyNode) => SlotDeclaration[]
/**
* Kind is placed by clicking on a wall (door, window). When set, the
* floor-plan layer lets wall background clicks pass through during
@@ -1114,6 +1134,19 @@ export type Capabilities = {
* the `selectedMaterialTarget` round-trip, the paint-mode toolbar.
* Kinds with no paint behaviour omit `paint`.
*/
/**
* One paintable slot a kind exposes. `slotId` is the stable key written into
* `node.slots`; `label` is the human name (sentence case). `default` is the
* slot's fallback appearance when no override is set — either a `MaterialRef`
* (`library:<id>` / `scene:<id>`) or a `#rrggbb` colour. Mirrors the shape
* items derive from their GLB material names.
*/
export type SlotDeclaration = {
slotId: string
label: string
default?: string
}
export type PaintCapability = {
/**
* Resolve which logical surface the user clicked. Returns `null`