Selecting a textured wall/slab/item replaced its surface with a flat purple wash. Root cause: the selection highlight clones the material and tints it, but `NodeMaterial.clone()` on the WebGPU backend drops the texture-map node assignments, so the clone rendered flat — and a strong albedo blend + emissive washed whatever was left. Fix: re-attach the maps from the source material after cloning (shared by reference) and drop the albedo tint, keeping only a gentle indigo emissive so the real material/texture stays readable with a soft "selected" glow. Applied to both highlight paths: - generic editor highlight (slabs/items) in selection-manager - wall path (walls are excluded from the generic one), built lazily + cached/self-healing so it survives the wall finish's async texture load Removes the now-dead eager wall `highlightedVisible`/`selection` profile. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
414 lines
13 KiB
TypeScript
414 lines
13 KiB
TypeScript
import {
|
|
getEffectiveWallSurfaceMaterial,
|
|
getMaterialPresetByRef,
|
|
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'
|
|
import { MeshLambertNodeMaterial, MeshStandardNodeMaterial } from 'three/webgpu'
|
|
import {
|
|
baseMaterial,
|
|
type ColorPreset,
|
|
createDefaultMaterial,
|
|
createMaterial,
|
|
createMaterialFromPresetRef,
|
|
createSurfaceRoleMaterial,
|
|
type RenderShading,
|
|
resolveMaterialRef,
|
|
resolveSurfaceColor,
|
|
} from '../../lib/materials'
|
|
|
|
type SceneMaterials = Record<SceneMaterialId, SceneMaterial> | undefined
|
|
|
|
const DEFAULT_WALL_COLOR = '#f2f0ed'
|
|
|
|
const WALL_HIGHLIGHT_PROFILES = {
|
|
delete: {
|
|
color: new Color('#dc2626'),
|
|
blend: 0.76,
|
|
emissiveBlend: 0.92,
|
|
emissiveIntensity: 0.46,
|
|
},
|
|
} as const
|
|
|
|
type WallHighlightKind = keyof typeof WALL_HIGHLIGHT_PROFILES
|
|
|
|
export type WallMaterialArray = [Material, Material, Material]
|
|
|
|
export interface WallMaterials {
|
|
visible: WallMaterialArray
|
|
invisible: WallMaterialArray
|
|
deleteVisible: WallMaterialArray
|
|
deleteInvisible: WallMaterialArray
|
|
materialHash: string
|
|
}
|
|
|
|
const wallMaterialCache = new Map<string, WallMaterials>()
|
|
|
|
const dotPattern = Fn(() => {
|
|
const scale = float(0.1)
|
|
const dotSize = float(0.3)
|
|
|
|
const uv = vec2(positionLocal.x, positionLocal.y).div(scale)
|
|
const gridUV = fract(uv)
|
|
|
|
const dist = length(gridUV.sub(0.5))
|
|
|
|
const dots = step(dist, dotSize.mul(0.5))
|
|
|
|
const fadeHeight = float(2.5)
|
|
const yFade = float(1).sub(smoothstep(float(0), fadeHeight, positionLocal.y))
|
|
|
|
return dots.mul(yFade)
|
|
})
|
|
|
|
function getSurfaceVisibleMaterial(
|
|
spec: WallSurfaceMaterialSpec,
|
|
shading: RenderShading,
|
|
): Material {
|
|
if (spec.materialPreset) {
|
|
return createMaterialFromPresetRef(spec.materialPreset, shading) ?? baseMaterial(shading)
|
|
}
|
|
|
|
if (spec.material) {
|
|
return createMaterial(spec.material, shading)
|
|
}
|
|
|
|
return baseMaterial(shading)
|
|
}
|
|
|
|
function hasExplicitMaterial(spec: WallSurfaceMaterialSpec): boolean {
|
|
return Boolean(spec.materialPreset || spec.material)
|
|
}
|
|
|
|
// Resolve a wall face's declared default — a catalog `library:` finish or a
|
|
// flat colour — to a renderable material.
|
|
function resolveWallSlotDefault(slotDefault: string, shading: RenderShading): Material {
|
|
if (parseMaterialRef(slotDefault)?.kind === 'library') {
|
|
return createMaterialFromPresetRef(slotDefault, shading) ?? baseMaterial(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 {
|
|
const preset = getMaterialPresetByRef(spec.materialPreset)
|
|
if (preset?.mapProperties?.color) {
|
|
return preset.mapProperties.color
|
|
}
|
|
|
|
if (spec.material) {
|
|
return resolveMaterial(spec.material).color
|
|
}
|
|
|
|
return fallback
|
|
}
|
|
|
|
function getHighlightedColor(color: Color, kind: WallHighlightKind): Color {
|
|
const profile = WALL_HIGHLIGHT_PROFILES[kind]
|
|
return color.clone().lerp(profile.color, profile.blend)
|
|
}
|
|
|
|
function createHighlightedWallMaterial(material: Material, kind: WallHighlightKind): Material {
|
|
const highlightedMaterial = material.clone() as Material & {
|
|
color?: Color
|
|
emissive?: Color
|
|
emissiveIntensity?: number
|
|
needsUpdate?: boolean
|
|
}
|
|
const profile = WALL_HIGHLIGHT_PROFILES[kind]
|
|
|
|
if ('color' in highlightedMaterial && highlightedMaterial.color) {
|
|
highlightedMaterial.color = getHighlightedColor(highlightedMaterial.color, kind)
|
|
}
|
|
if ('emissive' in highlightedMaterial && highlightedMaterial.emissive) {
|
|
highlightedMaterial.emissive = highlightedMaterial.emissive
|
|
.clone()
|
|
.lerp(profile.color, profile.emissiveBlend)
|
|
}
|
|
if ('emissiveIntensity' in highlightedMaterial) {
|
|
highlightedMaterial.emissiveIntensity = Math.max(
|
|
highlightedMaterial.emissiveIntensity ?? 0,
|
|
profile.emissiveIntensity,
|
|
)
|
|
}
|
|
highlightedMaterial.needsUpdate = true
|
|
|
|
return highlightedMaterial
|
|
}
|
|
|
|
// Light selection highlight for walls (walls are excluded from the generic
|
|
// editor selection highlight, so they need their own). Adds a gentle indigo
|
|
// emissive (no albedo tint) so the real material/texture stays readable with a
|
|
// soft "selected" glow. Two NodeMaterial-clone gotchas are handled:
|
|
// 1. `clone()` on the WebGPU backend drops the texture-map nodes → re-attach
|
|
// them from the source (shared by reference).
|
|
// 2. The wall's finish texture loads async, so an early clone has no map yet →
|
|
// cache keyed by the source `.map` and rebuild when it changes (self-heals
|
|
// once the texture lands).
|
|
const SELECTION_HIGHLIGHT_COLOR = new Color('#818cf8')
|
|
const SELECTION_EMISSIVE_BLEND = 0.4
|
|
const SELECTION_EMISSIVE_INTENSITY = 0.12
|
|
|
|
const SELECTION_TEXTURE_MAP_KEYS = [
|
|
'map',
|
|
'normalMap',
|
|
'roughnessMap',
|
|
'metalnessMap',
|
|
'aoMap',
|
|
'emissiveMap',
|
|
'bumpMap',
|
|
'displacementMap',
|
|
'alphaMap',
|
|
'lightMap',
|
|
] as const
|
|
|
|
const selectionHighlightCache = new WeakMap<Material, { clone: Material; map: unknown }>()
|
|
|
|
function getSelectionHighlightMaterial(base: Material): Material {
|
|
const baseMap = (base as { map?: unknown }).map ?? null
|
|
const cached = selectionHighlightCache.get(base)
|
|
if (cached && cached.map === baseMap) return cached.clone
|
|
|
|
const clone = base.clone() as Material & {
|
|
emissive?: Color
|
|
emissiveIntensity?: number
|
|
needsUpdate?: boolean
|
|
}
|
|
// Re-attach texture maps the WebGPU NodeMaterial clone drops.
|
|
const src = base as unknown as Record<string, unknown>
|
|
const dst = clone as unknown as Record<string, unknown>
|
|
for (const key of SELECTION_TEXTURE_MAP_KEYS) {
|
|
if (src[key]) dst[key] = src[key]
|
|
}
|
|
if ('emissive' in clone && clone.emissive) {
|
|
clone.emissive = clone.emissive
|
|
.clone()
|
|
.lerp(SELECTION_HIGHLIGHT_COLOR, SELECTION_EMISSIVE_BLEND)
|
|
}
|
|
if ('emissiveIntensity' in clone) {
|
|
clone.emissiveIntensity = Math.max(clone.emissiveIntensity ?? 0, SELECTION_EMISSIVE_INTENSITY)
|
|
}
|
|
clone.needsUpdate = true
|
|
selectionHighlightCache.set(base, { clone, map: baseMap })
|
|
return clone
|
|
}
|
|
|
|
/** Lazy light-emissive selection variant of a wall's material array (keeps texture). */
|
|
export function getSelectionHighlightMaterials(materials: WallMaterialArray): WallMaterialArray {
|
|
return materials.map(getSelectionHighlightMaterial) as WallMaterialArray
|
|
}
|
|
|
|
function createInvisibleWallMaterial(color: string, shading: RenderShading): Material {
|
|
const material =
|
|
shading === 'solid'
|
|
? new MeshLambertNodeMaterial({
|
|
transparent: true,
|
|
color,
|
|
depthWrite: false,
|
|
emissive: color,
|
|
})
|
|
: new MeshStandardNodeMaterial({
|
|
transparent: true,
|
|
color,
|
|
depthWrite: false,
|
|
emissive: color,
|
|
})
|
|
|
|
material.opacityNode = mix(float(0.0), float(0.24), dotPattern())
|
|
return material
|
|
}
|
|
|
|
function mapWallMaterialArray(
|
|
materials: WallMaterialArray,
|
|
iteratee: (material: Material, index: number) => Material,
|
|
): WallMaterialArray {
|
|
return materials.map(iteratee) as WallMaterialArray
|
|
}
|
|
|
|
function disposeOwnedMaterials(materials: WallMaterialArray[]) {
|
|
const owned = new Set<Material>()
|
|
materials.forEach((entry) => {
|
|
entry.forEach((material) => {
|
|
owned.add(material)
|
|
})
|
|
})
|
|
owned.forEach((material) => {
|
|
material.dispose()
|
|
})
|
|
}
|
|
|
|
export function getWallMaterialHash(
|
|
wallNode: WallNode,
|
|
shading: RenderShading,
|
|
sceneMaterials?: SceneMaterials,
|
|
): string {
|
|
return JSON.stringify({
|
|
shading,
|
|
interior: wallFaceMaterialSignature(wallNode, 'interior', sceneMaterials),
|
|
exterior: wallFaceMaterialSignature(wallNode, 'exterior', sceneMaterials),
|
|
})
|
|
}
|
|
|
|
export function getMaterialsForWall(
|
|
wallNode: WallNode,
|
|
shading: RenderShading = 'rendered',
|
|
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, sceneMaterials)
|
|
: JSON.stringify({ textures, colorPreset, sceneTheme })
|
|
|
|
const existing = wallMaterialCache.get(cacheKey)
|
|
if (existing && existing.materialHash === materialHash) {
|
|
return existing
|
|
}
|
|
|
|
if (existing) {
|
|
disposeOwnedMaterials([existing.invisible, existing.deleteVisible, existing.deleteInvisible])
|
|
}
|
|
|
|
const wallRoleMaterial = createSurfaceRoleMaterial('wall', colorPreset, undefined, sceneTheme)
|
|
|
|
// 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,
|
|
resolveWallFaceMaterial(wallNode, 'interior', shading, sceneMaterials),
|
|
resolveWallFaceMaterial(wallNode, 'exterior', shading, sceneMaterials),
|
|
]
|
|
: [wallRoleMaterial, wallRoleMaterial, wallRoleMaterial]
|
|
|
|
const wallRoleColor = resolveSurfaceColor('wall', colorPreset, sceneTheme)
|
|
const invisible: WallMaterialArray = [
|
|
createInvisibleWallMaterial(wallRoleColor, textures ? shading : 'solid'),
|
|
createInvisibleWallMaterial(
|
|
textures
|
|
? resolveWallFaceColor(wallNode, 'interior', sceneMaterials, wallRoleColor)
|
|
: wallRoleColor,
|
|
textures ? shading : 'solid',
|
|
),
|
|
createInvisibleWallMaterial(
|
|
textures
|
|
? resolveWallFaceColor(wallNode, 'exterior', sceneMaterials, wallRoleColor)
|
|
: wallRoleColor,
|
|
textures ? shading : 'solid',
|
|
),
|
|
]
|
|
|
|
const deleteVisible = mapWallMaterialArray(visible, (material) =>
|
|
createHighlightedWallMaterial(material, 'delete'),
|
|
)
|
|
const deleteInvisible = mapWallMaterialArray(invisible, (material) =>
|
|
createHighlightedWallMaterial(material, 'delete'),
|
|
)
|
|
|
|
const result: WallMaterials = {
|
|
visible,
|
|
invisible,
|
|
deleteVisible,
|
|
deleteInvisible,
|
|
materialHash,
|
|
}
|
|
|
|
wallMaterialCache.set(cacheKey, result)
|
|
return result
|
|
}
|
|
|
|
export function getVisibleWallMaterials(
|
|
wallNode: WallNode,
|
|
shading: RenderShading = 'rendered',
|
|
textures = true,
|
|
colorPreset: ColorPreset = 'clay',
|
|
sceneTheme?: string,
|
|
sceneMaterials?: SceneMaterials,
|
|
): WallMaterialArray {
|
|
return getMaterialsForWall(wallNode, shading, textures, colorPreset, sceneTheme, sceneMaterials)
|
|
.visible
|
|
}
|