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,49 +240,164 @@ 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
|
||||
}
|
||||
|
||||
if (!(hasInterior || hasExterior)) {
|
||||
if (legacyFinish.material === undefined && legacyFinish.materialPreset === 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
|
||||
}
|
||||
|
||||
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>) {
|
||||
const hasRailing =
|
||||
node.railingMaterial !== undefined || typeof node.railingMaterialPreset === 'string'
|
||||
@@ -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) => {
|
||||
|
||||
@@ -221,7 +221,6 @@ export {
|
||||
buildRoofSurfaceMaterialPatch,
|
||||
buildSingleSurfaceMaterialPatch,
|
||||
buildStairSurfaceMaterialPatch,
|
||||
buildWallSurfaceMaterialPatch,
|
||||
getActivePaintMaterialLabel,
|
||||
hasActivePaintMaterial,
|
||||
} from './lib/material-paint'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ??
|
||||
|
||||
@@ -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 }
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user