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
@@ -906,6 +906,7 @@ export const SelectionManager = () => {
normal: event.normal,
localPosition: event.localPosition as readonly [number, number, number] | undefined,
hitObjectName: event.nativeEvent.object?.name,
hitObject: getEventObject(event),
})
const compatible = role !== null && paintEnabled
return {
@@ -915,15 +916,22 @@ export const SelectionManager = () => {
apply:
compatible && role
? () => {
useScene.getState().updateNode(
node.id as AnyNodeId,
paintCap.buildPatch({
node,
role,
material: paintSpec.material,
materialPreset: paintSpec.materialPreset,
}) as Partial<AnyNode>,
)
const args = {
node,
role,
material: paintSpec.material,
materialPreset: paintSpec.materialPreset,
}
if (paintCap.commit) {
paintCap.commit(args)
} else {
useScene
.getState()
.updateNode(
node.id as AnyNodeId,
paintCap.buildPatch(args) as Partial<AnyNode>,
)
}
}
: null,
preview:
@@ -1086,7 +1094,7 @@ export const SelectionManager = () => {
}
}
const disabledNodeTypes = ['item', 'window', 'door', 'zone']
const disabledNodeTypes = ['window', 'door', 'zone']
if (disabledNodeTypes.includes(node.type)) {
return {
key: `${node.type}:${node.id}:unsupported`,
@@ -1549,6 +1557,7 @@ export const SelectionManager = () => {
normal: event.normal,
localPosition: event.localPosition as readonly [number, number, number] | undefined,
hitObjectName: event.nativeEvent.object?.name,
hitObject: getEventObject(event),
})
if (role) {
setSelectedMaterialTargetForNode(nodeToSelect, role as MaterialTargetRole)
@@ -1930,7 +1939,8 @@ const SelectionStateSync = () => {
const selectedNode = useScene.getState().nodes[singleSelectedId as AnyNodeId]
if (
!selectedNode ||
(selectedNode.type !== 'wall' &&
(!nodeRegistry.get(selectedNode.type)?.capabilities?.paint &&
selectedNode.type !== 'wall' &&
selectedNode.type !== 'fence' &&
selectedNode.type !== 'slab' &&
selectedNode.type !== 'ceiling' &&
+26 -18
View File
@@ -30,24 +30,26 @@ import {
type WallSurfaceSide,
} from '@pascal-app/core'
export type PaintableMaterialTarget = Extract<
MaterialTarget,
| 'wall'
| 'roof'
| 'stair'
| 'fence'
| 'column'
| 'slab'
| 'ceiling'
| 'shelf'
| 'chimney'
| 'dormer'
| 'box-vent'
| 'ridge-vent'
| 'turbine-vent'
| 'cupola'
| 'eyebrow-vent'
>
export type PaintableMaterialTarget =
| Extract<
MaterialTarget,
| 'wall'
| 'roof'
| 'stair'
| 'fence'
| 'column'
| 'slab'
| 'ceiling'
| 'shelf'
| 'chimney'
| 'dormer'
| 'box-vent'
| 'ridge-vent'
| 'turbine-vent'
| 'cupola'
| 'eyebrow-vent'
>
| 'item'
export type SingleSurfaceMaterialRole = 'surface'
@@ -179,6 +181,7 @@ export function buildResetSurfaceMaterialUpdates(
if (
key === 'material' ||
key === 'materialPreset' ||
key === 'slots' ||
key.endsWith('Material') ||
key.endsWith('MaterialPreset')
) {
@@ -276,6 +279,7 @@ export function resolveActivePaintMaterialFromSelection(params: {
| ChimneyMaterialRole
| DormerSurfaceMaterialRole
| SingleSurfaceMaterialRole
| string
} | null
}): ActivePaintMaterial | null {
const { nodes, selectedId, selectedMaterialTarget } = params
@@ -444,6 +448,10 @@ export function resolvePaintTargetFromSelection(params: {
return 'shelf'
}
if (selectedNode.type === 'item') {
return 'item'
}
if (selectedNode.type === 'chimney') {
return 'chimney'
}
+1
View File
@@ -168,6 +168,7 @@ export type MaterialTargetRole =
| ChimneyMaterialRole
| DormerSurfaceMaterialRole
| SingleSurfaceMaterialRole
| string
export type SelectedMaterialTarget = {
nodeId: AnyNodeId