fix(selection): selected objects keep their texture (light glow, no wash)

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>
This commit is contained in:
Wassim SAMAD
2026-06-19 17:17:41 -04:00
co-authored by Claude Opus 4.8
parent f2352616d9
commit 8633886eef
3 changed files with 95 additions and 31 deletions
@@ -37,12 +37,6 @@ const WALL_HIGHLIGHT_PROFILES = {
emissiveBlend: 0.92,
emissiveIntensity: 0.46,
},
selection: {
color: new Color('#818cf8'),
blend: 0.32,
emissiveBlend: 0.7,
emissiveIntensity: 0.42,
},
} as const
type WallHighlightKind = keyof typeof WALL_HIGHLIGHT_PROFILES
@@ -54,8 +48,6 @@ export interface WallMaterials {
invisible: WallMaterialArray
deleteVisible: WallMaterialArray
deleteInvisible: WallMaterialArray
highlightedVisible: WallMaterialArray
highlightedInvisible: WallMaterialArray
materialHash: string
}
@@ -223,6 +215,68 @@ function createHighlightedWallMaterial(material: Material, kind: WallHighlightKi
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'
@@ -293,13 +347,7 @@ export function getMaterialsForWall(
}
if (existing) {
disposeOwnedMaterials([
existing.invisible,
existing.deleteVisible,
existing.deleteInvisible,
existing.highlightedVisible,
existing.highlightedInvisible,
])
disposeOwnedMaterials([existing.invisible, existing.deleteVisible, existing.deleteInvisible])
}
const wallRoleMaterial = createSurfaceRoleMaterial('wall', colorPreset, undefined, sceneTheme)
@@ -333,12 +381,6 @@ export function getMaterialsForWall(
),
]
const highlightedVisible = mapWallMaterialArray(visible, (material) =>
createHighlightedWallMaterial(material, 'selection'),
)
const highlightedInvisible = mapWallMaterialArray(invisible, (material) =>
createHighlightedWallMaterial(material, 'selection'),
)
const deleteVisible = mapWallMaterialArray(visible, (material) =>
createHighlightedWallMaterial(material, 'delete'),
)
@@ -351,8 +393,6 @@ export function getMaterialsForWall(
invisible,
deleteVisible,
deleteInvisible,
highlightedVisible,
highlightedInvisible,
materialHash,
}