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
@@ -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