From c6423e7c73ed5e14caf1aa21ce61cbf72e904bcd Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Thu, 18 Jun 2026 10:12:53 -0400 Subject: [PATCH] feat(paint-slots): migrate wall + procedural kinds onto node.slots Retire the inline material fields on every slot-model kind, moving them onto the unified node.slots model on load so painting, edit-propagation, and the picker behave uniformly. Walls: slots {interior,exterior}; slot-first viewer resolution threading sceneMaterials (content folded into the wall material hash); WallRenderer subscribes to the scene-material palette so a scene-material edit re-renders live; wallPaint rebuilt on createSlotPaintCapability. Load migration generalizes legacy -> slots across slab/ceiling (surface), fence (posts/infill/base/rail), column (shaft/base/capital/frame), shelf (shelves/frame/back), and stair (per-role tread/side/railing). Library/ scene refs pass through; inline customs mint a deduped scene material; legacy fields cleared. No visual change (renderers already fell back to the legacy fields). Roof/chimney/dormer/vents intentionally stay on their role system. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/schema/nodes/wall.ts | 7 + .../use-scene-wall-slot-migration.test.ts | 264 ++++++++++++++++++ packages/core/src/store/use-scene.ts | 242 +++++++++++++--- packages/editor/src/index.tsx | 1 - packages/editor/src/lib/material-paint.ts | 28 -- packages/nodes/src/wall/move-shared.ts | 20 ++ packages/nodes/src/wall/paint.ts | 101 +++---- packages/nodes/src/wall/renderer.tsx | 14 +- packages/nodes/src/wall/slots.ts | 10 +- .../viewer/src/systems/wall/wall-cutout.tsx | 10 +- .../viewer/src/systems/wall/wall-materials.ts | 124 ++++++-- 11 files changed, 659 insertions(+), 162 deletions(-) create mode 100644 packages/core/src/store/use-scene-wall-slot-migration.test.ts diff --git a/packages/core/src/schema/nodes/wall.ts b/packages/core/src/schema/nodes/wall.ts index af1b2b1f..a93d22bd 100644 --- a/packages/core/src/schema/nodes/wall.ts +++ b/packages/core/src/schema/nodes/wall.ts @@ -20,6 +20,13 @@ export const WallNode = BaseNode.extend({ interiorMaterialPreset: z.string().optional(), exteriorMaterial: MaterialSchema.optional(), exteriorMaterialPreset: z.string().optional(), + // Per-slot material overrides on the unified slot model, mirroring + // `SlabNode.slots`. Key = slot id (`interior` / `exterior`), value = a + // `MaterialRef` (`library:` / `scene:`). Absent = the declared slot + // default (`WALL_SLOT_DEFAULT`). The legacy `*Material*` fields above are + // read only by the load migration that moves them into `slots`; delete them + // in a follow-up once migrated scenes are the norm. + slots: z.record(z.string(), z.string()).optional(), thickness: z.number().optional(), height: z.number().optional(), curveOffset: z.number().optional(), diff --git a/packages/core/src/store/use-scene-wall-slot-migration.test.ts b/packages/core/src/store/use-scene-wall-slot-migration.test.ts new file mode 100644 index 00000000..ef1e90af --- /dev/null +++ b/packages/core/src/store/use-scene-wall-slot-migration.test.ts @@ -0,0 +1,264 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import type { AnyNode } from '../schema' +import useScene from './use-scene' + +type WallNode = Extract + +function sceneWithWall(wall: Record): Record { + return { + site_test: { + object: 'node', + id: 'site_test', + type: 'site', + parentId: null, + visible: true, + metadata: {}, + children: ['level_test'], + }, + level_test: { + object: 'node', + id: 'level_test', + type: 'level', + parentId: 'site_test', + visible: true, + metadata: {}, + children: ['wall_test'], + level: 0, + }, + wall_test: { + object: 'node', + id: 'wall_test', + type: 'wall', + parentId: 'level_test', + visible: true, + metadata: {}, + children: [], + start: [0, 0], + end: [4, 0], + ...wall, + }, + } as unknown as Record +} + +function sceneWithNode(node: Record): Record { + return { + site_test: { + object: 'node', + id: 'site_test', + type: 'site', + parentId: null, + visible: true, + metadata: {}, + children: ['level_test'], + }, + level_test: { + object: 'node', + id: 'level_test', + type: 'level', + parentId: 'site_test', + visible: true, + metadata: {}, + children: ['node_test'], + level: 0, + }, + node_test: { + object: 'node', + id: 'node_test', + visible: true, + metadata: {}, + parentId: 'level_test', + ...node, + }, + } as unknown as Record +} + +function resetScene() { + useScene.setState({ + nodes: {}, + rootNodeIds: [], + dirtyNodes: new Set(), + collections: {}, + materials: {}, + } as never) + useScene.temporal.getState().clear() +} + +describe('wall surface-material → slots migration', () => { + beforeEach(() => { + resetScene() + }) + + test('moves legacy library presets into slots and clears the inline fields', () => { + useScene.getState().setScene( + sceneWithWall({ + interiorMaterialPreset: 'library:concrete-plate', + exteriorMaterialPreset: 'library:wood-woodplank48', + }), + ['site_test'] as never, + ) + + const wall = useScene.getState().nodes.wall_test as WallNode + expect(wall.slots).toEqual({ + interior: 'library:concrete-plate', + exterior: 'library:wood-woodplank48', + }) + expect(wall.interiorMaterialPreset).toBeUndefined() + expect(wall.exteriorMaterialPreset).toBeUndefined() + }) + + test('mints a scene material for an inline legacy material and references it', () => { + useScene.getState().setScene( + sceneWithWall({ + interiorMaterial: { properties: { color: '#abcdef' } }, + }), + ['site_test'] as never, + ) + + const wall = useScene.getState().nodes.wall_test as WallNode + const interiorRef = wall.slots?.interior + expect(interiorRef?.startsWith('scene:')).toBe(true) + expect(wall.interiorMaterial).toBeUndefined() + + const materials = useScene.getState().materials + const id = interiorRef!.slice('scene:'.length) + expect(materials[id as keyof typeof materials]?.material).toEqual({ + properties: { color: '#abcdef' }, + } as never) + }) + + test('legacy catch-all material applies to both faces; identical inline customs share one scene material', () => { + useScene.getState().setScene( + sceneWithWall({ + material: { properties: { color: '#112233' } }, + }), + ['site_test'] as never, + ) + + const wall = useScene.getState().nodes.wall_test as WallNode + expect(wall.slots?.interior).toBeDefined() + expect(wall.slots?.interior).toBe(wall.slots?.exterior as string) + expect(wall.material).toBeUndefined() + // One minted datablock shared across both faces. + expect(Object.keys(useScene.getState().materials)).toHaveLength(1) + }) + + test('leaves an already slot-modelled wall untouched and mints nothing for unpainted walls', () => { + useScene + .getState() + .setScene(sceneWithWall({ slots: { interior: 'library:concrete-drywall' } }), [ + 'site_test', + ] as never) + + const migratedWall = useScene.getState().nodes.wall_test as WallNode + expect(migratedWall.slots).toEqual({ interior: 'library:concrete-drywall' }) + expect(Object.keys(useScene.getState().materials)).toHaveLength(0) + + useScene.getState().setScene(sceneWithWall({}), ['site_test'] as never) + const plainWall = useScene.getState().nodes.wall_test as WallNode + expect(plainWall.slots).toBeUndefined() + expect(Object.keys(useScene.getState().materials)).toHaveLength(0) + }) +}) + +type SlottedNode = AnyNode & { slots?: Record; material?: unknown } + +describe('procedural kind surface-material → slots migration', () => { + beforeEach(resetScene) + + test('slab: legacy preset → slots.surface, legacy cleared', () => { + useScene.getState().setScene( + sceneWithNode({ + type: 'slab', + polygon: [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + ], + materialPreset: 'library:flooring-tiles3', + }), + ['site_test'] as never, + ) + + const slab = (useScene.getState().nodes as Record).node_test! + expect(slab.slots).toEqual({ surface: 'library:flooring-tiles3' }) + expect((slab as { materialPreset?: unknown }).materialPreset).toBeUndefined() + }) + + test('ceiling: inline custom material mints a scene material on slots.surface', () => { + useScene.getState().setScene( + sceneWithNode({ + type: 'ceiling', + polygon: [ + [0, 0], + [2, 0], + [2, 2], + ], + material: { properties: { color: '#ddeeff' } }, + }), + ['site_test'] as never, + ) + + const ceiling = (useScene.getState().nodes as Record).node_test! + const ref = ceiling.slots?.surface + expect(ref?.startsWith('scene:')).toBe(true) + expect(ceiling.material).toBeUndefined() + expect(Object.keys(useScene.getState().materials)).toHaveLength(1) + }) + + test('fence: legacy preset fans out to every slot id (one shared ref)', () => { + useScene.getState().setScene( + sceneWithNode({ + type: 'fence', + start: [0, 0], + end: [4, 0], + materialPreset: 'library:wood-woodplank48', + }), + ['site_test'] as never, + ) + + const fence = (useScene.getState().nodes as Record).node_test! + expect(fence.slots).toEqual({ + posts: 'library:wood-woodplank48', + infill: 'library:wood-woodplank48', + base: 'library:wood-woodplank48', + rail: 'library:wood-woodplank48', + }) + }) + + test('stair: per-role legacy fields map tread→treads, side→body, railing→railing', () => { + useScene.getState().setScene( + sceneWithNode({ + type: 'stair', + treadMaterialPreset: 'library:wood-woodplank48', + sideMaterialPreset: 'library:concrete-plate', + railingMaterialPreset: 'library:metal-chrome', + }), + ['site_test'] as never, + ) + + const stair = (useScene.getState().nodes as Record).node_test! + expect(stair.slots?.treads).toBe('library:wood-woodplank48') + expect(stair.slots?.body).toBe('library:concrete-plate') + expect(stair.slots?.railing).toBe('library:metal-chrome') + expect((stair as { treadMaterialPreset?: unknown }).treadMaterialPreset).toBeUndefined() + }) + + test('unpainted procedural node mints nothing and stays slot-less', () => { + useScene.getState().setScene( + sceneWithNode({ + type: 'slab', + polygon: [ + [0, 0], + [2, 0], + [2, 2], + ], + }), + ['site_test'] as never, + ) + + const slab = (useScene.getState().nodes as Record).node_test! + expect(slab.slots).toBeUndefined() + expect(Object.keys(useScene.getState().materials)).toHaveLength(0) + }) +}) diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index daaf4f26..e33b2aeb 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -3,6 +3,7 @@ import type { TemporalState } from 'zundo' import { temporal } from 'zundo' import { create, type StoreApi, type UseBoundStore } from 'zustand' +import { parseMaterialRef, toSceneMaterialRef } from '../material-library' import { nodeRegistry } from '../registry/registry' import { BuildingNode } from '../schema' import type { Collection, CollectionId } from '../schema/collections' @@ -18,9 +19,17 @@ import { import { segmentPointToRoofWallFace } from '../schema/nodes/roof-segment-walls' import { ShelfNode as ShelfNodeSchema } from '../schema/nodes/shelf' import { SiteNode } from '../schema/nodes/site' -import { StairNode as StairNodeSchema } from '../schema/nodes/stair' +import { + getEffectiveStairSurfaceMaterial, + 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 { getEffectiveWallSurfaceMaterial, type WallSurfaceSide } from '../schema/nodes/wall' +import { + generateSceneMaterialId, + type SceneMaterial, + type SceneMaterialId, +} from '../schema/scene-material' import type { AnyNode, AnyNodeId } from '../schema/types' import * as nodeActions from './actions/node-actions' import { resetSceneHistoryPauseDepth } from './history-control' @@ -231,47 +240,162 @@ function migrateElevatorParent( } } -function migrateWallSurfaceMaterials(node: Record) { - const hasInterior = - node.interiorMaterial !== undefined || typeof node.interiorMaterialPreset === 'string' - const hasExterior = - node.exteriorMaterial !== undefined || typeof node.exteriorMaterialPreset === 'string' - const legacyFinish = { - material: node.material, - materialPreset: typeof node.materialPreset === 'string' ? node.materialPreset : undefined, - } - - if (!(hasInterior || hasExterior)) { - if (legacyFinish.material === undefined && legacyFinish.materialPreset === undefined) { - return node - } - - return { - ...node, - interiorMaterial: legacyFinish.material, - interiorMaterialPreset: legacyFinish.materialPreset, - exteriorMaterial: legacyFinish.material, - exteriorMaterialPreset: legacyFinish.materialPreset, +// Reuse an already-minted scene material for an identical inline legacy +// material so a whole building painted one custom colour collapses to one +// shared datablock (mirrors `commitSlotPaint`'s dedupe-on-match). +function findMintedSceneMaterialRef( + material: unknown, + mintedMaterials: Record, +): string | undefined { + const target = JSON.stringify(material) + for (const sceneMaterial of Object.values(mintedMaterials)) { + if (JSON.stringify(sceneMaterial.material) === target) { + return toSceneMaterialRef(sceneMaterial.id) } } + return undefined +} - if (!hasInterior) { - return { - ...node, - interiorMaterial: node.exteriorMaterial, - interiorMaterialPreset: node.exteriorMaterialPreset, +// Turn a legacy surface spec (`{ material, materialPreset }`) into a +// `MaterialRef`: a preset that's already a `library:`/`scene:` ref is used +// as-is; an inline material mints (or reuses) a scene material. Returns +// undefined when the spec carries no material. Shared by every legacy→slots +// migration below. +function legacySpecToMaterialRef( + spec: { material?: unknown; materialPreset?: unknown }, + mintedMaterials: Record, +): string | undefined { + if (typeof spec.materialPreset === 'string' && parseMaterialRef(spec.materialPreset)) { + return spec.materialPreset + } + if (spec.material !== undefined) { + const existing = findMintedSceneMaterialRef(spec.material, mintedMaterials) + if (existing) return existing + const id = generateSceneMaterialId() + mintedMaterials[id] = { + id, + name: `Material ${Object.keys(mintedMaterials).length + 1}`, + material: spec.material as SceneMaterial['material'], } + return toSceneMaterialRef(id) + } + return undefined +} + +// Move the retired inline `material*` / `interiorMaterial*` / `exteriorMaterial*` +// fields onto the unified `node.slots` model (interior / exterior → a +// `library:`/`scene:` ref), minting scene materials for inline customs into +// `mintedMaterials` (merged into the scene material map by the caller). Already +// slot-modelled walls and walls with no legacy material are left untouched. +function migrateWallSurfaceMaterials( + node: Record, + mintedMaterials: Record, +) { + if (node.slots && (node.slots.interior !== undefined || node.slots.exterior !== undefined)) { + return node } - if (!hasExterior) { - return { - ...node, - exteriorMaterial: node.interiorMaterial, - exteriorMaterialPreset: node.interiorMaterialPreset, - } + const slots: Record = { ...(node.slots ?? {}) } + for (const side of ['interior', 'exterior'] as WallSurfaceSide[]) { + const spec = getEffectiveWallSurfaceMaterial( + node as Parameters[0], + side, + ) + const ref = legacySpecToMaterialRef(spec, mintedMaterials) + if (ref) slots[side] = ref } - return node + if (Object.keys(slots).length === 0) { + return node + } + + return { + ...node, + slots, + material: undefined, + materialPreset: undefined, + interiorMaterial: undefined, + interiorMaterialPreset: undefined, + exteriorMaterial: undefined, + exteriorMaterialPreset: undefined, + } +} + +// Move a kind's single legacy `material` / `materialPreset` onto its declared +// slots. A pre-slot-model node painted one material rendered that material on +// every part (each slot resolves `node.slots[slot]` → legacy → default), so the +// migration writes the same ref to every slot id the kind can expose — unused +// conditional slots are harmless. Already slot-modelled or unpainted nodes are +// left untouched. Mirrors `migrateWallSurfaceMaterials` for single-surface and +// whole-object kinds (slab, ceiling, fence, column, shelf). +function migrateSingleMaterialSlots( + node: Record, + slotIds: readonly string[], + mintedMaterials: Record, +) { + if (node.slots && Object.keys(node.slots).length > 0) { + return node + } + + const ref = legacySpecToMaterialRef( + { material: node.material, materialPreset: node.materialPreset }, + mintedMaterials, + ) + if (!ref) { + return node + } + + const slots: Record = {} + for (const slotId of slotIds) slots[slotId] = ref + + return { ...node, slots, material: undefined, materialPreset: undefined } +} + +// Stair carries per-role legacy fields (`treadMaterial*` / `sideMaterial*` / +// `railingMaterial*`) plus a catch-all. Map each to its slot via the same +// fallback chain the renderer uses (`getEffectiveStairSurfaceMaterial`): +// tread→treads, side→body, railing→railing. Runs after +// `migrateStairSurfaceMaterials` has normalised the legacy fields. +function migrateStairSurfaceSlots( + node: Record, + mintedMaterials: Record, +) { + if (node.slots && Object.keys(node.slots).length > 0) { + return node + } + + const roleToSlot = [ + ['tread', 'treads'], + ['side', 'body'], + ['railing', 'railing'], + ] as const + + const slots: Record = {} + for (const [role, slotId] of roleToSlot) { + const spec = getEffectiveStairSurfaceMaterial( + node as Parameters[0], + role, + ) + const ref = legacySpecToMaterialRef(spec, mintedMaterials) + if (ref) slots[slotId] = ref + } + + if (Object.keys(slots).length === 0) { + return node + } + + return { + ...node, + slots, + material: undefined, + materialPreset: undefined, + treadMaterial: undefined, + treadMaterialPreset: undefined, + sideMaterial: undefined, + sideMaterialPreset: undefined, + railingMaterial: undefined, + railingMaterialPreset: undefined, + } } function migrateStairSurfaceMaterials(node: Record) { @@ -414,8 +538,14 @@ function migrateRoofSurfaceMaterials(node: Record) { return next } -function migrateNodes(nodes: Record): Record { +function migrateNodes(nodes: Record): { + nodes: Record + mintedMaterials: Record +} { const patchedNodes = { ...nodes } + // Scene materials minted while moving legacy wall fields onto `node.slots`; + // merged into the scene material map by the caller (`setScene`). + const mintedMaterials: Record = {} for (const [id, node] of Object.entries(patchedNodes)) { // 1. Item scale migration if (node.type === 'item' && !('scale' in node)) { @@ -502,6 +632,7 @@ function migrateNodes(nodes: Record): Record { if (normalized) { patchedNodes[id] = normalized } + patchedNodes[id] = migrateStairSurfaceSlots(patchedNodes[id], mintedMaterials) } if (node.type === 'stair-segment') { @@ -512,7 +643,27 @@ function migrateNodes(nodes: Record): Record { } if (node.type === 'wall') { - patchedNodes[id] = migrateWallSurfaceMaterials(patchedNodes[id]) + patchedNodes[id] = migrateWallSurfaceMaterials(patchedNodes[id], mintedMaterials) + } + + if (node.type === 'slab' || node.type === 'ceiling') { + patchedNodes[id] = migrateSingleMaterialSlots(patchedNodes[id], ['surface'], mintedMaterials) + } + + if (node.type === 'fence') { + patchedNodes[id] = migrateSingleMaterialSlots( + patchedNodes[id], + ['posts', 'infill', 'base', 'rail'], + mintedMaterials, + ) + } + + if (node.type === 'column') { + patchedNodes[id] = migrateSingleMaterialSlots( + patchedNodes[id], + ['shaft', 'base', 'capital', 'frame'], + mintedMaterials, + ) } if (node.type === 'shelf') { @@ -520,6 +671,11 @@ function migrateNodes(nodes: Record): Record { if (normalized) { patchedNodes[id] = normalized } + patchedNodes[id] = migrateSingleMaterialSlots( + patchedNodes[id], + ['shelves', 'frame', 'back'], + mintedMaterials, + ) } if (node.type === 'elevator') { @@ -619,7 +775,7 @@ function migrateNodes(nodes: Record): Record { } } } - return patchedNodes as Record + return { nodes: patchedNodes as Record, mintedMaterials } } function getNodeChildIds(node: AnyNode): AnyNodeId[] { @@ -789,7 +945,11 @@ const useScene: UseSceneStore = create()( setScene: (nodes, rootNodeIds, extra) => { // Apply backward compatibility migrations - const patchedNodes = migrateNodes(nodes) + const { nodes: patchedNodes, mintedMaterials } = migrateNodes(nodes) + // Scene materials minted by the wall legacy→slots migration join the + // loaded palette (existing refs win on id collision — there are none, + // ids are freshly generated). + const materials = { ...mintedMaterials, ...(extra?.materials ?? {}) } // Remove orphans: nodes whose parentId points to a non-existent node const cleanedNodes = { ...patchedNodes } @@ -811,7 +971,7 @@ const useScene: UseSceneStore = create()( rootNodeIds, dirtyNodes: new Set(), collections: extra?.collections ?? {}, - materials: extra?.materials ?? {}, + materials, }) const normalizedRootNodeIds = normalizeRootNodeIds(cleanedNodes, rootNodeIds) @@ -829,7 +989,7 @@ const useScene: UseSceneStore = create()( rootNodeIds: normalizedRootNodeIds, dirtyNodes: new Set(), collections: extra?.collections ?? {}, - materials: extra?.materials ?? {}, + materials, }) // Mark all nodes as dirty to trigger re-validation Object.values(cleanedNodes).forEach((node) => { diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 3cb0ed02..f9371842 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -221,7 +221,6 @@ export { buildRoofSurfaceMaterialPatch, buildSingleSurfaceMaterialPatch, buildStairSurfaceMaterialPatch, - buildWallSurfaceMaterialPatch, getActivePaintMaterialLabel, hasActivePaintMaterial, } from './lib/material-paint' diff --git a/packages/editor/src/lib/material-paint.ts b/packages/editor/src/lib/material-paint.ts index 7f86c05e..08313bba 100644 --- a/packages/editor/src/lib/material-paint.ts +++ b/packages/editor/src/lib/material-paint.ts @@ -13,7 +13,6 @@ import { getEffectiveRoofSurfaceMaterial, getEffectiveSegmentSurfaceMaterial, getEffectiveStairSurfaceMaterial, - getEffectiveWallSurfaceMaterial, getLibraryMaterialIdFromRef, type MaterialSchema, type MaterialTarget, @@ -26,7 +25,6 @@ import { type SlabNode, type StairNode, type StairSurfaceMaterialRole, - type WallNode, type WallSurfaceSide, } from '@pascal-app/core' @@ -78,32 +76,6 @@ export function getActivePaintMaterialLabel(material: ActivePaintMaterial | null return getCatalogEntryForActivePaintMaterial(material)?.label ?? 'Custom' } -export function buildWallSurfaceMaterialPatch( - node: WallNode, - targetSide: WallSurfaceSide, - material: MaterialSchema | undefined, - materialPreset: string | undefined, -): Partial { - const nextSurfaceMaterial = { material, materialPreset } - const nextInterior = - targetSide === 'interior' - ? nextSurfaceMaterial - : getEffectiveWallSurfaceMaterial(node, 'interior') - const nextExterior = - targetSide === 'exterior' - ? nextSurfaceMaterial - : getEffectiveWallSurfaceMaterial(node, 'exterior') - - return { - interiorMaterial: nextInterior.material, - interiorMaterialPreset: nextInterior.materialPreset, - exteriorMaterial: nextExterior.material, - exteriorMaterialPreset: nextExterior.materialPreset, - material: undefined, - materialPreset: undefined, - } -} - export function buildRoofSurfaceMaterialPatch( node: RoofNode, targetRole: RoofSurfaceMaterialRole, diff --git a/packages/nodes/src/wall/move-shared.ts b/packages/nodes/src/wall/move-shared.ts index 7cb591d5..aebea59f 100644 --- a/packages/nodes/src/wall/move-shared.ts +++ b/packages/nodes/src/wall/move-shared.ts @@ -2,7 +2,9 @@ import { type AnyNodeId, DEFAULT_WALL_HEIGHT, getMaterialPresetByRef, + parseMaterialRef, resolveMaterial, + type SceneMaterialId, useScene, type WallMoveBridgePlan, type WallNode, @@ -109,7 +111,25 @@ function wallSegmentExists( ) } +// Resolve a wall slot ref (`library:`/`scene:`) to a swatch colour, or +// undefined when the ref is absent / dangling / colourless. +function resolveWallSlotRefColor(ref: string | undefined): string | undefined { + const parsed = parseMaterialRef(ref) + if (!parsed) return undefined + if (parsed.kind === 'library') { + return getMaterialPresetByRef(ref)?.mapProperties.color ?? undefined + } + const sceneMaterial = useScene.getState().materials[parsed.id as SceneMaterialId] + return sceneMaterial ? resolveMaterial(sceneMaterial.material).color : undefined +} + export function getWallGhostColor(wall: WallNode) { + const slotColor = + resolveWallSlotRefColor(wall.slots?.interior) ?? resolveWallSlotRefColor(wall.slots?.exterior) + if (slotColor) { + return slotColor + } + const presetColor = getMaterialPresetByRef(wall.materialPreset)?.mapProperties.color ?? getMaterialPresetByRef(wall.interiorMaterialPreset)?.mapProperties.color ?? diff --git a/packages/nodes/src/wall/paint.ts b/packages/nodes/src/wall/paint.ts index a8485944..d4f5cfa4 100644 --- a/packages/nodes/src/wall/paint.ts +++ b/packages/nodes/src/wall/paint.ts @@ -1,14 +1,15 @@ import { + type AnyNode, type AnyNodeId, getEffectiveWallSurfaceMaterial, - type MaterialSchema, type PaintCapability, + type PaintPreviewArgs, sceneRegistry, type WallNode, type WallSurfaceSide, } from '@pascal-app/core' -import { getVisibleWallMaterials } from '@pascal-app/viewer' import type { Material, Mesh } from 'three' +import { buildSlotPreviewMaterial, createSlotPaintCapability } from '../shared/slot-paint' /** * Resolve which side of a wall the user clicked. Walls expose two @@ -56,81 +57,59 @@ export function resolveWallRole(args: { return hitFace === 'front' ? 'interior' : 'exterior' } -export function buildWallSurfaceMaterialPatch( - node: WallNode, - targetSide: WallSurfaceSide, - material: MaterialSchema | undefined, - materialPreset: string | undefined, -): Partial { - const nextSurfaceMaterial = { material, materialPreset } - const nextInterior = - targetSide === 'interior' - ? nextSurfaceMaterial - : getEffectiveWallSurfaceMaterial(node, 'interior') - const nextExterior = - targetSide === 'exterior' - ? nextSurfaceMaterial - : getEffectiveWallSurfaceMaterial(node, 'exterior') - - return { - interiorMaterial: nextInterior.material, - interiorMaterialPreset: nextInterior.materialPreset, - exteriorMaterial: nextExterior.material, - exteriorMaterialPreset: nextExterior.materialPreset, - material: undefined, - materialPreset: undefined, - } +// The wall's 3-material array maps side → group index (see +// `getVisibleWallMaterials`): 0 = edge/cap, 1 = interior, 2 = exterior. +const WALL_SIDE_MATERIAL_INDEX: Record = { + interior: 1, + exterior: 2, } /** - * Apply a preview to the wall's registered mesh by synthesising the - * post-paint node, asking the viewer's `getVisibleWallMaterials` for - * the corresponding material array, and swapping the mesh's - * material assignment until the editor calls the returned cleanup. + * Preview a wall paint by swapping just the painted face's entry in the wall + * mesh's material array. The array is the shared cached `WallMaterials.visible`, + * so we clone it before swapping and restore the original reference on cleanup + * (never mutate the cache). */ -function applyWallPreview( - node: WallNode, - role: WallSurfaceSide, - material: MaterialSchema | undefined, - materialPreset: string | undefined, -): (() => void) | null { - const mesh = sceneRegistry.nodes.get(node.id as AnyNodeId) +function applyWallPreview(args: PaintPreviewArgs): (() => void) | null { + const { role, material, materialPreset } = args + const side = role as WallSurfaceSide + const index = WALL_SIDE_MATERIAL_INDEX[side] + if (!index) return null + + const mesh = sceneRegistry.nodes.get(args.node.id as AnyNodeId) if (!(mesh && (mesh as Mesh).isMesh)) return null const wallMesh = mesh as Mesh - const previewNode: WallNode = { - ...node, - ...buildWallSurfaceMaterialPatch(node, role, material, materialPreset), - } - const nextMaterial = getVisibleWallMaterials(previewNode) - if (!nextMaterial) return null + const current = wallMesh.material + if (!Array.isArray(current)) return null + + const preview = buildSlotPreviewMaterial(material, materialPreset) + if (!preview) return () => {} + + const previous = current as Material[] + const next = previous.slice() + next[index] = preview + wallMesh.material = next - const previousMaterial = wallMesh.material as Material | Material[] - wallMesh.material = nextMaterial return () => { - wallMesh.material = previousMaterial + wallMesh.material = previous } } /** - * Capability binding for the wall kind. The editor's - * selection-manager invokes these in place of the legacy - * `if (node.type === 'wall') { ... }` arms. + * Capability binding for the wall kind on the unified slot model. Painting + * writes `node.slots[interior|exterior]` (a `library:` ref or a minted + * `scene:` material) exactly like every other kind; `legacyEffective` reads + * the retired inline `interiorMaterial*` / `exteriorMaterial*` fields so the + * picker still shows the current value on a pre-migration scene. */ -export const wallPaint: PaintCapability = { +export const wallPaint: PaintCapability = createSlotPaintCapability({ resolveRole: ({ node, materialIndex, normal, localPosition }) => resolveWallRole({ node: node as WallNode, materialIndex, normal, localPosition }), - buildPatch: ({ node, role, material, materialPreset }) => - buildWallSurfaceMaterialPatch( - node as WallNode, - role as WallSurfaceSide, - material, - materialPreset, - ), - applyPreview: ({ node, role, material, materialPreset }) => - applyWallPreview(node as WallNode, role as WallSurfaceSide, material, materialPreset), - getEffectiveMaterial: ({ node, role }) => { + applyPreview: applyWallPreview, + legacyEffective: (node: AnyNode, role: string) => { const spec = getEffectiveWallSurfaceMaterial(node as WallNode, role as WallSurfaceSide) + if (spec.material === undefined && spec.materialPreset === undefined) return null return { material: spec.material, materialPreset: spec.materialPreset } }, -} +}) diff --git a/packages/nodes/src/wall/renderer.tsx b/packages/nodes/src/wall/renderer.tsx index d2d9f152..856f0609 100644 --- a/packages/nodes/src/wall/renderer.tsx +++ b/packages/nodes/src/wall/renderer.tsx @@ -49,7 +49,19 @@ const WallRenderer = ({ node }: { node: WallNode }) => { const textures = useViewer((s) => s.textures) const colorPreset = useViewer((s) => s.colorPreset) const sceneTheme = useViewer((s) => s.sceneTheme) - const material = getVisibleWallMaterials(node, shading, textures, colorPreset, sceneTheme) + // Subscribe to the scene-material palette so editing a `scene:` material a + // wall slot references re-renders the wall live (the wall-system geometry + // dirty loop never fires for a material-only edit). `getMaterialsForWall`'s + // content hash keeps unaffected walls on their cached materials. + const sceneMaterials = useScene((s) => s.materials) + const material = getVisibleWallMaterials( + node, + shading, + textures, + colorPreset, + sceneTheme, + sceneMaterials, + ) return ( { const hideWall = getWallHideState(wallNode, wallMesh as Mesh, wallMode, u) const isDeleteHighlighted = deleteHoveredWallId === wallId const isSelectionHighlighted = !isDeleteHighlighted && highlightedWallIds.has(wallId) - const materials = getMaterialsForWall(wallNode, shading, textures, colorPreset, sceneTheme) + const materials = getMaterialsForWall( + wallNode, + shading, + textures, + colorPreset, + sceneTheme, + useScene.getState().materials, + ) if (hideWall) { ;(wallMesh as Mesh).material = isDeleteHighlighted @@ -145,6 +152,7 @@ export const WallCutout = () => { useViewer.getState().textures, useViewer.getState().colorPreset, useViewer.getState().sceneTheme, + useScene.getState().materials, ) const current = wallMesh.material as Material | Material[] snapshot.set(wallMesh, current) diff --git a/packages/viewer/src/systems/wall/wall-materials.ts b/packages/viewer/src/systems/wall/wall-materials.ts index a3769071..fd5efe65 100644 --- a/packages/viewer/src/systems/wall/wall-materials.ts +++ b/packages/viewer/src/systems/wall/wall-materials.ts @@ -4,9 +4,12 @@ import { getWallSurfaceMaterialSignature, parseMaterialRef, resolveMaterial, + type SceneMaterial, + type SceneMaterialId, WALL_SLOT_DEFAULT, type WallNode, type WallSurfaceMaterialSpec, + type WallSurfaceSide, } from '@pascal-app/core' import { Color, type Material } from 'three' import { Fn, float, fract, length, mix, positionLocal, smoothstep, step, vec2 } from 'three/tsl' @@ -19,9 +22,12 @@ import { createMaterialFromPresetRef, createSurfaceRoleMaterial, type RenderShading, + resolveMaterialRef, resolveSurfaceColor, } from '../../lib/materials' +type SceneMaterials = Record | undefined + const DEFAULT_WALL_COLOR = '#f2f0ed' const WALL_HIGHLIGHT_PROFILES = { @@ -100,6 +106,77 @@ function resolveWallSlotDefault(slotDefault: string, shading: RenderShading): Ma return createDefaultMaterial(slotDefault, 0.9, shading) } +// Slot-first resolution for one wall face, matching every other paintable kind: +// node.slots[side] ref → legacy inline fields → declared slot default. +// A dangling `scene:` ref (material deleted / copied across scenes) falls back +// to the declared default — it never blocks rendering (the dangling-ref rule). +function resolveWallFaceMaterial( + wallNode: WallNode, + side: WallSurfaceSide, + shading: RenderShading, + sceneMaterials: SceneMaterials, +): Material { + const ref = wallNode.slots?.[side] + if (ref) { + return ( + resolveMaterialRef(ref, sceneMaterials, shading) ?? + resolveWallSlotDefault(WALL_SLOT_DEFAULT[side], shading) + ) + } + + const spec = getEffectiveWallSurfaceMaterial(wallNode, side) + if (hasExplicitMaterial(spec)) { + return getSurfaceVisibleMaterial(spec, shading) + } + + return resolveWallSlotDefault(WALL_SLOT_DEFAULT[side], shading) +} + +// Cache-key fragment for one face: the slot ref plus, for a `scene:` ref, the +// referenced material's *content* — so editing a scene material assigned to a +// wall invalidates the cache (a `library:` ref is static catalog content, so +// its id alone is enough). Falls back to the legacy signature when unmigrated. +function wallFaceMaterialSignature( + wallNode: WallNode, + side: WallSurfaceSide, + sceneMaterials: SceneMaterials, +): string { + const ref = wallNode.slots?.[side] + if (ref) { + const parsed = parseMaterialRef(ref) + if (parsed?.kind === 'scene') { + return JSON.stringify({ + ref, + material: sceneMaterials?.[parsed.id as SceneMaterialId]?.material ?? null, + }) + } + return JSON.stringify({ ref }) + } + return getWallSurfaceMaterialSignature(getEffectiveWallSurfaceMaterial(wallNode, side)) +} + +// Slot-first tint for the cutaway/invisible wall variant. +function resolveWallFaceColor( + wallNode: WallNode, + side: WallSurfaceSide, + sceneMaterials: SceneMaterials, + fallback: string, +): string { + const ref = wallNode.slots?.[side] + if (ref) { + const parsed = parseMaterialRef(ref) + if (parsed?.kind === 'library') { + return getMaterialPresetByRef(ref)?.mapProperties?.color ?? fallback + } + if (parsed?.kind === 'scene') { + const sceneMaterial = sceneMaterials?.[parsed.id as SceneMaterialId] + return sceneMaterial ? resolveMaterial(sceneMaterial.material).color : fallback + } + return fallback + } + return getSurfaceColor(getEffectiveWallSurfaceMaterial(wallNode, side), fallback) +} + function getSurfaceColor(spec: WallSurfaceMaterialSpec, fallback = DEFAULT_WALL_COLOR): string { const preset = getMaterialPresetByRef(spec.materialPreset) if (preset?.mapProperties?.color) { @@ -185,15 +262,15 @@ function disposeOwnedMaterials(materials: WallMaterialArray[]) { }) } -export function getWallMaterialHash(wallNode: WallNode, shading: RenderShading): string { +export function getWallMaterialHash( + wallNode: WallNode, + shading: RenderShading, + sceneMaterials?: SceneMaterials, +): string { return JSON.stringify({ shading, - interior: getWallSurfaceMaterialSignature( - getEffectiveWallSurfaceMaterial(wallNode, 'interior'), - ), - exterior: getWallSurfaceMaterialSignature( - getEffectiveWallSurfaceMaterial(wallNode, 'exterior'), - ), + interior: wallFaceMaterialSignature(wallNode, 'interior', sceneMaterials), + exterior: wallFaceMaterialSignature(wallNode, 'exterior', sceneMaterials), }) } @@ -203,10 +280,11 @@ export function getMaterialsForWall( textures = true, colorPreset: ColorPreset = 'clay', sceneTheme?: string, + sceneMaterials?: SceneMaterials, ): WallMaterials { const cacheKey = `${wallNode.id}-${shading}-${textures}-${colorPreset}-${sceneTheme ?? 'base'}` const materialHash = textures - ? getWallMaterialHash(wallNode, shading) + ? getWallMaterialHash(wallNode, shading, sceneMaterials) : JSON.stringify({ textures, colorPreset, sceneTheme }) const existing = wallMaterialCache.get(cacheKey) @@ -224,23 +302,17 @@ export function getMaterialsForWall( ]) } - const interiorSpec = getEffectiveWallSurfaceMaterial(wallNode, 'interior') - const exteriorSpec = getEffectiveWallSurfaceMaterial(wallNode, 'exterior') const wallRoleMaterial = createSurfaceRoleMaterial('wall', colorPreset, undefined, sceneTheme) - // Colored mode: an unpainted face takes its declared slot default (parity - // with the retired DEFAULT_WALL_MATERIAL); only an explicit preset/material - // keeps a texture. Textures-off collapses every face to the themed wall role - // (the guaranteed escape hatch). The edge/cap slot (index 0) stays role-based. + // Colored mode: each face resolves slot-first (node.slots ref → legacy inline + // fields → declared slot default, parity with the retired DEFAULT_WALL_MATERIAL). + // Textures-off collapses every face to the themed wall role (the guaranteed + // escape hatch). The edge/cap slot (index 0) stays role-based. const visible: WallMaterialArray = textures ? [ wallRoleMaterial, - hasExplicitMaterial(interiorSpec) - ? getSurfaceVisibleMaterial(interiorSpec, shading) - : resolveWallSlotDefault(WALL_SLOT_DEFAULT.interior, shading), - hasExplicitMaterial(exteriorSpec) - ? getSurfaceVisibleMaterial(exteriorSpec, shading) - : resolveWallSlotDefault(WALL_SLOT_DEFAULT.exterior, shading), + resolveWallFaceMaterial(wallNode, 'interior', shading, sceneMaterials), + resolveWallFaceMaterial(wallNode, 'exterior', shading, sceneMaterials), ] : [wallRoleMaterial, wallRoleMaterial, wallRoleMaterial] @@ -248,11 +320,15 @@ export function getMaterialsForWall( const invisible: WallMaterialArray = [ createInvisibleWallMaterial(wallRoleColor, textures ? shading : 'solid'), createInvisibleWallMaterial( - textures ? getSurfaceColor(interiorSpec, wallRoleColor) : wallRoleColor, + textures + ? resolveWallFaceColor(wallNode, 'interior', sceneMaterials, wallRoleColor) + : wallRoleColor, textures ? shading : 'solid', ), createInvisibleWallMaterial( - textures ? getSurfaceColor(exteriorSpec, wallRoleColor) : wallRoleColor, + textures + ? resolveWallFaceColor(wallNode, 'exterior', sceneMaterials, wallRoleColor) + : wallRoleColor, textures ? shading : 'solid', ), ] @@ -290,6 +366,8 @@ export function getVisibleWallMaterials( textures = true, colorPreset: ColorPreset = 'clay', sceneTheme?: string, + sceneMaterials?: SceneMaterials, ): WallMaterialArray { - return getMaterialsForWall(wallNode, shading, textures, colorPreset, sceneTheme).visible + return getMaterialsForWall(wallNode, shading, textures, colorPreset, sceneTheme, sceneMaterials) + .visible }