From 7afb286e4787947733d306f3597cb42e5106b551 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Mon, 15 Jun 2026 10:01:08 -0400 Subject: [PATCH] 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 --- packages/core/src/index.ts | 12 + packages/core/src/lib/slots.ts | 27 ++ packages/core/src/material-library.ts | 22 ++ packages/core/src/registry/types.ts | 10 + packages/core/src/schema/index.ts | 1 + packages/core/src/schema/nodes/item.ts | 5 + packages/core/src/schema/scene-material.ts | 13 + packages/core/src/store/use-scene.ts | 61 ++++- .../components/editor/selection-manager.tsx | 32 ++- packages/editor/src/lib/material-paint.ts | 44 ++-- packages/editor/src/store/use-editor.tsx | 1 + packages/nodes/src/item/definition.ts | 2 + packages/nodes/src/item/paint.ts | 249 ++++++++++++++++++ packages/nodes/src/item/renderer.tsx | 204 +++++++++++--- packages/viewer/src/index.ts | 1 + packages/viewer/src/lib/materials.ts | 21 ++ 16 files changed, 627 insertions(+), 78 deletions(-) create mode 100644 packages/core/src/lib/slots.ts create mode 100644 packages/core/src/schema/scene-material.ts create mode 100644 packages/nodes/src/item/paint.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b6ff26f8..23d2d78b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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, diff --git a/packages/core/src/lib/slots.ts b/packages/core/src/lib/slots.ts new file mode 100644 index 00000000..3b3db329 --- /dev/null +++ b/packages/core/src/lib/slots.ts @@ -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) +} diff --git a/packages/core/src/material-library.ts b/packages/core/src/material-library.ts index d97f3b60..aa6f7e81 100644 --- a/packages/core/src/material-library.ts +++ b/packages/core/src/material-library.ts @@ -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 diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 03cbd617..b007bf27 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -1126,6 +1126,14 @@ export type PaintCapability = { * `role`. Returned partial is merged into the node by the editor. */ buildPatch: (args: PaintPatchArgs) => Partial + /** + * 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:` 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 = { diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 60823687..f0baa8db 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -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' diff --git a/packages/core/src/schema/nodes/item.ts b/packages/core/src/schema/nodes/item.ts index c03aa4b8..f8732482 100644 --- a/packages/core/src/schema/nodes/item.ts +++ b/packages/core/src/schema/nodes/item.ts @@ -146,6 +146,11 @@ export const ItemNode = BaseNode.extend({ // Denormalized references to collections this node belongs to collectionIds: z.array(z.custom()).optional(), + // Per-slot material overrides. Key = slot id (see deriveSlotId), value = a + // MaterialRef string ('library:' or 'scene:'). 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) diff --git a/packages/core/src/schema/scene-material.ts b/packages/core/src/schema/scene-material.ts new file mode 100644 index 00000000..4b1bbd3f --- /dev/null +++ b/packages/core/src/schema/scene-material.ts @@ -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 diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index b3ac8c9b..daaf4f26 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -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 + materials: Record // 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, rootNodeIds: AnyNodeId[]) => void + setScene: ( + nodes: Record, + rootNodeIds: AnyNodeId[], + extra?: { + collections?: Record + materials?: Record + }, + ) => void markDirty: (id: AnyNodeId) => void clearDirty: (id: AnyNodeId) => void @@ -728,12 +737,19 @@ export type SceneState = { updateCollection: (id: CollectionId, data: Partial>) => 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>) => void + removeSceneMaterial: (id: SceneMaterialId) => void } // type PartializedStoreState = Pick; type UseSceneStore = UseBoundStore> & { - temporal: StoreApi>> + temporal: StoreApi< + TemporalState> + > } const useScene: UseSceneStore = create()( @@ -750,6 +766,7 @@ const useScene: UseSceneStore = create()( // 4. Collections collections: {} as Record, + materials: {} as Record, // 5. Read-only lock readOnly: false, @@ -761,6 +778,7 @@ const useScene: UseSceneStore = create()( rootNodeIds: [], dirtyNodes: new Set(), collections: {}, + materials: {}, }) }, @@ -769,7 +787,7 @@ const useScene: UseSceneStore = create()( 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()( nodes: cleanedNodes, rootNodeIds, dirtyNodes: new Set(), - collections: {}, + collections: extra?.collections ?? {}, + materials: extra?.materials ?? {}, }) const normalizedRootNodeIds = normalizeRootNodeIds(cleanedNodes, rootNodeIds) @@ -809,7 +828,8 @@ const useScene: UseSceneStore = create()( nodes: cleanedNodes, rootNodeIds: normalizedRootNodeIds, dirtyNodes: new Set(), - 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()( 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 }, diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index ace508cc..b1a3e50a 100644 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -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, - ) + 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, + ) + } } : 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' && diff --git a/packages/editor/src/lib/material-paint.ts b/packages/editor/src/lib/material-paint.ts index fbd59271..bd672f15 100644 --- a/packages/editor/src/lib/material-paint.ts +++ b/packages/editor/src/lib/material-paint.ts @@ -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' } diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index d4097598..50008876 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -168,6 +168,7 @@ export type MaterialTargetRole = | ChimneyMaterialRole | DormerSurfaceMaterialRole | SingleSurfaceMaterialRole + | string export type SelectedMaterialTarget = { nodeId: AnyNodeId diff --git a/packages/nodes/src/item/definition.ts b/packages/nodes/src/item/definition.ts index 9224d701..34255675 100644 --- a/packages/nodes/src/item/definition.ts +++ b/packages/nodes/src/item/definition.ts @@ -7,6 +7,7 @@ import { } from '@pascal-app/core' import { buildItemFloorplan } from './floorplan' import { itemFloorplanMoveTarget } from './floorplan-move' +import { itemPaint } from './paint' import { itemParametrics } from './parametrics' import { ItemNode } from './schema' @@ -199,6 +200,7 @@ export const itemDefinition: NodeDefinition = { selectable: { hitVolume: 'bbox' }, duplicable: true, deletable: true, + paint: itemPaint, // Items participate in compositions — e.g. "table-with-plants", // "shelf-with-books-on-top" — so they're presettable in their own // right (and as descendants of presettable parents). The GLB-kind diff --git a/packages/nodes/src/item/paint.ts b/packages/nodes/src/item/paint.ts new file mode 100644 index 00000000..f4ad1f6e --- /dev/null +++ b/packages/nodes/src/item/paint.ts @@ -0,0 +1,249 @@ +import { + type AnyNode, + type AnyNodeId, + generateSceneMaterialId, + type ItemNode, + type MaterialSchema, + type PaintCapability, + parseMaterialRef, + type SceneMaterial, + type SceneMaterialId, + toSceneMaterialRef, + useScene, +} from '@pascal-app/core' +import { createMaterial, createMaterialFromPresetRef, useViewer } from '@pascal-app/viewer' +import type { Material, Mesh } from 'three' + +type SlotTag = string | null | (string | null)[] + +type SlotUserData = { + slotId?: SlotTag +} + +function deepEqual(a: unknown, b: unknown): boolean { + if (Object.is(a, b)) return true + if (typeof a !== typeof b) return false + if (a === null || b === null) return false + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false + for (let index = 0; index < a.length; index += 1) { + if (!deepEqual(a[index], b[index])) return false + } + return true + } + if (typeof a === 'object') { + const aRecord = a as Record + const bRecord = b as Record + const aKeys = Object.keys(aRecord) + const bKeys = Object.keys(bRecord) + if (aKeys.length !== bKeys.length) return false + for (const key of aKeys) { + if (!Object.hasOwn(bRecord, key)) return false + if (!deepEqual(aRecord[key], bRecord[key])) return false + } + return true + } + return false +} + +function getSlotTag(mesh: Mesh): SlotTag | undefined { + return (mesh.userData as SlotUserData).slotId +} + +function slotTagContainsRole(tag: SlotTag | undefined, role: string): boolean { + if (Array.isArray(tag)) return tag.includes(role) + return tag === role +} + +function resolveItemSlotId(args: { + materialIndex: number | null + hitObject?: { userData?: SlotUserData } +}): string | null { + const tag = args.hitObject?.userData?.slotId + const slotId = Array.isArray(tag) + ? (tag[args.materialIndex ?? 0] ?? null) + : typeof tag === 'string' + ? tag + : null + return slotId +} + +function buildItemSlotsPatch( + node: ItemNode, + role: string, + material: MaterialSchema | undefined, + materialPreset: string | undefined, +): Partial { + const slots = { ...(node.slots ?? {}) } + if (material === undefined && materialPreset === undefined) { + delete slots[role] + return { slots } + } + if (materialPreset) { + slots[role] = materialPreset + return { slots } + } + return { slots } +} + +function findMatchingSceneMaterial( + materials: Record, + material: MaterialSchema, +): SceneMaterial | null { + for (const sceneMaterial of Object.values(materials)) { + if (deepEqual(sceneMaterial.material, material)) return sceneMaterial + } + return null +} + +function commitNewSceneMaterialAndSlots( + nodeId: AnyNodeId, + nextSlots: ItemNode['slots'], + sceneMaterial: SceneMaterial, +): void { + // Creating the scene material and setting the slot ref are one logical + // edit, so apply both in a single `set` — zundo records one history entry, + // and one undo removes both the ref and its (now orphaned) material. + useScene.setState((state) => { + if (state.readOnly) return state + const currentNode = state.nodes[nodeId] + if (!currentNode || currentNode.type !== 'item') return state + return { + materials: { ...state.materials, [sceneMaterial.id as SceneMaterialId]: sceneMaterial }, + nodes: { + ...state.nodes, + [nodeId]: { ...currentNode, slots: nextSlots } as AnyNode, + }, + } + }) + useScene.getState().markDirty(nodeId) +} + +function commitItemPaint( + node: ItemNode, + role: string, + material: MaterialSchema | undefined, + materialPreset: string | undefined, +): void { + const nodeId = node.id as AnyNodeId + const state = useScene.getState() + const currentNode = (state.nodes[nodeId] as ItemNode | undefined) ?? node + let ref: string | undefined + let newSceneMaterial: SceneMaterial | null = null + + if (material === undefined && materialPreset === undefined) { + ref = undefined + } else if (materialPreset) { + ref = materialPreset + } else if (material) { + const existing = findMatchingSceneMaterial(state.materials, material) + if (existing) { + ref = toSceneMaterialRef(existing.id) + } else { + const id = generateSceneMaterialId() + newSceneMaterial = { + id, + name: `Material ${Object.keys(state.materials).length + 1}`, + material, + } + ref = toSceneMaterialRef(id) + } + } else { + return + } + + const nextSlots = { ...(currentNode.slots ?? {}) } + if (ref) nextSlots[role] = ref + else delete nextSlots[role] + + if (newSceneMaterial) { + commitNewSceneMaterialAndSlots(nodeId, nextSlots, newSceneMaterial) + return + } + + state.updateNode(nodeId, { slots: nextSlots } as Partial) +} + +function buildPreviewMaterial( + material: MaterialSchema | undefined, + materialPreset: string | undefined, +): Material | null { + const shading = useViewer.getState().shading + if (materialPreset) return createMaterialFromPresetRef(materialPreset, shading) + if (material) return createMaterial(material, shading) + return null +} + +function applyItemPreview( + role: string, + root: import('three').Object3D, + material: MaterialSchema | undefined, + materialPreset: string | undefined, +): (() => void) | null { + const previewMaterial = buildPreviewMaterial(material, materialPreset) + if (!previewMaterial) return () => {} + + const restores: Array<() => void> = [] + root.traverse((object) => { + const mesh = object as Mesh + if (!mesh.isMesh) return + const tag = getSlotTag(mesh) + if (!slotTagContainsRole(tag, role)) return + + if (Array.isArray(tag)) { + const current = mesh.material as Material | Material[] + if (Array.isArray(current)) { + const previousArray = [...current] + const nextArray = [...current] + let changed = false + for (let index = 0; index < tag.length; index += 1) { + if (tag[index] !== role || !nextArray[index]) continue + nextArray[index] = previewMaterial + changed = true + } + if (!changed) return + mesh.material = nextArray + restores.push(() => { + mesh.material = previousArray + }) + return + } + if (tag[0] !== role) return + } + + const previous = mesh.material + mesh.material = previewMaterial + restores.push(() => { + mesh.material = previous + }) + }) + + if (restores.length === 0) return null + return () => { + for (let index = restores.length - 1; index >= 0; index -= 1) { + restores[index]?.() + } + } +} + +export const itemPaint: PaintCapability = { + resolveRole: ({ materialIndex, hitObject }) => + resolveItemSlotId({ materialIndex, hitObject: hitObject as { userData?: SlotUserData } }), + buildPatch: ({ node, role, material, materialPreset }) => + buildItemSlotsPatch(node as ItemNode, role, material, materialPreset) as Partial, + commit: ({ node, role, material, materialPreset }) => + commitItemPaint(node as ItemNode, role, material, materialPreset), + applyPreview: ({ role, root, material, materialPreset }) => + applyItemPreview(role, root, material, materialPreset), + getEffectiveMaterial: ({ node, role }) => { + const ref = (node as ItemNode).slots?.[role] + const parsed = parseMaterialRef(ref) + if (!parsed) return null + if (parsed.kind === 'library') { + return { material: undefined, materialPreset: ref } + } + const sceneMaterial = useScene.getState().materials[parsed.id as SceneMaterialId] + if (!sceneMaterial) return null + return { material: sceneMaterial.material, materialPreset: undefined } + }, +} diff --git a/packages/nodes/src/item/renderer.tsx b/packages/nodes/src/item/renderer.tsx index 2c4adf6c..a84f8bab 100644 --- a/packages/nodes/src/item/renderer.tsx +++ b/packages/nodes/src/item/renderer.tsx @@ -3,9 +3,11 @@ import { type AnimationEffect, type AnyNodeId, + deriveSlotId, getScaledDimensions, type Interactive, type ItemNode, + isSlotMaterialName, type LightEffect, useInteractive, useLiveNodeOverrides, @@ -22,6 +24,7 @@ import { NodeRenderer, type RenderShading, resolveCdnUrl, + resolveMaterialRef, useItemLightPool, useNodeEvents, useViewer, @@ -30,7 +33,7 @@ import { useAnimations } from '@react-three/drei' import { Clone } from '@react-three/drei/core/Clone' import { useGLTF } from '@react-three/drei/core/Gltf' import { useFrame } from '@react-three/fiber' -import { Suspense, useEffect, useMemo, useRef } from 'react' +import { Suspense, useEffect, useLayoutEffect, useMemo, useRef } from 'react' import type { AnimationAction, Group, Material, Mesh } from 'three' import { MathUtils } from 'three' import { positionLocal, smoothstep, time } from 'three/tsl' @@ -44,16 +47,117 @@ type MutableMaterial = Material & { wireframe?: boolean } -const getMaterialForOriginal = ( - original: Material, - shading: RenderShading, - textures: boolean, - colorPreset: ColorPreset, -): Material => { - if (original.name.toLowerCase() === 'glass') { - return glassMaterial +type CapturedSingleItemMaterialData = { + captured: true + authoredMaterials: Material + slotIds: string | null +} + +type CapturedMultiItemMaterialData = { + captured: true + authoredMaterials: Material[] + slotIds: (string | null)[] +} + +type CapturedItemMaterialData = CapturedSingleItemMaterialData | CapturedMultiItemMaterialData + +type ItemMeshUserData = Mesh['userData'] & { + pascalItemMaterialCapture?: CapturedItemMaterialData + slotId?: string | null | (string | null)[] +} + +type SceneMaterials = ReturnType['materials'] + +const getAuthoredSlotId = (material: Material): string | null => + isSlotMaterialName(material.name) ? deriveSlotId(material.name) : null + +const captureItemMeshMaterials = (mesh: Mesh): CapturedItemMaterialData => { + const userData = mesh.userData as ItemMeshUserData + const captured = userData.pascalItemMaterialCapture + if (captured?.captured) { + userData.slotId = captured.slotIds + return captured } + + if (Array.isArray(mesh.material)) { + const authoredMaterials = mesh.material.slice() + const slotIds = authoredMaterials.map(getAuthoredSlotId) + const next: CapturedItemMaterialData = { + captured: true, + authoredMaterials, + slotIds, + } + userData.pascalItemMaterialCapture = next + userData.slotId = slotIds + return next + } + + const slotId = getAuthoredSlotId(mesh.material) + const next: CapturedItemMaterialData = { + captured: true, + authoredMaterials: mesh.material, + slotIds: slotId, + } + userData.pascalItemMaterialCapture = next + userData.slotId = slotId + return next +} + +const hasCapturedSlot = (captured: CapturedItemMaterialData): boolean => + Array.isArray(captured.slotIds) + ? captured.slotIds.some((slotId) => slotId != null) + : captured.slotIds != null + +const isCapturedMaterialArray = ( + captured: CapturedItemMaterialData, +): captured is CapturedMultiItemMaterialData => Array.isArray(captured.authoredMaterials) + +const isGlassMaterial = (material: Material): boolean => + material === glassMaterial || material.name.toLowerCase() === 'glass' + +const clampGeometryGroups = (mesh: Mesh, matCount: number): void => { + if (mesh.geometry.groups.length === 0) return + + const needsClamp = mesh.geometry.groups.some( + (group) => group.materialIndex !== undefined && group.materialIndex >= matCount, + ) + if (!needsClamp) return + + mesh.geometry = mesh.geometry.clone() + for (const group of mesh.geometry.groups) { + if (group.materialIndex !== undefined && group.materialIndex >= matCount) { + group.materialIndex = 0 + } + } +} + +const resolveItemMaterial = ( + authoredMaterial: Material, + slotId: string | null, + { + colorPreset, + isAuthored, + nodeSlots, + sceneMaterials, + shading, + textures, + }: { + colorPreset: ColorPreset + isAuthored: boolean + nodeSlots: ItemNode['slots'] + sceneMaterials: SceneMaterials + shading: RenderShading + textures: boolean + }, +): Material => { if (!textures) return createSurfaceRoleMaterial('furnishing', colorPreset) + if (authoredMaterial.name.toLowerCase() === 'glass') return glassMaterial + if (slotId != null) { + const override = resolveMaterialRef(nodeSlots?.[slotId], sceneMaterials, shading) + if (override) return override + return authoredMaterial + } + if (isAuthored) return authoredMaterial return baseMaterial(shading) } @@ -182,6 +286,7 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => { const shading = useViewer((s) => s.shading) const textures = useViewer((s) => s.textures) const colorPreset = useViewer((s) => s.colorPreset) + const sceneMaterials = useScene((s) => s.materials) // Freeze the interactive definition at mount — asset schemas don't change at runtime const interactiveRef = useRef(node.asset.interactive) @@ -203,44 +308,59 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => { return () => useInteractive.getState().removeItem(node.id) }, [node.id]) - useMemo(() => { - scene.traverse((child) => { - if ((child as Mesh).isMesh) { - const mesh = child as Mesh - if (mesh.name === 'cutout') { - child.visible = false - return - } + useLayoutEffect(() => { + const root = ref.current + if (!root) return - let hasGlass = false + const meshEntries: { mesh: Mesh; captured: CapturedItemMaterialData }[] = [] + let isAuthored = false - // Handle both single material and material array cases - if (Array.isArray(mesh.material)) { - mesh.material = mesh.material.map((mat) => - getMaterialForOriginal(mat, shading, textures, colorPreset), - ) - hasGlass = mesh.material.some((mat) => mat.name === 'glass') + root.traverse((child) => { + if (!(child as Mesh).isMesh) return - // Fix geometry groups that reference materialIndex beyond the material - // array length — this causes three-mesh-bvh to crash with - // "Cannot read properties of undefined (reading 'side')" - const matCount = mesh.material.length - if (mesh.geometry.groups.length > 0) { - for (const group of mesh.geometry.groups) { - if (group.materialIndex !== undefined && group.materialIndex >= matCount) { - group.materialIndex = 0 - } - } - } - } else { - mesh.material = getMaterialForOriginal(mesh.material, shading, textures, colorPreset) - hasGlass = mesh.material.name === 'glass' - } - mesh.castShadow = !hasGlass - mesh.receiveShadow = !hasGlass + const mesh = child as Mesh + if (mesh.name === 'cutout') { + child.visible = false } + + const captured = captureItemMeshMaterials(mesh) + if (hasCapturedSlot(captured)) isAuthored = true + if (mesh.name !== 'cutout') meshEntries.push({ mesh, captured }) }) - }, [scene, shading, textures, colorPreset]) + + const materialOptions = { + colorPreset, + isAuthored, + nodeSlots: node.slots, + sceneMaterials, + shading, + textures, + } + + for (const { mesh, captured } of meshEntries) { + let hasGlass = false + + if (isCapturedMaterialArray(captured)) { + const nextMaterials = captured.authoredMaterials.map((authoredMaterial, index) => + resolveItemMaterial(authoredMaterial, captured.slotIds[index] ?? null, materialOptions), + ) + mesh.material = nextMaterials + hasGlass = nextMaterials.some(isGlassMaterial) + clampGeometryGroups(mesh, nextMaterials.length) + } else { + const nextMaterial = resolveItemMaterial( + captured.authoredMaterials, + captured.slotIds, + materialOptions, + ) + mesh.material = nextMaterial + hasGlass = isGlassMaterial(nextMaterial) + } + + mesh.castShadow = !hasGlass + mesh.receiveShadow = !hasGlass + } + }, [ref, scene, shading, textures, colorPreset, node.slots, sceneMaterials]) const interactive = interactiveRef.current const animEffect = diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 158e4036..f8e55fa3 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -68,6 +68,7 @@ export { MONO_PALETTE, PRESET_PALETTES, type RenderShading, + resolveMaterialRef, resolveSurfaceColor, WHITE_PALETTE, } from './lib/materials' diff --git a/packages/viewer/src/lib/materials.ts b/packages/viewer/src/lib/materials.ts index 5229c603..e607f914 100644 --- a/packages/viewer/src/lib/materials.ts +++ b/packages/viewer/src/lib/materials.ts @@ -4,7 +4,10 @@ import { type MaterialPresetPayload, type MaterialProperties, type MaterialSchema, + parseMaterialRef, resolveMaterial, + type SceneMaterial, + type SceneMaterialId, type SurfaceRole, } from '@pascal-app/core' import * as THREE from 'three' @@ -486,6 +489,24 @@ export function createMaterial( return threeMaterial } +/** + * Resolve a MaterialRef ('library:' | 'scene:') to a three.js material. + * Returns null for an unknown / dangling ref so callers fall back to the + * slot's default (authored material, then themed default). Never throws. + */ +export function resolveMaterialRef( + ref: string | undefined, + sceneMaterials: Record | undefined, + shading: RenderShading = 'rendered', +): THREE.Material | null { + const parsed = parseMaterialRef(ref) + if (!parsed) return null + if (parsed.kind === 'library') return createMaterialFromPresetRef(ref, shading) + const sceneMaterial = sceneMaterials?.[parsed.id as SceneMaterialId] + if (!sceneMaterial) return null + return createMaterial(sceneMaterial.material, shading) +} + export function createDefaultMaterial( color = '#ffffff', roughness = 0.9,