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:
co-authored by
Claude Opus 4.8
parent
faf73d66f2
commit
c6423e7c73
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
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<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
|
||||
}
|
||||
|
||||
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<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 (!hasExterior) {
|
||||
return {
|
||||
...node,
|
||||
exteriorMaterial: node.interiorMaterial,
|
||||
exteriorMaterialPreset: node.interiorMaterialPreset,
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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<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) => {
|
||||
|
||||
Reference in New Issue
Block a user