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) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-18 10:12:53 -04:00
co-authored by Claude Opus 4.8
parent faf73d66f2
commit c6423e7c73
11 changed files with 659 additions and 162 deletions
+7
View File
@@ -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:<id>` / `scene:<id>`). 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(),
@@ -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<AnyNode, { type: 'wall' }>
function sceneWithWall(wall: Record<string, unknown>): Record<string, AnyNode> {
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<string, AnyNode>
}
function sceneWithNode(node: Record<string, unknown>): Record<string, AnyNode> {
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<string, AnyNode>
}
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<string, string>; 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<string, SlottedNode>).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<string, SlottedNode>).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<string, SlottedNode>).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<string, SlottedNode>).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<string, SlottedNode>).node_test!
expect(slab.slots).toBeUndefined()
expect(Object.keys(useScene.getState().materials)).toHaveLength(0)
})
})
+199 -39
View File
@@ -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<string, any>) {
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,
// 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<SceneMaterialId, SceneMaterial>,
): 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
}
// 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<SceneMaterialId, SceneMaterial>,
): 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<string, any>,
mintedMaterials: Record<SceneMaterialId, SceneMaterial>,
) {
if (node.slots && (node.slots.interior !== undefined || node.slots.exterior !== undefined)) {
return node
}
if (!(hasInterior || hasExterior)) {
if (legacyFinish.material === undefined && legacyFinish.materialPreset === undefined) {
const slots: Record<string, string> = { ...(node.slots ?? {}) }
for (const side of ['interior', 'exterior'] as WallSurfaceSide[]) {
const spec = getEffectiveWallSurfaceMaterial(
node as Parameters<typeof getEffectiveWallSurfaceMaterial>[0],
side,
)
const ref = legacySpecToMaterialRef(spec, mintedMaterials)
if (ref) slots[side] = ref
}
if (Object.keys(slots).length === 0) {
return node
}
return {
...node,
interiorMaterial: legacyFinish.material,
interiorMaterialPreset: legacyFinish.materialPreset,
exteriorMaterial: legacyFinish.material,
exteriorMaterialPreset: legacyFinish.materialPreset,
}
}
if (!hasInterior) {
return {
...node,
interiorMaterial: node.exteriorMaterial,
interiorMaterialPreset: node.exteriorMaterialPreset,
}
}
if (!hasExterior) {
return {
...node,
exteriorMaterial: node.interiorMaterial,
exteriorMaterialPreset: node.interiorMaterialPreset,
}
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<string, any>,
slotIds: readonly string[],
mintedMaterials: Record<SceneMaterialId, SceneMaterial>,
) {
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<string, string> = {}
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<string, any>,
mintedMaterials: Record<SceneMaterialId, SceneMaterial>,
) {
if (node.slots && Object.keys(node.slots).length > 0) {
return node
}
const roleToSlot = [
['tread', 'treads'],
['side', 'body'],
['railing', 'railing'],
] as const
const slots: Record<string, string> = {}
for (const [role, slotId] of roleToSlot) {
const spec = getEffectiveStairSurfaceMaterial(
node as Parameters<typeof getEffectiveStairSurfaceMaterial>[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<string, any>) {
@@ -414,8 +538,14 @@ function migrateRoofSurfaceMaterials(node: Record<string, any>) {
return next
}
function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
function migrateNodes(nodes: Record<string, any>): {
nodes: Record<string, AnyNode>
mintedMaterials: Record<SceneMaterialId, SceneMaterial>
} {
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<SceneMaterialId, SceneMaterial> = {}
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<string, any>): Record<string, AnyNode> {
if (normalized) {
patchedNodes[id] = normalized
}
patchedNodes[id] = migrateStairSurfaceSlots(patchedNodes[id], mintedMaterials)
}
if (node.type === 'stair-segment') {
@@ -512,7 +643,27 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
}
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<string, any>): Record<string, AnyNode> {
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<string, any>): Record<string, AnyNode> {
}
}
}
return patchedNodes as Record<string, AnyNode>
return { nodes: patchedNodes as Record<string, AnyNode>, mintedMaterials }
}
function getNodeChildIds(node: AnyNode): AnyNodeId[] {
@@ -789,7 +945,11 @@ const useScene: UseSceneStore = create<SceneState>()(
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<SceneState>()(
rootNodeIds,
dirtyNodes: new Set<AnyNodeId>(),
collections: extra?.collections ?? {},
materials: extra?.materials ?? {},
materials,
})
const normalizedRootNodeIds = normalizeRootNodeIds(cleanedNodes, rootNodeIds)
@@ -829,7 +989,7 @@ const useScene: UseSceneStore = create<SceneState>()(
rootNodeIds: normalizedRootNodeIds,
dirtyNodes: new Set<AnyNodeId>(),
collections: extra?.collections ?? {},
materials: extra?.materials ?? {},
materials,
})
// Mark all nodes as dirty to trigger re-validation
Object.values(cleanedNodes).forEach((node) => {
-1
View File
@@ -221,7 +221,6 @@ export {
buildRoofSurfaceMaterialPatch,
buildSingleSurfaceMaterialPatch,
buildStairSurfaceMaterialPatch,
buildWallSurfaceMaterialPatch,
getActivePaintMaterialLabel,
hasActivePaintMaterial,
} from './lib/material-paint'
-28
View File
@@ -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<WallNode> {
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,
+20
View File
@@ -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 ??
+40 -61
View File
@@ -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<WallNode> {
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<WallSurfaceSide, 1 | 2> = {
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 }
},
}
})
+13 -1
View File
@@ -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 (
<mesh
+4 -6
View File
@@ -1,13 +1,11 @@
import { type SlotDeclaration, WALL_SLOT_DEFAULT } from '@pascal-app/core'
/**
* A wall exposes two paintable faces — interior + exterior. Painting still
* writes the legacy `interiorMaterial*` / `exteriorMaterial*` fields via
* `wallPaint` (the inline model isn't migrated to `node.slots` yet); this
* A wall exposes two paintable faces — interior + exterior. Painting writes
* `node.slots[interior|exterior]` via `wallPaint` like every other kind; this
* declaration surfaces the slot list + declared defaults for the picker and
* keeps walls on the same `{ slotId, label, default }` contract as every other
* paintable kind. The defaults come from core so the viewer's material
* resolver renders the identical value.
* keeps walls on the same `{ slotId, label, default }` contract. The defaults
* come from core so the viewer's material resolver renders the identical value.
*/
export function wallSlots(): SlotDeclaration[] {
return [
@@ -104,7 +104,14 @@ export const WallCutout = () => {
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)
@@ -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<SceneMaterialId, SceneMaterial> | 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
}