feat: Add catalog-based material presets across structural nodes (#231)

* Add catalog-based material presets across structural nodes

* Fix material picker fallback and wall visible highlights

* Preserve wall materials in selection highlights
This commit is contained in:
Sudhir Yadav
2026-04-15 12:16:35 -04:00
committed by GitHub
parent f25806ee8f
commit 57df224948
67 changed files with 1458 additions and 151 deletions
+246 -4
View File
@@ -1,4 +1,11 @@
import { type MaterialProperties, type MaterialSchema, resolveMaterial } from '@pascal-app/core'
import {
type MaterialMapProperties,
type MaterialPresetPayload,
type MaterialProperties,
type MaterialSchema,
getMaterialPresetByRef,
resolveMaterial,
} from '@pascal-app/core'
import * as THREE from 'three'
const sideMap: Record<MaterialProperties['side'], THREE.Side> = {
@@ -8,19 +15,247 @@ const sideMap: Record<MaterialProperties['side'], THREE.Side> = {
}
const materialCache = new Map<string, THREE.MeshStandardMaterial>()
const textureCache = new Map<string, THREE.Texture>()
const textureLoadPromises = new Map<string, Promise<THREE.Texture | null>>()
const textureLoader = new THREE.TextureLoader()
const wrapMap = {
Repeat: THREE.RepeatWrapping,
ClampToEdge: THREE.ClampToEdgeWrapping,
MirroredRepeat: THREE.MirroredRepeatWrapping,
} as const
type StandardMaterial = THREE.MeshStandardMaterial | THREE.MeshPhysicalMaterial
type TextureSlot =
| 'map'
| 'normalMap'
| 'roughnessMap'
| 'metalnessMap'
| 'displacementMap'
| 'aoMap'
| 'bumpMap'
| 'alphaMap'
| 'lightMap'
| 'emissiveMap'
const SRGB_TEXTURE_SLOTS: TextureSlot[] = ['map', 'emissiveMap']
function getCacheKey(props: MaterialProperties): string {
return `${props.color}-${props.roughness}-${props.metalness}-${props.opacity}-${props.transparent}-${props.side}`
}
export function createMaterial(material?: MaterialSchema): THREE.MeshStandardMaterial {
const props = resolveMaterial(material)
const cacheKey = getCacheKey(props)
function getTextureKey(material?: MaterialSchema): string {
const texture = material?.texture
if (!texture) return 'none'
const repeat = texture.repeat?.join('x') ?? 'default'
const scale = texture.scale ?? 'default'
return `${texture.url}-${repeat}-${scale}`
}
function getTexture(material?: MaterialSchema): THREE.Texture | undefined {
const textureConfig = material?.texture
if (!textureConfig?.url) return undefined
const cacheKey = getTextureKey(material)
const cached = textureCache.get(cacheKey)
if (cached) return cached
const texture = textureLoader.load(textureConfig.url)
texture.wrapS = THREE.RepeatWrapping
texture.wrapT = THREE.RepeatWrapping
const repeatX = textureConfig.repeat?.[0] ?? textureConfig.scale ?? 1
const repeatY = textureConfig.repeat?.[1] ?? textureConfig.scale ?? 1
texture.repeat.set(repeatX, repeatY)
texture.colorSpace = THREE.SRGBColorSpace
textureCache.set(cacheKey, texture)
return texture
}
function isStandardMaterial(material: THREE.Material): material is StandardMaterial {
return (
material instanceof THREE.MeshStandardMaterial ||
material instanceof THREE.MeshPhysicalMaterial
)
}
function applyTextureProperties(
texture: THREE.Texture,
props: MaterialMapProperties,
slot?: TextureSlot,
): THREE.Texture {
texture.wrapS = wrapMap[props.wrapS]
texture.wrapT = wrapMap[props.wrapT]
texture.repeat.set(props.repeatX, props.repeatY)
texture.rotation = props.rotation
texture.flipY = props.flipY
texture.colorSpace = SRGB_TEXTURE_SLOTS.includes(slot ?? 'map')
? THREE.SRGBColorSpace
: THREE.NoColorSpace
texture.needsUpdate = true
return texture
}
function getPresetTextureCacheKey(path: string, props: MaterialMapProperties, slot?: TextureSlot): string {
return `${path}-${props.repeatX}-${props.repeatY}-${props.rotation}-${props.wrapS}-${props.wrapT}-${props.flipY}-${slot ?? 'map'}`
}
function getPresetTexture(path: string, props: MaterialMapProperties, slot?: TextureSlot): THREE.Texture {
const cacheKey = getPresetTextureCacheKey(path, props, slot)
const cached = textureCache.get(cacheKey)
if (cached) return cached
const texture = textureLoader.load(path)
applyTextureProperties(texture, props, slot)
textureCache.set(cacheKey, texture)
return texture
}
async function loadPresetTexture(
path: string,
props: MaterialMapProperties,
slot?: TextureSlot,
): Promise<THREE.Texture | null> {
const cacheKey = getPresetTextureCacheKey(path, props, slot)
const cached = textureCache.get(cacheKey)
if (cached) return cached
const existingPromise = textureLoadPromises.get(cacheKey)
if (existingPromise) return existingPromise
const promise = textureLoader
.loadAsync(path)
.then((texture) => {
applyTextureProperties(texture, props, slot)
textureCache.set(cacheKey, texture)
textureLoadPromises.delete(cacheKey)
return texture
})
.catch((error) => {
console.warn('[viewer] Failed to load material texture', path, error)
textureLoadPromises.delete(cacheKey)
return null
})
textureLoadPromises.set(cacheKey, promise)
return promise
}
function queueTextureAssignment(
material: StandardMaterial,
slot: TextureSlot,
path: string | undefined,
props: MaterialMapProperties,
) {
if (!path) {
material[slot] = null
return
}
const cacheKey = getPresetTextureCacheKey(path, props, slot)
const cached = textureCache.get(cacheKey)
if (cached) {
material[slot] = cached
return
}
material[slot] = null
void loadPresetTexture(path, props, slot).then((texture) => {
if (!texture) return
material[slot] = texture
material.needsUpdate = true
})
}
function applyMaterialMapProperties(material: StandardMaterial, mapProperties: MaterialMapProperties) {
material.color.set(mapProperties.color)
material.roughness = mapProperties.roughness
material.metalness = mapProperties.metalness
material.emissiveIntensity = mapProperties.emissiveIntensity
material.emissive.set(mapProperties.emissiveColor)
material.displacementScale = mapProperties.displacementScale
material.bumpScale = mapProperties.bumpScale
material.aoMapIntensity = mapProperties.aoMapIntensity
material.lightMapIntensity = mapProperties.lightMapIntensity
material.transparent = mapProperties.transparent
material.opacity = mapProperties.opacity
material.side =
mapProperties.side === 0
? THREE.FrontSide
: mapProperties.side === 1
? THREE.BackSide
: THREE.DoubleSide
material.normalScale.set(mapProperties.normalScaleX, mapProperties.normalScaleY)
material.needsUpdate = true
}
function applyMaterialPresetTextures(
material: StandardMaterial,
preset: MaterialPresetPayload,
) {
const { maps, mapProperties } = preset
queueTextureAssignment(material, 'map', maps.albedoMap, mapProperties)
queueTextureAssignment(material, 'normalMap', maps.normalMap, mapProperties)
queueTextureAssignment(material, 'roughnessMap', maps.roughnessMap, mapProperties)
queueTextureAssignment(material, 'metalnessMap', maps.metalnessMap, mapProperties)
queueTextureAssignment(material, 'displacementMap', maps.displacementMap, mapProperties)
queueTextureAssignment(material, 'aoMap', maps.aoMap, mapProperties)
queueTextureAssignment(material, 'bumpMap', maps.bumpMap, mapProperties)
queueTextureAssignment(material, 'alphaMap', maps.alphaMap, mapProperties)
queueTextureAssignment(material, 'lightMap', maps.lightMap, mapProperties)
queueTextureAssignment(material, 'emissiveMap', maps.emissiveMap, mapProperties)
material.needsUpdate = true
}
export function applyMaterialPresetToMaterials(
materialInput: THREE.Material | THREE.Material[],
preset: MaterialPresetPayload | null | undefined,
) {
if (!preset) return
const materials = (Array.isArray(materialInput) ? materialInput : [materialInput]).filter(
isStandardMaterial,
)
if (materials.length === 0) return
for (const material of materials) {
applyMaterialMapProperties(material, preset.mapProperties)
applyMaterialPresetTextures(material, preset)
}
}
export function createMaterialFromPreset(preset: MaterialPresetPayload): THREE.MeshStandardMaterial {
const cacheKey = JSON.stringify(preset)
if (materialCache.has(cacheKey)) {
return materialCache.get(cacheKey)!
}
const material = new THREE.MeshStandardMaterial()
applyMaterialPresetToMaterials(material, preset)
materialCache.set(cacheKey, material)
return material
}
export function createMaterialFromPresetRef(materialPreset?: string): THREE.MeshStandardMaterial | null {
const preset = getMaterialPresetByRef(materialPreset)
if (!preset) return null
return createMaterialFromPreset(preset)
}
export function createMaterial(material?: MaterialSchema): THREE.MeshStandardMaterial {
const props = resolveMaterial(material)
const cacheKey = `${getCacheKey(props)}-${getTextureKey(material)}`
if (materialCache.has(cacheKey)) {
return materialCache.get(cacheKey)!
}
const map = getTexture(material)
const threeMaterial = new THREE.MeshStandardMaterial({
color: props.color,
roughness: props.roughness,
@@ -28,6 +263,7 @@ export function createMaterial(material?: MaterialSchema): THREE.MeshStandardMat
opacity: props.opacity,
transparent: props.transparent,
side: sideMap[props.side],
map,
})
materialCache.set(cacheKey, threeMaterial)
@@ -70,4 +306,10 @@ export function clearMaterialCache(): void {
material.dispose()
}
materialCache.clear()
for (const texture of textureCache.values()) {
texture.dispose()
}
textureCache.clear()
textureLoadPromises.clear()
}