702 lines
22 KiB
TypeScript
702 lines
22 KiB
TypeScript
import {
|
|
getMaterialPresetByRef,
|
|
type MaterialMapProperties,
|
|
type MaterialPresetPayload,
|
|
type MaterialProperties,
|
|
type MaterialSchema,
|
|
parseMaterialRef,
|
|
resolveMaterial,
|
|
type SceneMaterial,
|
|
type SceneMaterialId,
|
|
type SurfaceRole,
|
|
} from '@pascal-app/core'
|
|
import * as THREE from 'three'
|
|
import { MeshLambertNodeMaterial, MeshStandardNodeMaterial } from 'three/webgpu'
|
|
|
|
import { resolveCdnUrl } from './asset-url'
|
|
import { isKtx2Url, ktx2Loader } from './ktx2-loader'
|
|
import { getSceneTheme } from './scene-themes'
|
|
|
|
export type RenderShading = 'solid' | 'rendered'
|
|
export type ColorPreset = 'clay' | 'white' | 'mono' | 'blueprint'
|
|
|
|
export const CLAY_PALETTE: Record<SurfaceRole, string> = {
|
|
wall: '#dcd6c7',
|
|
floor: '#cfc8b6',
|
|
ceiling: '#e4ded0',
|
|
roof: '#b8ad96',
|
|
joinery: '#c4bba6',
|
|
glazing: '#c8d4dc',
|
|
furnishing: '#d2ccbe',
|
|
}
|
|
|
|
export const WHITE_PALETTE: Record<SurfaceRole, string> = {
|
|
wall: '#f4f3ef',
|
|
floor: '#ece9e2',
|
|
ceiling: '#fbfaf6',
|
|
roof: '#dedbd2',
|
|
joinery: '#e8e5dc',
|
|
glazing: '#dbe8ee',
|
|
furnishing: '#efede7',
|
|
}
|
|
|
|
export const MONO_PALETTE: Record<SurfaceRole, string> = {
|
|
wall: '#c8c8c8',
|
|
floor: '#b8b8b8',
|
|
ceiling: '#d8d8d8',
|
|
roof: '#9a9a9a',
|
|
joinery: '#adadad',
|
|
glazing: '#c2cbd0',
|
|
furnishing: '#c0c0c0',
|
|
}
|
|
|
|
export const BLUEPRINT_PALETTE: Record<SurfaceRole, string> = {
|
|
wall: '#90a9c7',
|
|
floor: '#7f98ba',
|
|
ceiling: '#aec0d8',
|
|
roof: '#5f789b',
|
|
joinery: '#6f86a8',
|
|
glazing: '#b6d7ea',
|
|
furnishing: '#8ba2bf',
|
|
}
|
|
|
|
export const PRESET_PALETTES: Record<ColorPreset, Record<SurfaceRole, string>> = {
|
|
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] ?? CLAY_PALETTE)[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,
|
|
})
|
|
|
|
function resolveNodeMaterialSide(side: THREE.Side): THREE.Side {
|
|
return side === THREE.DoubleSide ? THREE.FrontSide : side
|
|
}
|
|
|
|
const sideMap: Record<MaterialProperties['side'], THREE.Side> = {
|
|
front: THREE.FrontSide,
|
|
back: THREE.BackSide,
|
|
double: THREE.FrontSide,
|
|
}
|
|
|
|
const materialCache = new Map<string, THREE.Material>()
|
|
const defaultMaterialCache = new Map<string, THREE.Material>()
|
|
const surfaceRoleMaterialCache = new Map<string, THREE.Material>()
|
|
const textureCache = new Map<string, THREE.Texture>()
|
|
const textureLoadPromises = new Map<string, Promise<THREE.Texture | null>>()
|
|
const textureLoader = new THREE.TextureLoader()
|
|
|
|
// `.ktx2` finish maps transcode through the shared KTX2 loader (support is
|
|
// detected once at viewer init); everything else loads as a normal image.
|
|
function pickTextureLoader(url: string): THREE.TextureLoader {
|
|
// KTX2Loader's load/loadAsync are call-compatible with TextureLoader (url →
|
|
// Texture / Promise<Texture>); cast for typing.
|
|
return isKtx2Url(url) ? (ktx2Loader as unknown as THREE.TextureLoader) : 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<Record<TextureSlot, THREE.Texture | null>>
|
|
|
|
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 = pickTextureLoader(textureConfig.url).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 = pickTextureLoader(resolvedPath).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<THREE.Texture | null> {
|
|
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 = pickTextureLoader(resolvedPath)
|
|
.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 = resolveNodeMaterialSide(
|
|
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
|
|
}
|
|
|
|
/**
|
|
* Resolve a MaterialRef ('library:<id>' | 'scene:<id>') to a three.js material.
|
|
* Returns null for an unknown / dangling ref so callers fall back to the
|
|
* slot's default (authored material, then themed default). Never throws.
|
|
*/
|
|
export function resolveMaterialRef(
|
|
ref: string | undefined,
|
|
sceneMaterials: Record<SceneMaterialId, SceneMaterial> | undefined,
|
|
shading: RenderShading = 'rendered',
|
|
): THREE.Material | null {
|
|
const parsed = parseMaterialRef(ref)
|
|
if (!parsed) return null
|
|
if (parsed.kind === 'library') return createMaterialFromPresetRef(ref, shading)
|
|
const sceneMaterial = sceneMaterials?.[parsed.id as SceneMaterialId]
|
|
if (!sceneMaterial) return null
|
|
return createMaterial(sceneMaterial.material, shading)
|
|
}
|
|
|
|
/**
|
|
* Resolve a node kind's declared slot default — either a catalog `library:<id>`
|
|
* finish or a flat `#rrggbb` colour — to a renderable material. Shared by the
|
|
* procedural kinds whose colored-mode unpainted appearance comes from a
|
|
* declarative default (slab, wall).
|
|
*/
|
|
export function resolveSlotDefaultMaterial(
|
|
slotDefault: string,
|
|
shading: RenderShading = 'rendered',
|
|
roughness = 0.9,
|
|
): THREE.Material {
|
|
if (parseMaterialRef(slotDefault)?.kind === 'library') {
|
|
return (
|
|
createMaterialFromPresetRef(slotDefault, shading) ??
|
|
createDefaultMaterial('#ffffff', roughness, shading)
|
|
)
|
|
}
|
|
return createDefaultMaterial(slotDefault, roughness, shading)
|
|
}
|
|
|
|
export function createDefaultMaterial(
|
|
color = '#ffffff',
|
|
roughness = 0.9,
|
|
shading: RenderShading = 'rendered',
|
|
side: THREE.Side = THREE.FrontSide,
|
|
): THREE.Material {
|
|
const resolvedSide = resolveNodeMaterialSide(side)
|
|
if (shading === 'solid') {
|
|
return new MeshLambertNodeMaterial({
|
|
color,
|
|
side: resolvedSide,
|
|
})
|
|
}
|
|
|
|
return new MeshStandardNodeMaterial({
|
|
color,
|
|
roughness,
|
|
metalness: 0,
|
|
side: resolvedSide,
|
|
})
|
|
}
|
|
|
|
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 : resolveNodeMaterialSide(side ?? THREE.FrontSide)
|
|
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()
|
|
}
|