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:
co-authored by
Claude Opus 4.8
parent
f2352616d9
commit
8633886eef
@@ -568,10 +568,12 @@ const HIGHLIGHT_PROFILES = {
|
|||||||
emissiveIntensity: 0.46,
|
emissiveIntensity: 0.46,
|
||||||
},
|
},
|
||||||
selection: {
|
selection: {
|
||||||
|
// Keep the real material/texture readable: no albedo tint, just a gentle
|
||||||
|
// indigo emissive glow so it reads as selected.
|
||||||
color: new Color('#818cf8'),
|
color: new Color('#818cf8'),
|
||||||
blend: 0.32,
|
blend: 0,
|
||||||
emissiveBlend: 0.7,
|
emissiveBlend: 0.4,
|
||||||
emissiveIntensity: 0.42,
|
emissiveIntensity: 0.12,
|
||||||
},
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
@@ -596,8 +598,30 @@ function isHighlightableMesh(object: Object3D): object is Mesh {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TEXTURE_MAP_KEYS = [
|
||||||
|
'map',
|
||||||
|
'normalMap',
|
||||||
|
'roughnessMap',
|
||||||
|
'metalnessMap',
|
||||||
|
'aoMap',
|
||||||
|
'emissiveMap',
|
||||||
|
'bumpMap',
|
||||||
|
'displacementMap',
|
||||||
|
'alphaMap',
|
||||||
|
'lightMap',
|
||||||
|
] as const
|
||||||
|
|
||||||
function createHighlightedMaterial(material: Material, kind: HighlightKind): Material {
|
function createHighlightedMaterial(material: Material, kind: HighlightKind): Material {
|
||||||
const highlightedMaterial = material.clone() as HighlightableMaterial
|
const highlightedMaterial = material.clone() as HighlightableMaterial
|
||||||
|
// `NodeMaterial.clone()` on the WebGPU backend drops the texture-map node
|
||||||
|
// assignments, so the clone renders flat. Re-attach the maps from the source
|
||||||
|
// material (they're shared by reference — same texture object) so the
|
||||||
|
// selected object keeps its texture under the highlight.
|
||||||
|
const src = material as unknown as Record<string, unknown>
|
||||||
|
const dst = highlightedMaterial as unknown as Record<string, unknown>
|
||||||
|
for (const key of TEXTURE_MAP_KEYS) {
|
||||||
|
if (src[key]) dst[key] = src[key]
|
||||||
|
}
|
||||||
const profile = HIGHLIGHT_PROFILES[kind]
|
const profile = HIGHLIGHT_PROFILES[kind]
|
||||||
|
|
||||||
if (highlightedMaterial.color instanceof Color) {
|
if (highlightedMaterial.color instanceof Color) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useEffect, useRef } from 'react'
|
|||||||
import type { Material } from 'three'
|
import type { Material } from 'three'
|
||||||
import { type Mesh, Vector3 } from 'three/webgpu'
|
import { type Mesh, Vector3 } from 'three/webgpu'
|
||||||
import useViewer from '../../store/use-viewer'
|
import useViewer from '../../store/use-viewer'
|
||||||
import { getMaterialsForWall } from './wall-materials'
|
import { getMaterialsForWall, getSelectionHighlightMaterials } from './wall-materials'
|
||||||
|
|
||||||
const tmpVec = new Vector3()
|
const tmpVec = new Vector3()
|
||||||
const u = new Vector3()
|
const u = new Vector3()
|
||||||
@@ -117,13 +117,13 @@ export const WallCutout = () => {
|
|||||||
;(wallMesh as Mesh).material = isDeleteHighlighted
|
;(wallMesh as Mesh).material = isDeleteHighlighted
|
||||||
? materials.deleteInvisible
|
? materials.deleteInvisible
|
||||||
: isSelectionHighlighted
|
: isSelectionHighlighted
|
||||||
? materials.highlightedInvisible
|
? getSelectionHighlightMaterials(materials.invisible)
|
||||||
: materials.invisible
|
: materials.invisible
|
||||||
} else {
|
} else {
|
||||||
;(wallMesh as Mesh).material = isDeleteHighlighted
|
;(wallMesh as Mesh).material = isDeleteHighlighted
|
||||||
? materials.deleteVisible
|
? materials.deleteVisible
|
||||||
: isSelectionHighlighted
|
: isSelectionHighlighted
|
||||||
? materials.highlightedVisible
|
? getSelectionHighlightMaterials(materials.visible)
|
||||||
: materials.visible
|
: materials.visible
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -156,9 +156,9 @@ export const WallCutout = () => {
|
|||||||
)
|
)
|
||||||
const current = wallMesh.material as Material | Material[]
|
const current = wallMesh.material as Material | Material[]
|
||||||
snapshot.set(wallMesh, current)
|
snapshot.set(wallMesh, current)
|
||||||
if (current === mats.highlightedVisible || current === mats.deleteVisible) {
|
if (current === mats.deleteVisible) {
|
||||||
wallMesh.material = mats.visible
|
wallMesh.material = mats.visible
|
||||||
} else if (current === mats.highlightedInvisible || current === mats.deleteInvisible) {
|
} else if (current === mats.deleteInvisible) {
|
||||||
wallMesh.material = mats.invisible
|
wallMesh.material = mats.invisible
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -37,12 +37,6 @@ const WALL_HIGHLIGHT_PROFILES = {
|
|||||||
emissiveBlend: 0.92,
|
emissiveBlend: 0.92,
|
||||||
emissiveIntensity: 0.46,
|
emissiveIntensity: 0.46,
|
||||||
},
|
},
|
||||||
selection: {
|
|
||||||
color: new Color('#818cf8'),
|
|
||||||
blend: 0.32,
|
|
||||||
emissiveBlend: 0.7,
|
|
||||||
emissiveIntensity: 0.42,
|
|
||||||
},
|
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
type WallHighlightKind = keyof typeof WALL_HIGHLIGHT_PROFILES
|
type WallHighlightKind = keyof typeof WALL_HIGHLIGHT_PROFILES
|
||||||
@@ -54,8 +48,6 @@ export interface WallMaterials {
|
|||||||
invisible: WallMaterialArray
|
invisible: WallMaterialArray
|
||||||
deleteVisible: WallMaterialArray
|
deleteVisible: WallMaterialArray
|
||||||
deleteInvisible: WallMaterialArray
|
deleteInvisible: WallMaterialArray
|
||||||
highlightedVisible: WallMaterialArray
|
|
||||||
highlightedInvisible: WallMaterialArray
|
|
||||||
materialHash: string
|
materialHash: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,6 +215,68 @@ function createHighlightedWallMaterial(material: Material, kind: WallHighlightKi
|
|||||||
return highlightedMaterial
|
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 {
|
function createInvisibleWallMaterial(color: string, shading: RenderShading): Material {
|
||||||
const material =
|
const material =
|
||||||
shading === 'solid'
|
shading === 'solid'
|
||||||
@@ -293,13 +347,7 @@ export function getMaterialsForWall(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
disposeOwnedMaterials([
|
disposeOwnedMaterials([existing.invisible, existing.deleteVisible, existing.deleteInvisible])
|
||||||
existing.invisible,
|
|
||||||
existing.deleteVisible,
|
|
||||||
existing.deleteInvisible,
|
|
||||||
existing.highlightedVisible,
|
|
||||||
existing.highlightedInvisible,
|
|
||||||
])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const wallRoleMaterial = createSurfaceRoleMaterial('wall', colorPreset, undefined, sceneTheme)
|
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) =>
|
const deleteVisible = mapWallMaterialArray(visible, (material) =>
|
||||||
createHighlightedWallMaterial(material, 'delete'),
|
createHighlightedWallMaterial(material, 'delete'),
|
||||||
)
|
)
|
||||||
@@ -351,8 +393,6 @@ export function getMaterialsForWall(
|
|||||||
invisible,
|
invisible,
|
||||||
deleteVisible,
|
deleteVisible,
|
||||||
deleteInvisible,
|
deleteInvisible,
|
||||||
highlightedVisible,
|
|
||||||
highlightedInvisible,
|
|
||||||
materialHash,
|
materialHash,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user