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:
co-authored by
Claude Fable 5
parent
cf24b62c44
commit
7afb286e47
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user