diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index b0416476e..6997fd0de 100644 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -568,10 +568,12 @@ const HIGHLIGHT_PROFILES = { emissiveIntensity: 0.46, }, 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'), - blend: 0.32, - emissiveBlend: 0.7, - emissiveIntensity: 0.42, + blend: 0, + emissiveBlend: 0.4, + emissiveIntensity: 0.12, }, } 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 { 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 + const dst = highlightedMaterial as unknown as Record + for (const key of TEXTURE_MAP_KEYS) { + if (src[key]) dst[key] = src[key] + } const profile = HIGHLIGHT_PROFILES[kind] if (highlightedMaterial.color instanceof Color) { diff --git a/packages/viewer/src/systems/wall/wall-cutout.tsx b/packages/viewer/src/systems/wall/wall-cutout.tsx index 069eea83c..d21d35a54 100644 --- a/packages/viewer/src/systems/wall/wall-cutout.tsx +++ b/packages/viewer/src/systems/wall/wall-cutout.tsx @@ -4,7 +4,7 @@ import { useEffect, useRef } from 'react' import type { Material } from 'three' import { type Mesh, Vector3 } from 'three/webgpu' import useViewer from '../../store/use-viewer' -import { getMaterialsForWall } from './wall-materials' +import { getMaterialsForWall, getSelectionHighlightMaterials } from './wall-materials' const tmpVec = new Vector3() const u = new Vector3() @@ -117,13 +117,13 @@ export const WallCutout = () => { ;(wallMesh as Mesh).material = isDeleteHighlighted ? materials.deleteInvisible : isSelectionHighlighted - ? materials.highlightedInvisible + ? getSelectionHighlightMaterials(materials.invisible) : materials.invisible } else { ;(wallMesh as Mesh).material = isDeleteHighlighted ? materials.deleteVisible : isSelectionHighlighted - ? materials.highlightedVisible + ? getSelectionHighlightMaterials(materials.visible) : materials.visible } }) @@ -156,9 +156,9 @@ export const WallCutout = () => { ) const current = wallMesh.material as Material | Material[] snapshot.set(wallMesh, current) - if (current === mats.highlightedVisible || current === mats.deleteVisible) { + if (current === mats.deleteVisible) { wallMesh.material = mats.visible - } else if (current === mats.highlightedInvisible || current === mats.deleteInvisible) { + } else if (current === mats.deleteInvisible) { wallMesh.material = mats.invisible } }) diff --git a/packages/viewer/src/systems/wall/wall-materials.ts b/packages/viewer/src/systems/wall/wall-materials.ts index fd5efe656..07259a5bd 100644 --- a/packages/viewer/src/systems/wall/wall-materials.ts +++ b/packages/viewer/src/systems/wall/wall-materials.ts @@ -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() + +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 + const dst = clone as unknown as Record + 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, }