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
+12
View File
@@ -72,6 +72,12 @@ export {
segmentsIntersect,
} from './lib/polygon-relations'
export { getRenderableSlabPolygon } from './lib/slab-polygon'
export {
deriveSlotId,
isSlotMaterialName,
SLOT_MATERIAL_PREFIX,
slotLabelFromId,
} from './lib/slots'
export {
type AutoCeilingPlanningContext,
type AutoCeilingSyncPlan,
@@ -92,12 +98,18 @@ export {
getLibraryMaterialIdFromRef,
getMaterialPresetByRef,
getMaterialsForCategory,
getSceneMaterialIdFromRef,
LIBRARY_MATERIAL_REF_PREFIX,
MATERIAL_CATALOG,
MATERIAL_CATEGORIES,
type MaterialCatalogItem,
type MaterialCategory,
type MaterialRef,
type ParsedMaterialRef,
parseMaterialRef,
SCENE_MATERIAL_REF_PREFIX,
toLibraryMaterialRef,
toSceneMaterialRef,
} from './material-library'
export type {
FloorPlacedFootprint,
+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)
}
+22
View File
@@ -2423,17 +2423,39 @@ export function getCatalogMaterialById(id?: string): MaterialCatalogItem | undef
}
export const LIBRARY_MATERIAL_REF_PREFIX = 'library:'
export const SCENE_MATERIAL_REF_PREFIX = 'scene:'
export function toLibraryMaterialRef(id: string) {
return `${LIBRARY_MATERIAL_REF_PREFIX}${id}`
}
export function toSceneMaterialRef(id: string) {
return `${SCENE_MATERIAL_REF_PREFIX}${id}`
}
export function getLibraryMaterialIdFromRef(materialRef?: string | null) {
if (!materialRef) return null
if (!materialRef.startsWith(LIBRARY_MATERIAL_REF_PREFIX)) return null
return materialRef.slice(LIBRARY_MATERIAL_REF_PREFIX.length)
}
export function getSceneMaterialIdFromRef(materialRef?: string | null): string | null {
if (!materialRef || !materialRef.startsWith(SCENE_MATERIAL_REF_PREFIX)) return null
return materialRef.slice(SCENE_MATERIAL_REF_PREFIX.length)
}
export type MaterialRef = string
export type ParsedMaterialRef = { kind: 'library'; id: string } | { kind: 'scene'; id: string }
export function parseMaterialRef(ref?: string | null): ParsedMaterialRef | null {
const lib = getLibraryMaterialIdFromRef(ref)
if (lib) return { kind: 'library', id: lib }
const scene = getSceneMaterialIdFromRef(ref)
if (scene) return { kind: 'scene', id: scene }
return null
}
export function getMaterialPresetByRef(materialRef?: string | null): MaterialPresetPayload | null {
const materialId = getLibraryMaterialIdFromRef(materialRef)
if (!materialId) return null
+10
View File
@@ -1126,6 +1126,14 @@ export type PaintCapability = {
* `role`. Returned partial is merged into the node by the editor.
*/
buildPatch: (args: PaintPatchArgs) => Partial<AnyNode>
/**
* Optional: fully own the click-commit instead of the default
* `updateNode(node.id, buildPatch(...))`. Kinds whose commit has a side
* effect (items create a scene material for one-off colours, then store a
* `scene:<id>` ref) implement this; kinds that just patch the node omit it.
* Must perform its mutations as a single undo step.
*/
commit?: (args: PaintPatchArgs) => void
/**
* Apply a preview to the kind's registered mesh subtree at
* `role`. The kind builds whatever preview material(s) it needs
@@ -1169,6 +1177,8 @@ export type PaintResolveArgs = {
localPosition?: readonly [number, number, number]
/** Optional: name of the three.js object that received the hit. Stair uses this. */
hitObjectName?: string
/** Optional: the three.js object that received the pointer hit. Items read userData.slotId off it. */
hitObject?: Object3D
}
export type PaintPatchArgs = {
+1
View File
@@ -158,6 +158,7 @@ export {
} from './nodes/wall'
export { WindowNode, WindowType } from './nodes/window'
export { ZoneNode } from './nodes/zone'
export { generateSceneMaterialId, SceneMaterial, type SceneMaterialId } from './scene-material'
export type { AnyNodeId, AnyNodeType } from './types'
// Union types
export { AnyNode } from './types'
+5
View File
@@ -146,6 +146,11 @@ export const ItemNode = BaseNode.extend({
// Denormalized references to collections this node belongs to
collectionIds: z.array(z.custom<CollectionId>()).optional(),
// Per-slot material overrides. Key = slot id (see deriveSlotId), value = a
// MaterialRef string ('library:<id>' or 'scene:<id>'). Absent = authored /
// registry default. A dangling ref renders the default (never blocks).
slots: z.record(z.string(), z.string()).optional(),
asset: assetSchema,
}).describe(dedent`Item node - used to represent a item in the building
- position: position in level coordinate system (or parent coordinate system if attached)
@@ -0,0 +1,13 @@
import { z } from 'zod'
import { generateId } from './base'
import { MaterialSchema } from './material'
export type SceneMaterialId = `mat_${string}`
export const generateSceneMaterialId = (): SceneMaterialId => generateId('mat')
export const SceneMaterial = z.object({
id: z.string(),
name: z.string(),
material: MaterialSchema,
})
export type SceneMaterial = z.infer<typeof SceneMaterial>
+54 -7
View File
@@ -20,6 +20,7 @@ import { ShelfNode as ShelfNodeSchema } from '../schema/nodes/shelf'
import { SiteNode } from '../schema/nodes/site'
import { StairNode as StairNodeSchema } from '../schema/nodes/stair'
import { StairSegmentNode as StairSegmentNodeSchema } from '../schema/nodes/stair-segment'
import type { SceneMaterial, SceneMaterialId } from '../schema/scene-material'
import type { AnyNode, AnyNodeId } from '../schema/types'
import * as nodeActions from './actions/node-actions'
import { resetSceneHistoryPauseDepth } from './history-control'
@@ -694,6 +695,7 @@ export type SceneState = {
// 4. Relational metadata — not nodes
collections: Record<CollectionId, Collection>
materials: Record<SceneMaterialId, SceneMaterial>
// 5. Read-only lock — when true all create/update/delete operations are no-ops
readOnly: boolean
@@ -703,7 +705,14 @@ export type SceneState = {
loadScene: () => void
clearScene: () => void
unloadScene: () => void
setScene: (nodes: Record<AnyNodeId, AnyNode>, rootNodeIds: AnyNodeId[]) => void
setScene: (
nodes: Record<AnyNodeId, AnyNode>,
rootNodeIds: AnyNodeId[],
extra?: {
collections?: Record<CollectionId, Collection>
materials?: Record<SceneMaterialId, SceneMaterial>
},
) => void
markDirty: (id: AnyNodeId) => void
clearDirty: (id: AnyNodeId) => void
@@ -728,12 +737,19 @@ export type SceneState = {
updateCollection: (id: CollectionId, data: Partial<Omit<Collection, 'id'>>) => void
addToCollection: (id: CollectionId, nodeId: AnyNodeId) => void
removeFromCollection: (id: CollectionId, nodeId: AnyNodeId) => void
// Scene material actions
addSceneMaterial: (material: SceneMaterial) => void
updateSceneMaterial: (id: SceneMaterialId, data: Partial<Omit<SceneMaterial, 'id'>>) => void
removeSceneMaterial: (id: SceneMaterialId) => void
}
// type PartializedStoreState = Pick<SceneState, 'rootNodeIds' | 'nodes'>;
type UseSceneStore = UseBoundStore<StoreApi<SceneState>> & {
temporal: StoreApi<TemporalState<Pick<SceneState, 'nodes' | 'rootNodeIds' | 'collections'>>>
temporal: StoreApi<
TemporalState<Pick<SceneState, 'nodes' | 'rootNodeIds' | 'collections' | 'materials'>>
>
}
const useScene: UseSceneStore = create<SceneState>()(
@@ -750,6 +766,7 @@ const useScene: UseSceneStore = create<SceneState>()(
// 4. Collections
collections: {} as Record<CollectionId, Collection>,
materials: {} as Record<SceneMaterialId, SceneMaterial>,
// 5. Read-only lock
readOnly: false,
@@ -761,6 +778,7 @@ const useScene: UseSceneStore = create<SceneState>()(
rootNodeIds: [],
dirtyNodes: new Set<AnyNodeId>(),
collections: {},
materials: {},
})
},
@@ -769,7 +787,7 @@ const useScene: UseSceneStore = create<SceneState>()(
get().loadScene() // Default scene
},
setScene: (nodes, rootNodeIds) => {
setScene: (nodes, rootNodeIds, extra) => {
// Apply backward compatibility migrations
const patchedNodes = migrateNodes(nodes)
@@ -792,7 +810,8 @@ const useScene: UseSceneStore = create<SceneState>()(
nodes: cleanedNodes,
rootNodeIds,
dirtyNodes: new Set<AnyNodeId>(),
collections: {},
collections: extra?.collections ?? {},
materials: extra?.materials ?? {},
})
const normalizedRootNodeIds = normalizeRootNodeIds(cleanedNodes, rootNodeIds)
@@ -809,7 +828,8 @@ const useScene: UseSceneStore = create<SceneState>()(
nodes: cleanedNodes,
rootNodeIds: normalizedRootNodeIds,
dirtyNodes: new Set<AnyNodeId>(),
collections: {},
collections: extra?.collections ?? {},
materials: extra?.materials ?? {},
})
// Mark all nodes as dirty to trigger re-validation
Object.values(cleanedNodes).forEach((node) => {
@@ -969,11 +989,38 @@ const useScene: UseSceneStore = create<SceneState>()(
return { collections: nextCollections, nodes: nextNodes }
})
},
// --- SCENE MATERIALS ---
addSceneMaterial: (material) => {
if (get().readOnly) return
set((state) => ({
materials: { ...state.materials, [material.id]: material },
}))
},
updateSceneMaterial: (id, data) => {
if (get().readOnly) return
set((state) => {
const material = state.materials[id]
if (!material) return state
return { materials: { ...state.materials, [id]: { ...material, ...data } } }
})
},
removeSceneMaterial: (id) => {
if (get().readOnly) return
set((state) => {
const materials = { ...state.materials }
delete materials[id]
return { materials }
})
},
}),
{
partialize: (state) => {
const { nodes, rootNodeIds, collections } = state
return { nodes, rootNodeIds, collections }
const { nodes, rootNodeIds, collections, materials } = state
return { nodes, rootNodeIds, collections, materials }
},
limit: 50, // Limit to last 50 actions
},