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:
@@ -1,4 +1,4 @@
|
||||
import { type CeilingNode, resolveMaterial, useRegistry } from '@pascal-app/core'
|
||||
import { type CeilingNode, getMaterialPresetByRef, resolveMaterial, useRegistry } from '@pascal-app/core'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { float, mix, positionWorld, smoothstep } from 'three/tsl'
|
||||
import { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
@@ -39,10 +39,11 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
|
||||
const handlers = useNodeEvents(node, 'ceiling')
|
||||
|
||||
const materials = useMemo(() => {
|
||||
const props = resolveMaterial(node.material)
|
||||
const preset = getMaterialPresetByRef(node.materialPreset)
|
||||
const props = preset?.mapProperties ?? resolveMaterial(node.material)
|
||||
const color = props.color || '#999999'
|
||||
return createCeilingMaterials(color)
|
||||
}, [node.material, node.material?.preset, node.material?.properties, node.material?.texture])
|
||||
}, [node.materialPreset, node.material, node.material?.preset, node.material?.properties, node.material?.texture])
|
||||
|
||||
return (
|
||||
<mesh material={materials.bottomMaterial} ref={ref}>
|
||||
|
||||
@@ -2,12 +2,28 @@ import { type FenceNode, useRegistry, useScene } from '@pascal-app/core'
|
||||
import { useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import type { Mesh } from 'three'
|
||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||
import { DEFAULT_STAIR_MATERIAL } from '../../../lib/materials'
|
||||
import {
|
||||
createMaterial,
|
||||
createMaterialFromPresetRef,
|
||||
DEFAULT_STAIR_MATERIAL,
|
||||
} from '../../../lib/materials'
|
||||
|
||||
export const FenceRenderer = ({ node }: { node: FenceNode }) => {
|
||||
const ref = useRef<Mesh>(null!)
|
||||
const handlers = useNodeEvents(node, 'fence')
|
||||
const material = useMemo(() => DEFAULT_STAIR_MATERIAL, [])
|
||||
const material = useMemo(() => {
|
||||
const presetMaterial = createMaterialFromPresetRef(node.materialPreset)
|
||||
if (presetMaterial) return presetMaterial
|
||||
const mat = node.material
|
||||
if (!mat) return DEFAULT_STAIR_MATERIAL
|
||||
return createMaterial(mat)
|
||||
}, [
|
||||
node.materialPreset,
|
||||
node.material,
|
||||
node.material?.preset,
|
||||
node.material?.properties,
|
||||
node.material?.texture,
|
||||
])
|
||||
|
||||
useRegistry(node.id, 'fence', ref)
|
||||
useLayoutEffect(() => {
|
||||
|
||||
@@ -1,24 +1,43 @@
|
||||
import { type RoofSegmentNode, useRegistry } from '@pascal-app/core'
|
||||
import { type AnyNodeId, type RoofNode, type RoofSegmentNode, useRegistry, useScene } from '@pascal-app/core'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import type * as THREE from 'three'
|
||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||
import { createMaterial } from '../../../lib/materials'
|
||||
import { createMaterial, createMaterialFromPresetRef } from '../../../lib/materials'
|
||||
import useViewer from '../../../store/use-viewer'
|
||||
import { roofDebugMaterials, roofMaterials } from '../roof/roof-materials'
|
||||
|
||||
export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => {
|
||||
const ref = useRef<THREE.Mesh>(null!)
|
||||
const nodes = useScene((state) => state.nodes)
|
||||
|
||||
useRegistry(node.id, 'roof-segment', ref)
|
||||
|
||||
const handlers = useNodeEvents(node, 'roof-segment')
|
||||
const debugColors = useViewer((s) => s.debugColors)
|
||||
const parentNode =
|
||||
node.parentId ? (nodes[node.parentId as AnyNodeId] as RoofNode | undefined) : undefined
|
||||
|
||||
const customMaterial = useMemo(() => {
|
||||
const mat = node.material
|
||||
const effectiveMaterialPreset = node.materialPreset ?? parentNode?.materialPreset
|
||||
const effectiveMaterial = node.material ?? parentNode?.material
|
||||
|
||||
const presetMaterial = createMaterialFromPresetRef(effectiveMaterialPreset)
|
||||
if (presetMaterial) return presetMaterial
|
||||
const mat = effectiveMaterial
|
||||
if (!mat) return null
|
||||
return createMaterial(mat)
|
||||
}, [node.material, node.material?.preset, node.material?.properties, node.material?.texture])
|
||||
}, [
|
||||
node.materialPreset,
|
||||
node.material,
|
||||
node.material?.preset,
|
||||
node.material?.properties,
|
||||
node.material?.texture,
|
||||
parentNode?.materialPreset,
|
||||
parentNode?.material,
|
||||
parentNode?.material?.preset,
|
||||
parentNode?.material?.properties,
|
||||
parentNode?.material?.texture,
|
||||
])
|
||||
|
||||
const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { type RoofNode, useRegistry } from '@pascal-app/core'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import type * as THREE from 'three'
|
||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||
import { createMaterial } from '../../../lib/materials'
|
||||
import { createMaterial, createMaterialFromPresetRef } from '../../../lib/materials'
|
||||
import useViewer from '../../../store/use-viewer'
|
||||
import { NodeRenderer } from '../node-renderer'
|
||||
import { roofDebugMaterials, roofMaterials } from './roof-materials'
|
||||
@@ -16,10 +16,12 @@ export const RoofRenderer = ({ node }: { node: RoofNode }) => {
|
||||
const debugColors = useViewer((s) => s.debugColors)
|
||||
|
||||
const customMaterial = useMemo(() => {
|
||||
const presetMaterial = createMaterialFromPresetRef(node.materialPreset)
|
||||
if (presetMaterial) return presetMaterial
|
||||
const mat = node.material
|
||||
if (!mat) return null
|
||||
return createMaterial(mat)
|
||||
}, [node.material, node.material?.preset, node.material?.properties, node.material?.texture])
|
||||
}, [node.materialPreset, node.material, node.material?.preset, node.material?.properties, node.material?.texture])
|
||||
|
||||
const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials
|
||||
|
||||
|
||||
@@ -2,7 +2,11 @@ import { type SlabNode, useRegistry } from '@pascal-app/core'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import type { Mesh } from 'three'
|
||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||
import { createMaterial, DEFAULT_SLAB_MATERIAL } from '../../../lib/materials'
|
||||
import {
|
||||
createMaterial,
|
||||
createMaterialFromPresetRef,
|
||||
DEFAULT_SLAB_MATERIAL,
|
||||
} from '../../../lib/materials'
|
||||
|
||||
export const SlabRenderer = ({ node }: { node: SlabNode }) => {
|
||||
const ref = useRef<Mesh>(null!)
|
||||
@@ -12,10 +16,18 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => {
|
||||
const handlers = useNodeEvents(node, 'slab')
|
||||
|
||||
const material = useMemo(() => {
|
||||
const presetMaterial = createMaterialFromPresetRef(node.materialPreset)
|
||||
if (presetMaterial) return presetMaterial
|
||||
const mat = node.material
|
||||
if (!mat) return DEFAULT_SLAB_MATERIAL
|
||||
return createMaterial(mat)
|
||||
}, [node.material, node.material?.preset, node.material?.properties, node.material?.texture])
|
||||
}, [
|
||||
node.material,
|
||||
node.material?.preset,
|
||||
node.material?.properties,
|
||||
node.material?.texture,
|
||||
node.materialPreset,
|
||||
])
|
||||
|
||||
return (
|
||||
<mesh
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { type StairSegmentNode, useRegistry, useScene } from '@pascal-app/core'
|
||||
import { type AnyNodeId, type StairNode, type StairSegmentNode, useRegistry, useScene } from '@pascal-app/core'
|
||||
import { useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import type * as THREE from 'three'
|
||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||
import { createMaterial, DEFAULT_STAIR_MATERIAL } from '../../../lib/materials'
|
||||
import { createMaterial, createMaterialFromPresetRef, DEFAULT_STAIR_MATERIAL } from '../../../lib/materials'
|
||||
|
||||
export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => {
|
||||
const ref = useRef<THREE.Mesh>(null!)
|
||||
const nodes = useScene((state) => state.nodes)
|
||||
|
||||
useRegistry(node.id, 'stair-segment', ref)
|
||||
|
||||
@@ -14,12 +15,30 @@ export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => {
|
||||
}, [node.id])
|
||||
|
||||
const handlers = useNodeEvents(node, 'stair-segment')
|
||||
const parentNode =
|
||||
node.parentId ? (nodes[node.parentId as AnyNodeId] as StairNode | undefined) : undefined
|
||||
|
||||
const material = useMemo(() => {
|
||||
const mat = node.material
|
||||
const effectiveMaterialPreset = node.materialPreset ?? parentNode?.materialPreset
|
||||
const effectiveMaterial = node.material ?? parentNode?.material
|
||||
|
||||
const presetMaterial = createMaterialFromPresetRef(effectiveMaterialPreset)
|
||||
if (presetMaterial) return presetMaterial
|
||||
const mat = effectiveMaterial
|
||||
if (!mat) return DEFAULT_STAIR_MATERIAL
|
||||
return createMaterial(mat)
|
||||
}, [node.material, node.material?.preset, node.material?.properties, node.material?.texture])
|
||||
}, [
|
||||
node.materialPreset,
|
||||
node.material,
|
||||
node.material?.preset,
|
||||
node.material?.properties,
|
||||
node.material?.texture,
|
||||
parentNode?.materialPreset,
|
||||
parentNode?.material,
|
||||
parentNode?.material?.preset,
|
||||
parentNode?.material?.properties,
|
||||
parentNode?.material?.texture,
|
||||
])
|
||||
|
||||
return (
|
||||
<mesh
|
||||
|
||||
@@ -2,7 +2,7 @@ import { type AnyNodeId, type StairNode, type StairSegmentNode, useRegistry, use
|
||||
import { useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||
import { createMaterial, DEFAULT_STAIR_MATERIAL } from '../../../lib/materials'
|
||||
import { createMaterial, createMaterialFromPresetRef, DEFAULT_STAIR_MATERIAL } from '../../../lib/materials'
|
||||
import { NodeRenderer } from '../node-renderer'
|
||||
|
||||
type SegmentTransform = {
|
||||
@@ -47,10 +47,12 @@ export const StairRenderer = ({ node }: { node: StairNode }) => {
|
||||
const handlers = useNodeEvents(node, 'stair')
|
||||
|
||||
const material = useMemo(() => {
|
||||
const presetMaterial = createMaterialFromPresetRef(node.materialPreset)
|
||||
if (presetMaterial) return presetMaterial
|
||||
const mat = node.material
|
||||
if (!mat) return DEFAULT_STAIR_MATERIAL
|
||||
return createMaterial(mat)
|
||||
}, [node.material, node.material?.preset, node.material?.properties, node.material?.texture])
|
||||
}, [node.materialPreset, node.material, node.material?.preset, node.material?.properties, node.material?.texture])
|
||||
|
||||
return (
|
||||
<group
|
||||
|
||||
@@ -2,7 +2,11 @@ import { useRegistry, useScene, type WallNode } from '@pascal-app/core'
|
||||
import { useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import type { Mesh } from 'three'
|
||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||
import { createMaterial, DEFAULT_WALL_MATERIAL } from '../../../lib/materials'
|
||||
import {
|
||||
createMaterial,
|
||||
createMaterialFromPresetRef,
|
||||
DEFAULT_WALL_MATERIAL,
|
||||
} from '../../../lib/materials'
|
||||
import { NodeRenderer } from '../node-renderer'
|
||||
|
||||
export const WallRenderer = ({ node }: { node: WallNode }) => {
|
||||
@@ -17,10 +21,18 @@ export const WallRenderer = ({ node }: { node: WallNode }) => {
|
||||
const handlers = useNodeEvents(node, 'wall')
|
||||
|
||||
const material = useMemo(() => {
|
||||
const presetMaterial = createMaterialFromPresetRef(node.materialPreset)
|
||||
if (presetMaterial) return presetMaterial
|
||||
const mat = node.material
|
||||
if (!mat) return DEFAULT_WALL_MATERIAL
|
||||
return createMaterial(mat)
|
||||
}, [node.material, node.material?.preset, node.material?.properties, node.material?.texture])
|
||||
}, [
|
||||
node.material,
|
||||
node.material?.preset,
|
||||
node.material?.properties,
|
||||
node.material?.texture,
|
||||
node.materialPreset,
|
||||
])
|
||||
|
||||
return (
|
||||
<mesh castShadow material={material} receiveShadow ref={ref} visible={node.visible}>
|
||||
|
||||
@@ -310,6 +310,10 @@ const PostProcessingPasses = () => {
|
||||
bgCurrent.current.lerp(bgTarget.current, Math.min(delta, 0.1) * 4)
|
||||
bgUniform.current.value.copy(bgCurrent.current)
|
||||
|
||||
if (!isInitialized) {
|
||||
return
|
||||
}
|
||||
|
||||
if (hasPipelineErrorRef.current || !renderPipelineRef.current) {
|
||||
try {
|
||||
if ((renderer as any).setClearAlpha) {
|
||||
|
||||
@@ -281,6 +281,10 @@ export const SelectionManager = () => {
|
||||
if (!strategy) return
|
||||
if (strategy.isValid(event.node)) {
|
||||
event.stopPropagation()
|
||||
if (event.node.type === 'slab') {
|
||||
useViewer.setState({ hoveredId: null })
|
||||
return
|
||||
}
|
||||
useViewer.setState({ hoveredId: event.node.id })
|
||||
}
|
||||
}
|
||||
@@ -388,11 +392,14 @@ const OutlinerSync = () => {
|
||||
const selection = useViewer((s) => s.selection)
|
||||
const hoveredId = useViewer((s) => s.hoveredId)
|
||||
const outliner = useViewer((s) => s.outliner)
|
||||
const nodes = useScene((s) => s.nodes)
|
||||
|
||||
useEffect(() => {
|
||||
// Sync selected objects
|
||||
outliner.selectedObjects.length = 0
|
||||
for (const id of selection.selectedIds) {
|
||||
const node = nodes[id as AnyNodeId]
|
||||
if (node?.type === 'slab') continue
|
||||
const obj = sceneRegistry.nodes.get(id)
|
||||
if (obj) outliner.selectedObjects.push(obj)
|
||||
}
|
||||
@@ -400,10 +407,12 @@ const OutlinerSync = () => {
|
||||
// Sync hovered objects
|
||||
outliner.hoveredObjects.length = 0
|
||||
if (hoveredId) {
|
||||
const hoveredNode = nodes[hoveredId as AnyNodeId]
|
||||
if (hoveredNode?.type === 'slab') return
|
||||
const obj = sceneRegistry.nodes.get(hoveredId)
|
||||
if (obj) outliner.hoveredObjects.push(obj)
|
||||
}
|
||||
}, [selection, hoveredId, outliner])
|
||||
}, [selection, hoveredId, outliner, nodes])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
type AnyNodeId,
|
||||
baseMaterial,
|
||||
emitter,
|
||||
getMaterialPresetByRef,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
type WallNode,
|
||||
@@ -13,6 +14,7 @@ import { Color } from 'three'
|
||||
import { Fn, float, fract, length, mix, positionLocal, smoothstep, step, vec2 } from 'three/tsl'
|
||||
import { type Mesh, MeshStandardNodeMaterial, Vector3 } from 'three/webgpu'
|
||||
import useViewer from '../../store/use-viewer'
|
||||
import { createMaterial, createMaterialFromPresetRef } from '../../lib/materials'
|
||||
|
||||
const tmpVec = new Vector3()
|
||||
const u = new Vector3()
|
||||
@@ -21,12 +23,14 @@ const DEFAULT_WALL_COLOR = '#f2f0ed'
|
||||
const WALL_HIGHLIGHT_PROFILES = {
|
||||
delete: {
|
||||
color: new Color('#dc2626'),
|
||||
blend: 0.78,
|
||||
blend: 0.76,
|
||||
emissiveBlend: 0.92,
|
||||
emissiveIntensity: 0.46,
|
||||
},
|
||||
selection: {
|
||||
color: new Color('#818cf8'),
|
||||
blend: 0.32,
|
||||
emissiveBlend: 0.7,
|
||||
emissiveIntensity: 0.42,
|
||||
},
|
||||
} as const
|
||||
@@ -51,11 +55,11 @@ const dotPattern = Fn(() => {
|
||||
})
|
||||
|
||||
interface WallMaterials {
|
||||
visible: MeshStandardNodeMaterial
|
||||
visible: Material
|
||||
invisible: MeshStandardNodeMaterial
|
||||
deleteVisible: MeshStandardNodeMaterial
|
||||
deleteVisible: Material
|
||||
deleteInvisible: MeshStandardNodeMaterial
|
||||
highlightedVisible: MeshStandardNodeMaterial
|
||||
highlightedVisible: Material
|
||||
highlightedInvisible: MeshStandardNodeMaterial
|
||||
materialHash: string
|
||||
}
|
||||
@@ -75,6 +79,7 @@ const presetColors = {
|
||||
} as const
|
||||
|
||||
function getMaterialHash(wallNode: WallNode): string {
|
||||
if (wallNode.materialPreset) return `preset-ref-${wallNode.materialPreset}`
|
||||
if (!wallNode.material) return 'none'
|
||||
const mat = wallNode.material
|
||||
if (mat.preset && mat.preset !== 'custom') {
|
||||
@@ -90,27 +95,54 @@ function getPresetColor(preset: string): string {
|
||||
return presetColors[preset as keyof typeof presetColors] ?? '#ffffff'
|
||||
}
|
||||
|
||||
function getHighlightedColor(color: string, kind: WallHighlightKind): Color {
|
||||
function getHighlightedColor(color: Color, kind: WallHighlightKind): Color {
|
||||
const profile = WALL_HIGHLIGHT_PROFILES[kind]
|
||||
return new Color(color).lerp(profile.color, profile.blend)
|
||||
return color.clone().lerp(profile.color, profile.blend)
|
||||
}
|
||||
|
||||
function createHighlightedWallMaterial(
|
||||
material: MeshStandardNodeMaterial,
|
||||
baseColor: string,
|
||||
material: Material,
|
||||
kind: WallHighlightKind,
|
||||
): MeshStandardNodeMaterial {
|
||||
const highlightedMaterial = material.clone()
|
||||
const highlightedColor = getHighlightedColor(baseColor, kind)
|
||||
): Material {
|
||||
const highlightedMaterial = material.clone() as Material & {
|
||||
color?: Color
|
||||
emissive?: Color
|
||||
emissiveIntensity?: number
|
||||
needsUpdate?: boolean
|
||||
}
|
||||
const profile = WALL_HIGHLIGHT_PROFILES[kind]
|
||||
|
||||
highlightedMaterial.color = highlightedColor
|
||||
highlightedMaterial.emissive = highlightedColor.clone()
|
||||
highlightedMaterial.emissiveIntensity = profile.emissiveIntensity
|
||||
if ('color' in highlightedMaterial && highlightedMaterial.color) {
|
||||
highlightedMaterial.color = getHighlightedColor(highlightedMaterial.color, kind)
|
||||
}
|
||||
if ('emissive' in highlightedMaterial && highlightedMaterial.emissive) {
|
||||
highlightedMaterial.emissive = highlightedMaterial.emissive
|
||||
.clone()
|
||||
.lerp(profile.color, profile.emissiveBlend)
|
||||
}
|
||||
if ('emissiveIntensity' in highlightedMaterial) {
|
||||
highlightedMaterial.emissiveIntensity = Math.max(
|
||||
highlightedMaterial.emissiveIntensity ?? 0,
|
||||
profile.emissiveIntensity,
|
||||
)
|
||||
}
|
||||
highlightedMaterial.needsUpdate = true
|
||||
|
||||
return highlightedMaterial
|
||||
}
|
||||
|
||||
function createBaseVisibleWallMaterial(wallNode: WallNode): Material {
|
||||
if (wallNode.materialPreset) {
|
||||
return createMaterialFromPresetRef(wallNode.materialPreset) ?? baseMaterial
|
||||
}
|
||||
|
||||
if (wallNode.material) {
|
||||
return createMaterial(wallNode.material)
|
||||
}
|
||||
|
||||
return baseMaterial
|
||||
}
|
||||
|
||||
function getMaterialsForWall(wallNode: WallNode): WallMaterials {
|
||||
const cacheKey = wallNode.id
|
||||
const materialHash = getMaterialHash(wallNode)
|
||||
@@ -130,19 +162,16 @@ function getMaterialsForWall(wallNode: WallNode): WallMaterials {
|
||||
}
|
||||
|
||||
let userColor = DEFAULT_WALL_COLOR
|
||||
if (wallNode.material?.properties?.color) {
|
||||
const preset = getMaterialPresetByRef(wallNode.materialPreset)
|
||||
if (preset?.mapProperties?.color) {
|
||||
userColor = preset.mapProperties.color
|
||||
} else if (wallNode.material?.properties?.color) {
|
||||
userColor = wallNode.material.properties.color
|
||||
} else if (wallNode.material?.preset && wallNode.material.preset !== 'custom') {
|
||||
userColor = getPresetColor(wallNode.material.preset)
|
||||
}
|
||||
|
||||
const visibleMat = wallNode.material
|
||||
? new MeshStandardNodeMaterial({
|
||||
color: userColor,
|
||||
roughness: 1,
|
||||
metalness: 0,
|
||||
})
|
||||
: (baseMaterial.clone() as MeshStandardNodeMaterial)
|
||||
const visibleMat = createBaseVisibleWallMaterial(wallNode)
|
||||
|
||||
const invisibleMat = new MeshStandardNodeMaterial({
|
||||
transparent: true,
|
||||
@@ -152,10 +181,13 @@ function getMaterialsForWall(wallNode: WallNode): WallMaterials {
|
||||
emissive: userColor,
|
||||
})
|
||||
|
||||
const highlightedVisible = createHighlightedWallMaterial(visibleMat, userColor, 'selection')
|
||||
const highlightedInvisible = createHighlightedWallMaterial(invisibleMat, userColor, 'selection')
|
||||
const deleteVisible = createHighlightedWallMaterial(visibleMat, userColor, 'delete')
|
||||
const deleteInvisible = createHighlightedWallMaterial(invisibleMat, userColor, 'delete')
|
||||
const highlightedVisible = createHighlightedWallMaterial(visibleMat, 'selection')
|
||||
const highlightedInvisible = createHighlightedWallMaterial(
|
||||
invisibleMat,
|
||||
'selection',
|
||||
) as MeshStandardNodeMaterial
|
||||
const deleteVisible = createHighlightedWallMaterial(visibleMat, 'delete')
|
||||
const deleteInvisible = createHighlightedWallMaterial(invisibleMat, 'delete') as MeshStandardNodeMaterial
|
||||
|
||||
const result: WallMaterials = {
|
||||
visible: visibleMat,
|
||||
@@ -170,6 +202,10 @@ function getMaterialsForWall(wallNode: WallNode): WallMaterials {
|
||||
return result
|
||||
}
|
||||
|
||||
function getVisibleWallMaterial(wallNode: WallNode): Material {
|
||||
return createBaseVisibleWallMaterial(wallNode)
|
||||
}
|
||||
|
||||
function getWallHideState(
|
||||
wallNode: WallNode,
|
||||
wallMesh: Mesh,
|
||||
@@ -265,9 +301,7 @@ export const WallCutout = () => {
|
||||
? materials.deleteVisible
|
||||
: isSelectionHighlighted
|
||||
? materials.highlightedVisible
|
||||
: wallNode.material
|
||||
? materials.visible
|
||||
: baseMaterial
|
||||
: getVisibleWallMaterial(wallNode)
|
||||
}
|
||||
})
|
||||
lastWallMode.current = wallMode
|
||||
@@ -289,7 +323,7 @@ export const WallCutout = () => {
|
||||
const current = wallMesh.material as Material
|
||||
snapshot.set(wallMesh, current)
|
||||
if (current === mats.highlightedVisible || current === mats.deleteVisible) {
|
||||
wallMesh.material = mats.visible
|
||||
wallMesh.material = getVisibleWallMaterial(wallNode)
|
||||
} else if (current === mats.highlightedInvisible || current === mats.deleteInvisible) {
|
||||
wallMesh.material = mats.invisible
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user