import { getMaterialPresetByRef, type MaterialMapProperties, type MaterialPresetPayload, type MaterialProperties, type MaterialSchema, resolveMaterial, type SurfaceRole, } from '@pascal-app/core' import * as THREE from 'three' import { MeshLambertNodeMaterial, MeshStandardNodeMaterial } from 'three/webgpu' import { resolveCdnUrl } from './asset-url' import { getSceneTheme } from './scene-themes' export type RenderShading = 'solid' | 'rendered' export type ColorPreset = 'clay' | 'white' | 'mono' | 'blueprint' export const CLAY_PALETTE: Record = { wall: '#dcd6c7', floor: '#cfc8b6', ceiling: '#e4ded0', roof: '#b8ad96', joinery: '#c4bba6', glazing: '#c8d4dc', furnishing: '#d2ccbe', } export const WHITE_PALETTE: Record = { wall: '#f4f3ef', floor: '#ece9e2', ceiling: '#fbfaf6', roof: '#dedbd2', joinery: '#e8e5dc', glazing: '#dbe8ee', furnishing: '#efede7', } export const MONO_PALETTE: Record = { wall: '#c8c8c8', floor: '#b8b8b8', ceiling: '#d8d8d8', roof: '#9a9a9a', joinery: '#adadad', glazing: '#c2cbd0', furnishing: '#c0c0c0', } export const BLUEPRINT_PALETTE: Record = { wall: '#90a9c7', floor: '#7f98ba', ceiling: '#aec0d8', roof: '#5f789b', joinery: '#6f86a8', glazing: '#b6d7ea', furnishing: '#8ba2bf', } export const PRESET_PALETTES: Record> = { clay: CLAY_PALETTE, white: WHITE_PALETTE, mono: MONO_PALETTE, blueprint: BLUEPRINT_PALETTE, } export function resolveSurfaceColor( role: SurfaceRole, preset: ColorPreset, sceneThemeId?: string, ): string { // The active scene theme may tint individual roles (e.g. Mediterranean's blue // roof); fall back to the chosen colour preset's palette when it doesn't. const tints = sceneThemeId ? getSceneTheme(sceneThemeId).clayTints : undefined return tints?.[role] ?? PRESET_PALETTES[preset][role] } // DoubleSide on any NodeMaterial inside the MRT scenePass (SSGI's output / // diffuseColor / normal targets) causes WebGPU to create a render pipeline // whose back-face shader variant doesn't declare outputs for every MRT target // — the validator rejects it and poisons the entire render context. FrontSide // avoids that code path. Same pattern as MeshStandardMaterial in renderer.tsx. export const glassMaterial = new MeshLambertNodeMaterial({ color: '#e0f2fe', transparent: true, opacity: 0.35, side: THREE.FrontSide, }) const sideMap: Record = { front: THREE.FrontSide, back: THREE.BackSide, double: THREE.DoubleSide, } const materialCache = new Map() const defaultMaterialCache = new Map() const surfaceRoleMaterialCache = new Map() const textureCache = new Map() const textureLoadPromises = new Map>() const textureLoader = new THREE.TextureLoader() const wrapMap = { Repeat: THREE.RepeatWrapping, ClampToEdge: THREE.ClampToEdgeWrapping, MirroredRepeat: THREE.MirroredRepeatWrapping, } as const type CommonMaterial = THREE.Material & { color: THREE.Color map?: THREE.Texture | null emissive?: THREE.Color emissiveIntensity?: number opacity: number transparent: boolean side: THREE.Side needsUpdate: boolean } type StandardMaterial = | THREE.MeshStandardMaterial | THREE.MeshPhysicalMaterial | MeshStandardNodeMaterial type TextureMaterial = CommonMaterial & Partial> type TextureSlot = | 'map' | 'normalMap' | 'roughnessMap' | 'metalnessMap' | 'displacementMap' | 'aoMap' | 'bumpMap' | 'alphaMap' | 'lightMap' | 'emissiveMap' const SRGB_TEXTURE_SLOTS: TextureSlot[] = ['map', 'emissiveMap'] const TEXTURE_SLOTS: TextureSlot[] = [ 'map', 'normalMap', 'roughnessMap', 'metalnessMap', 'displacementMap', 'aoMap', 'bumpMap', 'alphaMap', 'lightMap', 'emissiveMap', ] function getTextureChannel(slot?: TextureSlot): number { if (slot === 'aoMap' || slot === 'lightMap') { return 2 } return 0 } function getCacheKey(props: MaterialProperties, shading: RenderShading): string { return `${shading}-${props.color}-${props.roughness}-${props.metalness}-${props.opacity}-${props.transparent}-${props.side}` } 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.updateMatrix() 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 || material instanceof MeshStandardNodeMaterial ) } function isCommonMaterial(material: THREE.Material): material is CommonMaterial { return 'color' in material && material.color instanceof THREE.Color } 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.updateMatrix() texture.channel = getTextureChannel(slot) texture.colorSpace = SRGB_TEXTURE_SLOTS.includes(slot ?? 'map') ? THREE.SRGBColorSpace : THREE.NoColorSpace texture.needsUpdate = true return texture } function setTextureCacheKey(texture: THREE.Texture, cacheKey: string): THREE.Texture { texture.userData.pascalTextureCacheKey = cacheKey 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 resolvedPath = resolveCdnUrl(path) ?? path const cacheKey = getPresetTextureCacheKey(resolvedPath, props, slot) const cached = textureCache.get(cacheKey) if (cached) return cached const texture = textureLoader.load(resolvedPath) applyTextureProperties(texture, props, slot) setTextureCacheKey(texture, cacheKey) textureCache.set(cacheKey, texture) return texture } function createAssignedTexture( source: THREE.Texture, props: MaterialMapProperties, slot?: TextureSlot, ): THREE.Texture { const texture = source.clone() const cacheKey = source.userData.pascalTextureCacheKey if (typeof cacheKey === 'string') { setTextureCacheKey(texture, cacheKey) } return applyTextureProperties(texture, props, slot) } function applyTexturePropertiesToMaterial(material: CommonMaterial, props: MaterialMapProperties) { const slots = isStandardMaterial(material) ? TEXTURE_SLOTS : (['map'] as const) const textureMaterial = material as TextureMaterial for (const slot of slots) { const texture = textureMaterial[slot as TextureSlot] if (!texture) continue applyTextureProperties(texture, props, slot as TextureSlot) } } async function loadPresetTexture( path: string, props: MaterialMapProperties, slot?: TextureSlot, ): Promise { const resolvedPath = resolveCdnUrl(path) ?? path const cacheKey = getPresetTextureCacheKey(resolvedPath, props, slot) const cached = textureCache.get(cacheKey) if (cached) return cached const existingPromise = textureLoadPromises.get(cacheKey) if (existingPromise) return existingPromise const promise = textureLoader .loadAsync(resolvedPath) .then((texture) => { applyTextureProperties(texture, props, slot) setTextureCacheKey(texture, cacheKey) textureCache.set(cacheKey, texture) textureLoadPromises.delete(cacheKey) return texture }) .catch((error) => { console.warn('[viewer] Failed to load material texture', resolvedPath, error) textureLoadPromises.delete(cacheKey) return null }) textureLoadPromises.set(cacheKey, promise) return promise } function queueTextureAssignment( material: CommonMaterial, slot: TextureSlot, path: string | undefined, props: MaterialMapProperties, ) { const textureMaterial = material as TextureMaterial if (!path) { textureMaterial[slot] = null return } const resolvedPath = resolveCdnUrl(path) ?? path const cacheKey = getPresetTextureCacheKey(resolvedPath, props, slot) if (textureMaterial[slot]?.userData.pascalTextureCacheKey === cacheKey) { applyTextureProperties(textureMaterial[slot], props, slot) return } const cached = textureCache.get(cacheKey) if (cached) { textureMaterial[slot] = createAssignedTexture(cached, props, slot) material.needsUpdate = true return } textureMaterial[slot] = null loadPresetTexture(path, props, slot).then((texture) => { if (!texture) return textureMaterial[slot] = createAssignedTexture(texture, props, slot) material.needsUpdate = true }) } function applyMaterialMapProperties( material: CommonMaterial, mapProperties: MaterialMapProperties, ) { material.color.set(mapProperties.color) if (isStandardMaterial(material)) { material.roughness = mapProperties.roughness material.metalness = mapProperties.metalness material.displacementScale = mapProperties.displacementScale material.bumpScale = mapProperties.bumpScale material.aoMapIntensity = mapProperties.aoMapIntensity material.lightMapIntensity = mapProperties.lightMapIntensity material.normalScale.set(mapProperties.normalScaleX, mapProperties.normalScaleY) } if (material.emissive) { material.emissive.set(mapProperties.emissiveColor) } if ('emissiveIntensity' in material) { material.emissiveIntensity = mapProperties.emissiveIntensity } material.transparent = mapProperties.transparent material.opacity = mapProperties.opacity material.side = mapProperties.side === 0 ? THREE.FrontSide : mapProperties.side === 1 ? THREE.BackSide : THREE.DoubleSide applyTexturePropertiesToMaterial(material, mapProperties) material.needsUpdate = true } function applyMaterialPresetTextures(material: CommonMaterial, preset: MaterialPresetPayload) { const { maps, mapProperties } = preset queueTextureAssignment(material, 'map', maps.albedoMap, mapProperties) if (!isStandardMaterial(material)) { material.needsUpdate = true return } 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( isCommonMaterial, ) if (materials.length === 0) return for (const material of materials) { applyMaterialMapProperties(material, preset.mapProperties) applyMaterialPresetTextures(material, preset) } } export function createMaterialFromPreset( preset: MaterialPresetPayload, shading: RenderShading = 'rendered', ): THREE.Material { const cacheKey = `${shading}-${JSON.stringify(preset)}` if (materialCache.has(cacheKey)) { return materialCache.get(cacheKey)! } const material = shading === 'solid' ? new MeshLambertNodeMaterial() : new MeshStandardNodeMaterial() applyMaterialPresetToMaterials(material, preset) materialCache.set(cacheKey, material) return material } export function createMaterialFromPresetRef( materialPreset?: string, shading: RenderShading = 'rendered', ): THREE.Material | null { const preset = getMaterialPresetByRef(materialPreset) if (!preset) return null return createMaterialFromPreset(preset, shading) } export function createMaterial( material?: MaterialSchema, shading: RenderShading = 'rendered', ): THREE.Material { const props = resolveMaterial(material) const cacheKey = `${getCacheKey(props, shading)}-${getTextureKey(material)}` if (materialCache.has(cacheKey)) { return materialCache.get(cacheKey)! } const map = getTexture(material) const materialParams: { color: string map?: THREE.Texture opacity: number side: THREE.Side transparent: boolean } = { color: props.color, opacity: props.opacity, transparent: props.transparent, side: sideMap[props.side], } if (map) materialParams.map = map const threeMaterial = shading === 'solid' ? new MeshLambertNodeMaterial(materialParams) : new MeshStandardNodeMaterial({ ...materialParams, roughness: props.roughness, metalness: props.metalness, }) materialCache.set(cacheKey, threeMaterial) return threeMaterial } export function createDefaultMaterial( color = '#ffffff', roughness = 0.9, shading: RenderShading = 'rendered', side: THREE.Side = THREE.FrontSide, ): THREE.Material { if (shading === 'solid') { return new MeshLambertNodeMaterial({ color, side, }) } return new MeshStandardNodeMaterial({ color, roughness, metalness: 0, side, }) } function cachedDefaultMaterial( key: string, color: string, roughness: number, shading: RenderShading, side: THREE.Side = THREE.FrontSide, ): THREE.Material { const cacheKey = `${key}-${shading}` const cached = defaultMaterialCache.get(cacheKey) if (cached) return cached const material = createDefaultMaterial(color, roughness, shading, side) defaultMaterialCache.set(cacheKey, material) return material } export function createSurfaceRoleMaterial( role: SurfaceRole, preset: ColorPreset, side: THREE.Side = THREE.FrontSide, sceneThemeId?: string, ): THREE.Material { // DoubleSide on glazing trips the MRT back-face pipeline issue documented // on `glassMaterial` above — the validator rejects the back-face variant // for missing MRT outputs and poisons the render context (manifests as // "Color target has no corresponding fragment stage output" on scene // open, since the dormer's window-assembly mounts the glazing material // on both gable faces on the first frame). Callers that need both sides // visible (e.g. dormer back gable) must rotate the host mesh 180° so the // FrontSide faces the viewer. const resolvedSide = role === 'glazing' ? THREE.FrontSide : side const cacheKey = `${role}-${preset}-${resolvedSide}-${sceneThemeId ?? 'base'}` const cached = surfaceRoleMaterialCache.get(cacheKey) if (cached) return cached const material = role === 'glazing' ? new MeshLambertNodeMaterial({ color: resolveSurfaceColor(role, preset, sceneThemeId), depthWrite: false, opacity: 0.25, side: resolvedSide, transparent: true, }) : new MeshLambertNodeMaterial({ color: resolveSurfaceColor(role, preset, sceneThemeId), side: resolvedSide, }) material.userData.__pascalCachedMaterial = true surfaceRoleMaterialCache.set(cacheKey, material) return material } export function baseMaterial(shading: RenderShading = 'rendered'): THREE.Material { return cachedDefaultMaterial('base', '#f2f0ed', 0.5, shading) } export function DEFAULT_WALL_MATERIAL(shading: RenderShading = 'rendered'): THREE.Material { return cachedDefaultMaterial('wall', '#ffffff', 0.9, shading) } export function DEFAULT_SLAB_MATERIAL(shading: RenderShading = 'rendered'): THREE.Material { return cachedDefaultMaterial('slab', '#e5e5e5', 0.8, shading) } export function DEFAULT_DOOR_MATERIAL(shading: RenderShading = 'rendered'): THREE.Material { return cachedDefaultMaterial('door', '#8b4513', 0.7, shading) } export function DEFAULT_WINDOW_MATERIAL(shading: RenderShading = 'rendered'): THREE.Material { const cacheKey = `window-${shading}` const cached = defaultMaterialCache.get(cacheKey) if (cached) return cached // DoubleSide on a NodeMaterial inside the MRT scene pass compiles a back-face // pipeline variant whose fragment outputs don't cover every MRT target — the // validator rejects it and poisons the render context (see the note above // `glassMaterial`). FrontSide; flip the consumer's back-face group 180° if a // back face is actually visible. const params = { color: '#87ceeb', opacity: 0.3, transparent: true, side: THREE.FrontSide, } const material = shading === 'solid' ? new MeshLambertNodeMaterial(params) : new MeshStandardNodeMaterial({ ...params, roughness: 0.1, metalness: 0.1, }) defaultMaterialCache.set(cacheKey, material) return material } export function DEFAULT_CEILING_MATERIAL(shading: RenderShading = 'rendered'): THREE.Material { return cachedDefaultMaterial('ceiling', '#f5f5dc', 0.95, shading) } export function DEFAULT_ROOF_MATERIAL(shading: RenderShading = 'rendered'): THREE.Material { return cachedDefaultMaterial('roof', '#808080', 0.85, shading) } export function DEFAULT_SHELF_MATERIAL(shading: RenderShading = 'rendered'): THREE.Material { return cachedDefaultMaterial('shelf', '#ffffff', 0.9, shading) } export function DEFAULT_STAIR_MATERIAL(shading: RenderShading = 'rendered'): THREE.Material { return cachedDefaultMaterial('stair', '#ffffff', 0.9, shading) } export function disposeMaterial(material: THREE.Material): void { material.dispose() } export function clearMaterialCache(): void { for (const material of materialCache.values()) { material.dispose() } materialCache.clear() for (const material of defaultMaterialCache.values()) { material.dispose() } defaultMaterialCache.clear() for (const material of surfaceRoleMaterialCache.values()) { material.dispose() } surfaceRoleMaterialCache.clear() for (const texture of textureCache.values()) { texture.dispose() } textureCache.clear() textureLoadPromises.clear() }