feat(paint-slots): authored item materials + unified slot painting

Phase 1 + paint unification of the paint-slots plan.

- core: scene-material data layer (materials map mirroring collections,
  undo/partialize/setScene full-graph support), SceneMaterial schema,
  scene:/library: MaterialRef helpers + parseMaterialRef, slot id helpers
  (deriveSlotId/slotLabelFromId), slots map on ItemNode, hitObject on
  PaintResolveArgs, optional PaintCapability.commit.
- viewer: resolveMaterialRef (library:/scene: -> three material, null on
  dangling).
- nodes(item): renderer keeps authored GLB materials for slot-authored
  assets and applies per-slot overrides per-instance (never mutates the
  shared cached GLB); textures-off still collapses to furnishing role;
  non-authored items unchanged. Item paint capability + registration.
- editor: item joins the unified (nodeId, slotId) paint dispatch; item
  paint target + slot reset.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-15 10:01:08 -04:00
co-authored by Claude Fable 5
parent cf24b62c44
commit 7afb286e47
16 changed files with 627 additions and 78 deletions
+27
View File
@@ -0,0 +1,27 @@
export const SLOT_MATERIAL_PREFIX = 'slot_'
/** A glTF material name marks a paintable slot when it starts with `slot_` (case-insensitive). */
export function isSlotMaterialName(name: string): boolean {
return name.toLowerCase().startsWith(SLOT_MATERIAL_PREFIX)
}
/**
* Derive the stable slot id from a glTF material name:
* strip the `slot_` prefix (case-insensitive), drop Blender numeric dedupe
* suffixes like `.001`, lowercase the remainder. Returns null when the name
* is not a slot material. Used by BOTH the upload scan (later) and the
* renderer so DB metadata and runtime meshes can never drift.
*/
export function deriveSlotId(materialName: string): string | null {
if (!isSlotMaterialName(materialName)) return null
let rest = materialName.slice(SLOT_MATERIAL_PREFIX.length)
rest = rest.replace(/\.\d+$/, '')
return rest.toLowerCase()
}
/** slot id -> display label: underscores to spaces, sentence case. e.g. 'bed_frame' -> 'Bed frame'. */
export function slotLabelFromId(slotId: string): string {
const spaced = slotId.replace(/_/g, ' ').trim()
if (!spaced) return spaced
return spaced.charAt(0).toUpperCase() + spaced.slice(1)
}