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(),
|
interiorMaterialPreset: z.string().optional(),
|
||||||
exteriorMaterial: MaterialSchema.optional(),
|
exteriorMaterial: MaterialSchema.optional(),
|
||||||
exteriorMaterialPreset: z.string().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(),
|
thickness: z.number().optional(),
|
||||||
height: z.number().optional(),
|
height: z.number().optional(),
|
||||||
curveOffset: 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 type { TemporalState } from 'zundo'
|
||||||
import { temporal } from 'zundo'
|
import { temporal } from 'zundo'
|
||||||
import { create, type StoreApi, type UseBoundStore } from 'zustand'
|
import { create, type StoreApi, type UseBoundStore } from 'zustand'
|
||||||
|
import { parseMaterialRef, toSceneMaterialRef } from '../material-library'
|
||||||
import { nodeRegistry } from '../registry/registry'
|
import { nodeRegistry } from '../registry/registry'
|
||||||
import { BuildingNode } from '../schema'
|
import { BuildingNode } from '../schema'
|
||||||
import type { Collection, CollectionId } from '../schema/collections'
|
import type { Collection, CollectionId } from '../schema/collections'
|
||||||
@@ -18,9 +19,17 @@ import {
|
|||||||
import { segmentPointToRoofWallFace } from '../schema/nodes/roof-segment-walls'
|
import { segmentPointToRoofWallFace } from '../schema/nodes/roof-segment-walls'
|
||||||
import { ShelfNode as ShelfNodeSchema } from '../schema/nodes/shelf'
|
import { ShelfNode as ShelfNodeSchema } from '../schema/nodes/shelf'
|
||||||
import { SiteNode } from '../schema/nodes/site'
|
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 { 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 type { AnyNode, AnyNodeId } from '../schema/types'
|
||||||
import * as nodeActions from './actions/node-actions'
|
import * as nodeActions from './actions/node-actions'
|
||||||
import { resetSceneHistoryPauseDepth } from './history-control'
|
import { resetSceneHistoryPauseDepth } from './history-control'
|
||||||
@@ -231,49 +240,164 @@ function migrateElevatorParent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function migrateWallSurfaceMaterials(node: Record<string, any>) {
|
// Reuse an already-minted scene material for an identical inline legacy
|
||||||
const hasInterior =
|
// material so a whole building painted one custom colour collapses to one
|
||||||
node.interiorMaterial !== undefined || typeof node.interiorMaterialPreset === 'string'
|
// shared datablock (mirrors `commitSlotPaint`'s dedupe-on-match).
|
||||||
const hasExterior =
|
function findMintedSceneMaterialRef(
|
||||||
node.exteriorMaterial !== undefined || typeof node.exteriorMaterialPreset === 'string'
|
material: unknown,
|
||||||
const legacyFinish = {
|
mintedMaterials: Record<SceneMaterialId, SceneMaterial>,
|
||||||
material: node.material,
|
): string | undefined {
|
||||||
materialPreset: typeof node.materialPreset === 'string' ? node.materialPreset : 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)) {
|
// Turn a legacy surface spec (`{ material, materialPreset }`) into a
|
||||||
if (legacyFinish.material === undefined && legacyFinish.materialPreset === undefined) {
|
// `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
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...node,
|
...node,
|
||||||
interiorMaterial: legacyFinish.material,
|
slots,
|
||||||
interiorMaterialPreset: legacyFinish.materialPreset,
|
material: undefined,
|
||||||
exteriorMaterial: legacyFinish.material,
|
materialPreset: undefined,
|
||||||
exteriorMaterialPreset: legacyFinish.materialPreset,
|
interiorMaterial: undefined,
|
||||||
}
|
interiorMaterialPreset: undefined,
|
||||||
}
|
exteriorMaterial: undefined,
|
||||||
|
exteriorMaterialPreset: undefined,
|
||||||
if (!hasInterior) {
|
|
||||||
return {
|
|
||||||
...node,
|
|
||||||
interiorMaterial: node.exteriorMaterial,
|
|
||||||
interiorMaterialPreset: node.exteriorMaterialPreset,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasExterior) {
|
|
||||||
return {
|
|
||||||
...node,
|
|
||||||
exteriorMaterial: node.interiorMaterial,
|
|
||||||
exteriorMaterialPreset: node.interiorMaterialPreset,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
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>) {
|
function migrateStairSurfaceMaterials(node: Record<string, any>) {
|
||||||
const hasRailing =
|
const hasRailing =
|
||||||
node.railingMaterial !== undefined || typeof node.railingMaterialPreset === 'string'
|
node.railingMaterial !== undefined || typeof node.railingMaterialPreset === 'string'
|
||||||
@@ -414,8 +538,14 @@ function migrateRoofSurfaceMaterials(node: Record<string, any>) {
|
|||||||
return next
|
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 }
|
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)) {
|
for (const [id, node] of Object.entries(patchedNodes)) {
|
||||||
// 1. Item scale migration
|
// 1. Item scale migration
|
||||||
if (node.type === 'item' && !('scale' in node)) {
|
if (node.type === 'item' && !('scale' in node)) {
|
||||||
@@ -502,6 +632,7 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
|
|||||||
if (normalized) {
|
if (normalized) {
|
||||||
patchedNodes[id] = normalized
|
patchedNodes[id] = normalized
|
||||||
}
|
}
|
||||||
|
patchedNodes[id] = migrateStairSurfaceSlots(patchedNodes[id], mintedMaterials)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.type === 'stair-segment') {
|
if (node.type === 'stair-segment') {
|
||||||
@@ -512,7 +643,27 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (node.type === 'wall') {
|
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') {
|
if (node.type === 'shelf') {
|
||||||
@@ -520,6 +671,11 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
|
|||||||
if (normalized) {
|
if (normalized) {
|
||||||
patchedNodes[id] = normalized
|
patchedNodes[id] = normalized
|
||||||
}
|
}
|
||||||
|
patchedNodes[id] = migrateSingleMaterialSlots(
|
||||||
|
patchedNodes[id],
|
||||||
|
['shelves', 'frame', 'back'],
|
||||||
|
mintedMaterials,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.type === 'elevator') {
|
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[] {
|
function getNodeChildIds(node: AnyNode): AnyNodeId[] {
|
||||||
@@ -789,7 +945,11 @@ const useScene: UseSceneStore = create<SceneState>()(
|
|||||||
|
|
||||||
setScene: (nodes, rootNodeIds, extra) => {
|
setScene: (nodes, rootNodeIds, extra) => {
|
||||||
// Apply backward compatibility migrations
|
// 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
|
// Remove orphans: nodes whose parentId points to a non-existent node
|
||||||
const cleanedNodes = { ...patchedNodes }
|
const cleanedNodes = { ...patchedNodes }
|
||||||
@@ -811,7 +971,7 @@ const useScene: UseSceneStore = create<SceneState>()(
|
|||||||
rootNodeIds,
|
rootNodeIds,
|
||||||
dirtyNodes: new Set<AnyNodeId>(),
|
dirtyNodes: new Set<AnyNodeId>(),
|
||||||
collections: extra?.collections ?? {},
|
collections: extra?.collections ?? {},
|
||||||
materials: extra?.materials ?? {},
|
materials,
|
||||||
})
|
})
|
||||||
|
|
||||||
const normalizedRootNodeIds = normalizeRootNodeIds(cleanedNodes, rootNodeIds)
|
const normalizedRootNodeIds = normalizeRootNodeIds(cleanedNodes, rootNodeIds)
|
||||||
@@ -829,7 +989,7 @@ const useScene: UseSceneStore = create<SceneState>()(
|
|||||||
rootNodeIds: normalizedRootNodeIds,
|
rootNodeIds: normalizedRootNodeIds,
|
||||||
dirtyNodes: new Set<AnyNodeId>(),
|
dirtyNodes: new Set<AnyNodeId>(),
|
||||||
collections: extra?.collections ?? {},
|
collections: extra?.collections ?? {},
|
||||||
materials: extra?.materials ?? {},
|
materials,
|
||||||
})
|
})
|
||||||
// Mark all nodes as dirty to trigger re-validation
|
// Mark all nodes as dirty to trigger re-validation
|
||||||
Object.values(cleanedNodes).forEach((node) => {
|
Object.values(cleanedNodes).forEach((node) => {
|
||||||
|
|||||||
@@ -221,7 +221,6 @@ export {
|
|||||||
buildRoofSurfaceMaterialPatch,
|
buildRoofSurfaceMaterialPatch,
|
||||||
buildSingleSurfaceMaterialPatch,
|
buildSingleSurfaceMaterialPatch,
|
||||||
buildStairSurfaceMaterialPatch,
|
buildStairSurfaceMaterialPatch,
|
||||||
buildWallSurfaceMaterialPatch,
|
|
||||||
getActivePaintMaterialLabel,
|
getActivePaintMaterialLabel,
|
||||||
hasActivePaintMaterial,
|
hasActivePaintMaterial,
|
||||||
} from './lib/material-paint'
|
} from './lib/material-paint'
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
getEffectiveRoofSurfaceMaterial,
|
getEffectiveRoofSurfaceMaterial,
|
||||||
getEffectiveSegmentSurfaceMaterial,
|
getEffectiveSegmentSurfaceMaterial,
|
||||||
getEffectiveStairSurfaceMaterial,
|
getEffectiveStairSurfaceMaterial,
|
||||||
getEffectiveWallSurfaceMaterial,
|
|
||||||
getLibraryMaterialIdFromRef,
|
getLibraryMaterialIdFromRef,
|
||||||
type MaterialSchema,
|
type MaterialSchema,
|
||||||
type MaterialTarget,
|
type MaterialTarget,
|
||||||
@@ -26,7 +25,6 @@ import {
|
|||||||
type SlabNode,
|
type SlabNode,
|
||||||
type StairNode,
|
type StairNode,
|
||||||
type StairSurfaceMaterialRole,
|
type StairSurfaceMaterialRole,
|
||||||
type WallNode,
|
|
||||||
type WallSurfaceSide,
|
type WallSurfaceSide,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
|
|
||||||
@@ -78,32 +76,6 @@ export function getActivePaintMaterialLabel(material: ActivePaintMaterial | null
|
|||||||
return getCatalogEntryForActivePaintMaterial(material)?.label ?? 'Custom'
|
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(
|
export function buildRoofSurfaceMaterialPatch(
|
||||||
node: RoofNode,
|
node: RoofNode,
|
||||||
targetRole: RoofSurfaceMaterialRole,
|
targetRole: RoofSurfaceMaterialRole,
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import {
|
|||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
DEFAULT_WALL_HEIGHT,
|
DEFAULT_WALL_HEIGHT,
|
||||||
getMaterialPresetByRef,
|
getMaterialPresetByRef,
|
||||||
|
parseMaterialRef,
|
||||||
resolveMaterial,
|
resolveMaterial,
|
||||||
|
type SceneMaterialId,
|
||||||
useScene,
|
useScene,
|
||||||
type WallMoveBridgePlan,
|
type WallMoveBridgePlan,
|
||||||
type WallNode,
|
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) {
|
export function getWallGhostColor(wall: WallNode) {
|
||||||
|
const slotColor =
|
||||||
|
resolveWallSlotRefColor(wall.slots?.interior) ?? resolveWallSlotRefColor(wall.slots?.exterior)
|
||||||
|
if (slotColor) {
|
||||||
|
return slotColor
|
||||||
|
}
|
||||||
|
|
||||||
const presetColor =
|
const presetColor =
|
||||||
getMaterialPresetByRef(wall.materialPreset)?.mapProperties.color ??
|
getMaterialPresetByRef(wall.materialPreset)?.mapProperties.color ??
|
||||||
getMaterialPresetByRef(wall.interiorMaterialPreset)?.mapProperties.color ??
|
getMaterialPresetByRef(wall.interiorMaterialPreset)?.mapProperties.color ??
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import {
|
import {
|
||||||
|
type AnyNode,
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
getEffectiveWallSurfaceMaterial,
|
getEffectiveWallSurfaceMaterial,
|
||||||
type MaterialSchema,
|
|
||||||
type PaintCapability,
|
type PaintCapability,
|
||||||
|
type PaintPreviewArgs,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
type WallNode,
|
type WallNode,
|
||||||
type WallSurfaceSide,
|
type WallSurfaceSide,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { getVisibleWallMaterials } from '@pascal-app/viewer'
|
|
||||||
import type { Material, Mesh } from 'three'
|
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
|
* 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'
|
return hitFace === 'front' ? 'interior' : 'exterior'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildWallSurfaceMaterialPatch(
|
// The wall's 3-material array maps side → group index (see
|
||||||
node: WallNode,
|
// `getVisibleWallMaterials`): 0 = edge/cap, 1 = interior, 2 = exterior.
|
||||||
targetSide: WallSurfaceSide,
|
const WALL_SIDE_MATERIAL_INDEX: Record<WallSurfaceSide, 1 | 2> = {
|
||||||
material: MaterialSchema | undefined,
|
interior: 1,
|
||||||
materialPreset: string | undefined,
|
exterior: 2,
|
||||||
): 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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply a preview to the wall's registered mesh by synthesising the
|
* Preview a wall paint by swapping just the painted face's entry in the wall
|
||||||
* post-paint node, asking the viewer's `getVisibleWallMaterials` for
|
* mesh's material array. The array is the shared cached `WallMaterials.visible`,
|
||||||
* the corresponding material array, and swapping the mesh's
|
* so we clone it before swapping and restore the original reference on cleanup
|
||||||
* material assignment until the editor calls the returned cleanup.
|
* (never mutate the cache).
|
||||||
*/
|
*/
|
||||||
function applyWallPreview(
|
function applyWallPreview(args: PaintPreviewArgs): (() => void) | null {
|
||||||
node: WallNode,
|
const { role, material, materialPreset } = args
|
||||||
role: WallSurfaceSide,
|
const side = role as WallSurfaceSide
|
||||||
material: MaterialSchema | undefined,
|
const index = WALL_SIDE_MATERIAL_INDEX[side]
|
||||||
materialPreset: string | undefined,
|
if (!index) return null
|
||||||
): (() => void) | null {
|
|
||||||
const mesh = sceneRegistry.nodes.get(node.id as AnyNodeId)
|
const mesh = sceneRegistry.nodes.get(args.node.id as AnyNodeId)
|
||||||
if (!(mesh && (mesh as Mesh).isMesh)) return null
|
if (!(mesh && (mesh as Mesh).isMesh)) return null
|
||||||
const wallMesh = mesh as Mesh
|
const wallMesh = mesh as Mesh
|
||||||
|
|
||||||
const previewNode: WallNode = {
|
const current = wallMesh.material
|
||||||
...node,
|
if (!Array.isArray(current)) return null
|
||||||
...buildWallSurfaceMaterialPatch(node, role, material, materialPreset),
|
|
||||||
}
|
const preview = buildSlotPreviewMaterial(material, materialPreset)
|
||||||
const nextMaterial = getVisibleWallMaterials(previewNode)
|
if (!preview) return () => {}
|
||||||
if (!nextMaterial) return null
|
|
||||||
|
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 () => {
|
return () => {
|
||||||
wallMesh.material = previousMaterial
|
wallMesh.material = previous
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Capability binding for the wall kind. The editor's
|
* Capability binding for the wall kind on the unified slot model. Painting
|
||||||
* selection-manager invokes these in place of the legacy
|
* writes `node.slots[interior|exterior]` (a `library:` ref or a minted
|
||||||
* `if (node.type === 'wall') { ... }` arms.
|
* `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 }) =>
|
resolveRole: ({ node, materialIndex, normal, localPosition }) =>
|
||||||
resolveWallRole({ node: node as WallNode, materialIndex, normal, localPosition }),
|
resolveWallRole({ node: node as WallNode, materialIndex, normal, localPosition }),
|
||||||
buildPatch: ({ node, role, material, materialPreset }) =>
|
applyPreview: applyWallPreview,
|
||||||
buildWallSurfaceMaterialPatch(
|
legacyEffective: (node: AnyNode, role: string) => {
|
||||||
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 }) => {
|
|
||||||
const spec = getEffectiveWallSurfaceMaterial(node as WallNode, role as WallSurfaceSide)
|
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 }
|
return { material: spec.material, materialPreset: spec.materialPreset }
|
||||||
},
|
},
|
||||||
}
|
})
|
||||||
|
|||||||
@@ -49,7 +49,19 @@ const WallRenderer = ({ node }: { node: WallNode }) => {
|
|||||||
const textures = useViewer((s) => s.textures)
|
const textures = useViewer((s) => s.textures)
|
||||||
const colorPreset = useViewer((s) => s.colorPreset)
|
const colorPreset = useViewer((s) => s.colorPreset)
|
||||||
const sceneTheme = useViewer((s) => s.sceneTheme)
|
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 (
|
return (
|
||||||
<mesh
|
<mesh
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { type SlotDeclaration, WALL_SLOT_DEFAULT } from '@pascal-app/core'
|
import { type SlotDeclaration, WALL_SLOT_DEFAULT } from '@pascal-app/core'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A wall exposes two paintable faces — interior + exterior. Painting still
|
* A wall exposes two paintable faces — interior + exterior. Painting writes
|
||||||
* writes the legacy `interiorMaterial*` / `exteriorMaterial*` fields via
|
* `node.slots[interior|exterior]` via `wallPaint` like every other kind; this
|
||||||
* `wallPaint` (the inline model isn't migrated to `node.slots` yet); this
|
|
||||||
* declaration surfaces the slot list + declared defaults for the picker and
|
* declaration surfaces the slot list + declared defaults for the picker and
|
||||||
* keeps walls on the same `{ slotId, label, default }` contract as every other
|
* keeps walls on the same `{ slotId, label, default }` contract. The defaults
|
||||||
* paintable kind. The defaults come from core so the viewer's material
|
* come from core so the viewer's material resolver renders the identical value.
|
||||||
* resolver renders the identical value.
|
|
||||||
*/
|
*/
|
||||||
export function wallSlots(): SlotDeclaration[] {
|
export function wallSlots(): SlotDeclaration[] {
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -104,7 +104,14 @@ export const WallCutout = () => {
|
|||||||
const hideWall = getWallHideState(wallNode, wallMesh as Mesh, wallMode, u)
|
const hideWall = getWallHideState(wallNode, wallMesh as Mesh, wallMode, u)
|
||||||
const isDeleteHighlighted = deleteHoveredWallId === wallId
|
const isDeleteHighlighted = deleteHoveredWallId === wallId
|
||||||
const isSelectionHighlighted = !isDeleteHighlighted && highlightedWallIds.has(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) {
|
if (hideWall) {
|
||||||
;(wallMesh as Mesh).material = isDeleteHighlighted
|
;(wallMesh as Mesh).material = isDeleteHighlighted
|
||||||
@@ -145,6 +152,7 @@ export const WallCutout = () => {
|
|||||||
useViewer.getState().textures,
|
useViewer.getState().textures,
|
||||||
useViewer.getState().colorPreset,
|
useViewer.getState().colorPreset,
|
||||||
useViewer.getState().sceneTheme,
|
useViewer.getState().sceneTheme,
|
||||||
|
useScene.getState().materials,
|
||||||
)
|
)
|
||||||
const current = wallMesh.material as Material | Material[]
|
const current = wallMesh.material as Material | Material[]
|
||||||
snapshot.set(wallMesh, current)
|
snapshot.set(wallMesh, current)
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ import {
|
|||||||
getWallSurfaceMaterialSignature,
|
getWallSurfaceMaterialSignature,
|
||||||
parseMaterialRef,
|
parseMaterialRef,
|
||||||
resolveMaterial,
|
resolveMaterial,
|
||||||
|
type SceneMaterial,
|
||||||
|
type SceneMaterialId,
|
||||||
WALL_SLOT_DEFAULT,
|
WALL_SLOT_DEFAULT,
|
||||||
type WallNode,
|
type WallNode,
|
||||||
type WallSurfaceMaterialSpec,
|
type WallSurfaceMaterialSpec,
|
||||||
|
type WallSurfaceSide,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { Color, type Material } from 'three'
|
import { Color, type Material } from 'three'
|
||||||
import { Fn, float, fract, length, mix, positionLocal, smoothstep, step, vec2 } from 'three/tsl'
|
import { Fn, float, fract, length, mix, positionLocal, smoothstep, step, vec2 } from 'three/tsl'
|
||||||
@@ -19,9 +22,12 @@ import {
|
|||||||
createMaterialFromPresetRef,
|
createMaterialFromPresetRef,
|
||||||
createSurfaceRoleMaterial,
|
createSurfaceRoleMaterial,
|
||||||
type RenderShading,
|
type RenderShading,
|
||||||
|
resolveMaterialRef,
|
||||||
resolveSurfaceColor,
|
resolveSurfaceColor,
|
||||||
} from '../../lib/materials'
|
} from '../../lib/materials'
|
||||||
|
|
||||||
|
type SceneMaterials = Record<SceneMaterialId, SceneMaterial> | undefined
|
||||||
|
|
||||||
const DEFAULT_WALL_COLOR = '#f2f0ed'
|
const DEFAULT_WALL_COLOR = '#f2f0ed'
|
||||||
|
|
||||||
const WALL_HIGHLIGHT_PROFILES = {
|
const WALL_HIGHLIGHT_PROFILES = {
|
||||||
@@ -100,6 +106,77 @@ function resolveWallSlotDefault(slotDefault: string, shading: RenderShading): Ma
|
|||||||
return createDefaultMaterial(slotDefault, 0.9, shading)
|
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 {
|
function getSurfaceColor(spec: WallSurfaceMaterialSpec, fallback = DEFAULT_WALL_COLOR): string {
|
||||||
const preset = getMaterialPresetByRef(spec.materialPreset)
|
const preset = getMaterialPresetByRef(spec.materialPreset)
|
||||||
if (preset?.mapProperties?.color) {
|
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({
|
return JSON.stringify({
|
||||||
shading,
|
shading,
|
||||||
interior: getWallSurfaceMaterialSignature(
|
interior: wallFaceMaterialSignature(wallNode, 'interior', sceneMaterials),
|
||||||
getEffectiveWallSurfaceMaterial(wallNode, 'interior'),
|
exterior: wallFaceMaterialSignature(wallNode, 'exterior', sceneMaterials),
|
||||||
),
|
|
||||||
exterior: getWallSurfaceMaterialSignature(
|
|
||||||
getEffectiveWallSurfaceMaterial(wallNode, 'exterior'),
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,10 +280,11 @@ export function getMaterialsForWall(
|
|||||||
textures = true,
|
textures = true,
|
||||||
colorPreset: ColorPreset = 'clay',
|
colorPreset: ColorPreset = 'clay',
|
||||||
sceneTheme?: string,
|
sceneTheme?: string,
|
||||||
|
sceneMaterials?: SceneMaterials,
|
||||||
): WallMaterials {
|
): WallMaterials {
|
||||||
const cacheKey = `${wallNode.id}-${shading}-${textures}-${colorPreset}-${sceneTheme ?? 'base'}`
|
const cacheKey = `${wallNode.id}-${shading}-${textures}-${colorPreset}-${sceneTheme ?? 'base'}`
|
||||||
const materialHash = textures
|
const materialHash = textures
|
||||||
? getWallMaterialHash(wallNode, shading)
|
? getWallMaterialHash(wallNode, shading, sceneMaterials)
|
||||||
: JSON.stringify({ textures, colorPreset, sceneTheme })
|
: JSON.stringify({ textures, colorPreset, sceneTheme })
|
||||||
|
|
||||||
const existing = wallMaterialCache.get(cacheKey)
|
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)
|
const wallRoleMaterial = createSurfaceRoleMaterial('wall', colorPreset, undefined, sceneTheme)
|
||||||
|
|
||||||
// Colored mode: an unpainted face takes its declared slot default (parity
|
// Colored mode: each face resolves slot-first (node.slots ref → legacy inline
|
||||||
// with the retired DEFAULT_WALL_MATERIAL); only an explicit preset/material
|
// fields → declared slot default, parity with the retired DEFAULT_WALL_MATERIAL).
|
||||||
// keeps a texture. Textures-off collapses every face to the themed wall role
|
// Textures-off collapses every face to the themed wall role (the guaranteed
|
||||||
// (the guaranteed escape hatch). The edge/cap slot (index 0) stays role-based.
|
// escape hatch). The edge/cap slot (index 0) stays role-based.
|
||||||
const visible: WallMaterialArray = textures
|
const visible: WallMaterialArray = textures
|
||||||
? [
|
? [
|
||||||
wallRoleMaterial,
|
wallRoleMaterial,
|
||||||
hasExplicitMaterial(interiorSpec)
|
resolveWallFaceMaterial(wallNode, 'interior', shading, sceneMaterials),
|
||||||
? getSurfaceVisibleMaterial(interiorSpec, shading)
|
resolveWallFaceMaterial(wallNode, 'exterior', shading, sceneMaterials),
|
||||||
: resolveWallSlotDefault(WALL_SLOT_DEFAULT.interior, shading),
|
|
||||||
hasExplicitMaterial(exteriorSpec)
|
|
||||||
? getSurfaceVisibleMaterial(exteriorSpec, shading)
|
|
||||||
: resolveWallSlotDefault(WALL_SLOT_DEFAULT.exterior, shading),
|
|
||||||
]
|
]
|
||||||
: [wallRoleMaterial, wallRoleMaterial, wallRoleMaterial]
|
: [wallRoleMaterial, wallRoleMaterial, wallRoleMaterial]
|
||||||
|
|
||||||
@@ -248,11 +320,15 @@ export function getMaterialsForWall(
|
|||||||
const invisible: WallMaterialArray = [
|
const invisible: WallMaterialArray = [
|
||||||
createInvisibleWallMaterial(wallRoleColor, textures ? shading : 'solid'),
|
createInvisibleWallMaterial(wallRoleColor, textures ? shading : 'solid'),
|
||||||
createInvisibleWallMaterial(
|
createInvisibleWallMaterial(
|
||||||
textures ? getSurfaceColor(interiorSpec, wallRoleColor) : wallRoleColor,
|
textures
|
||||||
|
? resolveWallFaceColor(wallNode, 'interior', sceneMaterials, wallRoleColor)
|
||||||
|
: wallRoleColor,
|
||||||
textures ? shading : 'solid',
|
textures ? shading : 'solid',
|
||||||
),
|
),
|
||||||
createInvisibleWallMaterial(
|
createInvisibleWallMaterial(
|
||||||
textures ? getSurfaceColor(exteriorSpec, wallRoleColor) : wallRoleColor,
|
textures
|
||||||
|
? resolveWallFaceColor(wallNode, 'exterior', sceneMaterials, wallRoleColor)
|
||||||
|
: wallRoleColor,
|
||||||
textures ? shading : 'solid',
|
textures ? shading : 'solid',
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
@@ -290,6 +366,8 @@ export function getVisibleWallMaterials(
|
|||||||
textures = true,
|
textures = true,
|
||||||
colorPreset: ColorPreset = 'clay',
|
colorPreset: ColorPreset = 'clay',
|
||||||
sceneTheme?: string,
|
sceneTheme?: string,
|
||||||
|
sceneMaterials?: SceneMaterials,
|
||||||
): WallMaterialArray {
|
): 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