Add paint hover previews to viewer

This commit is contained in:
sudhir
2026-04-22 10:52:35 +05:30
parent 65399731f7
commit 87dfdf1b38
15 changed files with 638 additions and 156 deletions
+4 -2
View File
@@ -157,14 +157,16 @@ function migrateStairSurfaceMaterials(node: Record<string, any>) {
if (node.treadMaterial !== undefined || typeof node.treadMaterialPreset === 'string') { if (node.treadMaterial !== undefined || typeof node.treadMaterialPreset === 'string') {
return { return {
material: node.treadMaterial, material: node.treadMaterial,
materialPreset: typeof node.treadMaterialPreset === 'string' ? node.treadMaterialPreset : undefined, materialPreset:
typeof node.treadMaterialPreset === 'string' ? node.treadMaterialPreset : undefined,
} }
} }
if (node.sideMaterial !== undefined || typeof node.sideMaterialPreset === 'string') { if (node.sideMaterial !== undefined || typeof node.sideMaterialPreset === 'string') {
return { return {
material: node.sideMaterial, material: node.sideMaterial,
materialPreset: typeof node.sideMaterialPreset === 'string' ? node.sideMaterialPreset : undefined, materialPreset:
typeof node.sideMaterialPreset === 'string' ? node.sideMaterialPreset : undefined,
} }
} }
@@ -685,10 +685,12 @@ function PaintCursorLayer({
const activePaintMaterial = useEditor((s) => s.activePaintMaterial) const activePaintMaterial = useEditor((s) => s.activePaintMaterial)
const activePaintTarget = useEditor((s) => s.activePaintTarget) const activePaintTarget = useEditor((s) => s.activePaintTarget)
const paintDisabledFeedbackTick = useEditor((s) => s.paintDisabledFeedbackTick) const paintDisabledFeedbackTick = useEditor((s) => s.paintDisabledFeedbackTick)
const hoverHighlightMode = useViewer((s) => s.hoverHighlightMode)
const badgeRef = useRef<HTMLDivElement>(null) const badgeRef = useRef<HTMLDivElement>(null)
const lastPointerPositionRef = useRef<{ x: number; y: number } | null>(null) const lastPointerPositionRef = useRef<{ x: number; y: number } | null>(null)
const [showDisabledFeedback, setShowDisabledFeedback] = useState(false) const [showDisabledFeedback, setShowDisabledFeedback] = useState(false)
const active = mode === 'material-paint' && !isVersionPreviewMode const active = mode === 'material-paint' && !isVersionPreviewMode
const showBlockedState = showDisabledFeedback || hoverHighlightMode === 'paint-disabled'
useEffect(() => { useEffect(() => {
if (!active) { if (!active) {
@@ -748,10 +750,26 @@ function PaintCursorLayer({
) )
const label = !hasMaterial const label = !hasMaterial
? 'Choose material' ? 'Choose material'
: showDisabledFeedback : showBlockedState
? 'Unsupported' ? 'Unsupported'
: `Paint ${activePaintTarget}` : `Paint ${activePaintTarget}`
const icon = showDisabledFeedback ? 'mdi:block-helper' : 'mdi:format-color-fill' const icon = showBlockedState ? 'mdi:block-helper' : 'mdi:format-color-fill'
useEffect(() => {
if (!active) return
const el = containerRef.current
if (!el) return
if (hoverHighlightMode === 'paint-disabled') {
el.style.cursor = 'not-allowed'
return () => {
el.style.cursor = ''
}
}
el.style.cursor = ''
}, [active, containerRef, hoverHighlightMode])
useEffect(() => { useEffect(() => {
if (!paintDisabledFeedbackTick) return if (!paintDisabledFeedbackTick) return
@@ -797,7 +815,7 @@ function PaintCursorLayer({
style={{ display: 'none', position: 'absolute', left: 0, top: 0 }} style={{ display: 'none', position: 'absolute', left: 0, top: 0 }}
> >
<PaintCursorBadge <PaintCursorBadge
disabled={!hasMaterial || showDisabledFeedback} disabled={!hasMaterial || showBlockedState}
icon={icon} icon={icon}
label={label} label={label}
position={{ x: 0, y: 0 }} position={{ x: 0, y: 0 }}
@@ -70,6 +70,14 @@ type ModifierKeys = {
ctrl: boolean ctrl: boolean
} }
type SceneMaterialPreview = ReturnType<typeof useViewer.getState>['materialPreview']
type PaintInteraction = {
apply: (() => void) | null
preview: SceneMaterialPreview
hoveredId: AnyNodeId
}
interface SelectionStrategy { interface SelectionStrategy {
types: SelectableNodeType[] types: SelectableNodeType[]
handleSelect: (node: AnyNode, nativeEvent?: any, modifierKeys?: ModifierKeys) => void handleSelect: (node: AnyNode, nativeEvent?: any, modifierKeys?: ModifierKeys) => void
@@ -503,6 +511,32 @@ export const SelectionManager = () => {
if (movingNode || curvingWall) return if (movingNode || curvingWall) return
const triggerPaintDisabledFeedback = useEditor.getState().triggerPaintDisabledFeedback const triggerPaintDisabledFeedback = useEditor.getState().triggerPaintDisabledFeedback
let hoverFrame = 0
let pendingHoveredId: AnyNodeId | null = null
let pendingHoverMode: HoverHighlightMode = 'default'
let pendingPreview: SceneMaterialPreview = null
const flushHoverState = () => {
hoverFrame = 0
const viewerState = useViewer.getState()
if (viewerState.hoveredId !== pendingHoveredId) {
useViewer.setState({ hoveredId: pendingHoveredId })
}
setHoverHighlightMode(pendingHoverMode)
if (pendingPreview) {
viewerState.setMaterialPreview(pendingPreview)
} else {
viewerState.clearMaterialPreview()
}
}
const scheduleHoverState = () => {
if (hoverFrame !== 0) return
hoverFrame = window.requestAnimationFrame(flushHoverState)
}
const resolveActivePaintMaterial = () => const resolveActivePaintMaterial = () =>
useEditor.getState().activePaintMaterial ?? useEditor.getState().activePaintMaterial ??
@@ -515,11 +549,7 @@ export const SelectionManager = () => {
selectedMaterialTarget: useEditor.getState().selectedMaterialTarget, selectedMaterialTarget: useEditor.getState().selectedMaterialTarget,
}) })
const getPaintInteraction = ( const getPaintInteraction = (event: NodeEvent): PaintInteraction | null => {
event: NodeEvent,
): {
apply: (() => void) | null
} | null => {
const activePaintMaterial = resolveActivePaintMaterial() const activePaintMaterial = resolveActivePaintMaterial()
const node = event.node const node = event.node
@@ -530,6 +560,7 @@ export const SelectionManager = () => {
const compatible = const compatible =
role !== null && isActivePaintMaterialCompatible(activePaintMaterial, 'wall') role !== null && isActivePaintMaterialCompatible(activePaintMaterial, 'wall')
return { return {
hoveredId: node.id as AnyNodeId,
apply: apply:
compatible && hasActivePaintMaterial(activePaintMaterial) compatible && hasActivePaintMaterial(activePaintMaterial)
? () => { ? () => {
@@ -546,6 +577,16 @@ export const SelectionManager = () => {
) )
} }
: null, : null,
preview:
compatible && hasActivePaintMaterial(activePaintMaterial) && role
? {
nodeId: node.id as AnyNodeId,
target: 'wall',
role,
material: activePaintMaterial.material,
materialPreset: activePaintMaterial.materialPreset,
}
: null,
} }
} }
@@ -562,6 +603,7 @@ export const SelectionManager = () => {
const compatible = const compatible =
role !== null && isActivePaintMaterialCompatible(activePaintMaterial, 'roof') role !== null && isActivePaintMaterialCompatible(activePaintMaterial, 'roof')
return { return {
hoveredId: roofNode.id as AnyNodeId,
apply: apply:
compatible && hasActivePaintMaterial(activePaintMaterial) compatible && hasActivePaintMaterial(activePaintMaterial)
? () => { ? () => {
@@ -578,6 +620,16 @@ export const SelectionManager = () => {
) )
} }
: null, : null,
preview:
compatible && hasActivePaintMaterial(activePaintMaterial) && role
? {
nodeId: roofNode.id as AnyNodeId,
target: 'roof',
role,
material: activePaintMaterial.material,
materialPreset: activePaintMaterial.materialPreset,
}
: null,
} }
} }
@@ -594,6 +646,7 @@ export const SelectionManager = () => {
const compatible = const compatible =
role !== null && isActivePaintMaterialCompatible(activePaintMaterial, 'stair') role !== null && isActivePaintMaterialCompatible(activePaintMaterial, 'stair')
return { return {
hoveredId: stairNode.id as AnyNodeId,
apply: apply:
compatible && hasActivePaintMaterial(activePaintMaterial) compatible && hasActivePaintMaterial(activePaintMaterial)
? () => { ? () => {
@@ -610,6 +663,16 @@ export const SelectionManager = () => {
) )
} }
: null, : null,
preview:
compatible && hasActivePaintMaterial(activePaintMaterial) && role
? {
nodeId: stairNode.id as AnyNodeId,
target: 'stair',
role,
material: activePaintMaterial.material,
materialPreset: activePaintMaterial.materialPreset,
}
: null,
} }
} }
@@ -620,6 +683,7 @@ export const SelectionManager = () => {
hasActivePaintMaterial(activePaintMaterial) hasActivePaintMaterial(activePaintMaterial)
return { return {
hoveredId: node.id as AnyNodeId,
apply: compatible apply: compatible
? () => { ? () => {
useScene useScene
@@ -633,19 +697,62 @@ export const SelectionManager = () => {
) )
} }
: null, : null,
preview: compatible
? {
nodeId: node.id as AnyNodeId,
target,
role: 'surface',
material: activePaintMaterial.material,
materialPreset: activePaintMaterial.materialPreset,
}
: null,
} }
} }
const disabledNodeTypes = ['item', 'window', 'door', 'zone'] const disabledNodeTypes = ['item', 'window', 'door', 'zone']
if (disabledNodeTypes.includes(node.type)) { if (disabledNodeTypes.includes(node.type)) {
return { return {
hoveredId: node.id as AnyNodeId,
apply: null, apply: null,
preview: null,
} }
} }
return null return null
} }
const onEnter = (event: NodeEvent) => {
if (boxSelectHandled) return
const interaction = getPaintInteraction(event)
if (!interaction) return
event.stopPropagation()
if (!interaction.preview) {
pendingHoveredId = interaction.hoveredId
pendingHoverMode = 'paint-disabled'
pendingPreview = null
scheduleHoverState()
return
}
pendingHoveredId = interaction.hoveredId
pendingHoverMode = 'paint-ready'
pendingPreview = interaction.preview
scheduleHoverState()
}
const onLeave = (event: NodeEvent) => {
const interaction = getPaintInteraction(event)
if (!interaction) return
pendingHoveredId = null
pendingHoverMode = 'default'
pendingPreview = null
scheduleHoverState()
}
const onClick = (event: NodeEvent) => { const onClick = (event: NodeEvent) => {
if (boxSelectHandled) return if (boxSelectHandled) return
@@ -654,12 +761,19 @@ export const SelectionManager = () => {
event.stopPropagation() event.stopPropagation()
if (hoverFrame !== 0) {
window.cancelAnimationFrame(hoverFrame)
flushHoverState()
}
if (!interaction.apply) { if (!interaction.apply) {
useViewer.getState().clearMaterialPreview()
triggerPaintDisabledFeedback() triggerPaintDisabledFeedback()
return return
} }
interaction.apply() interaction.apply()
useViewer.getState().clearMaterialPreview()
} }
const allTypes = [ const allTypes = [
@@ -679,14 +793,24 @@ export const SelectionManager = () => {
for (const type of allTypes) { for (const type of allTypes) {
emitter.on(`${type}:click` as any, onClick as any) emitter.on(`${type}:click` as any, onClick as any)
emitter.on(`${type}:enter` as any, onEnter as any)
emitter.on(`${type}:leave` as any, onLeave as any)
} }
return () => { return () => {
for (const type of allTypes) { for (const type of allTypes) {
emitter.off(`${type}:click` as any, onClick as any) emitter.off(`${type}:click` as any, onClick as any)
emitter.off(`${type}:enter` as any, onEnter as any)
emitter.off(`${type}:leave` as any, onLeave as any)
} }
if (hoverFrame !== 0) {
window.cancelAnimationFrame(hoverFrame)
}
useViewer.setState({ hoveredId: null })
setHoverHighlightMode('default')
useViewer.getState().clearMaterialPreview()
} }
}, [curvingWall, mode, movingNode]) }, [curvingWall, mode, movingNode, setHoverHighlightMode])
useEffect(() => { useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
@@ -1241,7 +1365,8 @@ const SelectionMaterialSync = () => {
}, [hoverHighlightMode, hoveredId, previewSelectedIds, selectedIds, syncSelectionMaterials]) }, [hoverHighlightMode, hoveredId, previewSelectedIds, selectedIds, syncSelectionMaterials])
useEffect(() => { useEffect(() => {
return useScene.subscribe(() => { return useScene.subscribe((state, prevState) => {
if (state.nodes === prevState.nodes) return
syncSelectionMaterials() syncSelectionMaterials()
}) })
}, [syncSelectionMaterials]) }, [syncSelectionMaterials])
@@ -1,8 +1,14 @@
import { type CeilingNode, getMaterialPresetByRef, resolveMaterial, useRegistry } from '@pascal-app/core' import {
type CeilingNode,
getMaterialPresetByRef,
resolveMaterial,
useRegistry,
} from '@pascal-app/core'
import { useMemo, useRef } from 'react' import { useMemo, useRef } from 'react'
import { float, mix, positionWorld, smoothstep } from 'three/tsl' import { float, mix, positionWorld, smoothstep } from 'three/tsl'
import { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu' import { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu'
import { useNodeEvents } from '../../../hooks/use-node-events' import { useNodeEvents } from '../../../hooks/use-node-events'
import useViewer from '../../../store/use-viewer'
import { NodeRenderer } from '../node-renderer' import { NodeRenderer } from '../node-renderer'
const gridScale = 5 const gridScale = 5
@@ -32,18 +38,47 @@ function createCeilingMaterials(color = '#999999') {
return { topMaterial, bottomMaterial } return { topMaterial, bottomMaterial }
} }
const ceilingMaterialCache = new Map<string, ReturnType<typeof createCeilingMaterials>>()
function getCeilingMaterials(color = '#999999') {
const cacheKey = color
const cached = ceilingMaterialCache.get(cacheKey)
if (cached) return cached
const materials = createCeilingMaterials(color)
ceilingMaterialCache.set(cacheKey, materials)
return materials
}
export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
const ref = useRef<Mesh>(null!) const ref = useRef<Mesh>(null!)
useRegistry(node.id, 'ceiling', ref) useRegistry(node.id, 'ceiling', ref)
const handlers = useNodeEvents(node, 'ceiling') const handlers = useNodeEvents(node, 'ceiling')
const materialPreview = useViewer((state) =>
state.materialPreview?.target === 'ceiling' && state.materialPreview.nodeId === node.id
? state.materialPreview
: null,
)
const materials = useMemo(() => { const materials = useMemo(() => {
const preset = getMaterialPresetByRef(node.materialPreset) const preset = getMaterialPresetByRef(materialPreview?.materialPreset ?? node.materialPreset)
const props = preset?.mapProperties ?? resolveMaterial(node.material) const props =
preset?.mapProperties ?? resolveMaterial(materialPreview?.material ?? node.material)
const color = props.color || '#999999' const color = props.color || '#999999'
return createCeilingMaterials(color) return getCeilingMaterials(color)
}, [node.materialPreset, node.material, node.material?.preset, node.material?.properties, node.material?.texture]) }, [
node.materialPreset,
node.material,
node.material?.preset,
node.material?.properties,
node.material?.texture,
materialPreview?.materialPreset,
materialPreview?.material,
materialPreview?.material?.preset,
materialPreview?.material?.properties,
materialPreview?.material?.texture,
])
return ( return (
<mesh material={materials.bottomMaterial} ref={ref}> <mesh material={materials.bottomMaterial} ref={ref}>
@@ -7,14 +7,22 @@ import {
createMaterialFromPresetRef, createMaterialFromPresetRef,
DEFAULT_STAIR_MATERIAL, DEFAULT_STAIR_MATERIAL,
} from '../../../lib/materials' } from '../../../lib/materials'
import useViewer from '../../../store/use-viewer'
export const FenceRenderer = ({ node }: { node: FenceNode }) => { export const FenceRenderer = ({ node }: { node: FenceNode }) => {
const ref = useRef<Mesh>(null!) const ref = useRef<Mesh>(null!)
const handlers = useNodeEvents(node, 'fence') const handlers = useNodeEvents(node, 'fence')
const materialPreview = useViewer((state) =>
state.materialPreview?.target === 'fence' && state.materialPreview.nodeId === node.id
? state.materialPreview
: null,
)
const material = useMemo(() => { const material = useMemo(() => {
const presetMaterial = createMaterialFromPresetRef(node.materialPreset) const presetMaterial = createMaterialFromPresetRef(
materialPreview?.materialPreset ?? node.materialPreset,
)
if (presetMaterial) return presetMaterial if (presetMaterial) return presetMaterial
const mat = node.material const mat = materialPreview?.material ?? node.material
if (!mat) return DEFAULT_STAIR_MATERIAL if (!mat) return DEFAULT_STAIR_MATERIAL
return createMaterial(mat) return createMaterial(mat)
}, [ }, [
@@ -23,6 +31,11 @@ export const FenceRenderer = ({ node }: { node: FenceNode }) => {
node.material?.preset, node.material?.preset,
node.material?.properties, node.material?.properties,
node.material?.texture, node.material?.texture,
materialPreview?.materialPreset,
materialPreview?.material,
materialPreview?.material?.preset,
materialPreview?.material?.properties,
materialPreview?.material?.texture,
]) ])
useRegistry(node.id, 'fence', ref) useRegistry(node.id, 'fence', ref)
@@ -31,7 +44,14 @@ export const FenceRenderer = ({ node }: { node: FenceNode }) => {
}, [node.id]) }, [node.id])
return ( return (
<mesh castShadow material={material} receiveShadow ref={ref} visible={node.visible} {...handlers}> <mesh
castShadow
material={material}
receiveShadow
ref={ref}
visible={node.visible}
{...handlers}
>
<boxGeometry args={[0, 0, 0]} /> <boxGeometry args={[0, 0, 0]} />
</mesh> </mesh>
) )
@@ -1,4 +1,10 @@
import { type AnyNodeId, type RoofNode, type RoofSegmentNode, useRegistry, useScene } from '@pascal-app/core' import {
type AnyNodeId,
type RoofNode,
type RoofSegmentNode,
useRegistry,
useScene,
} from '@pascal-app/core'
import { useEffect, useMemo, useRef } from 'react' import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events' import { useNodeEvents } from '../../../hooks/use-node-events'
@@ -14,8 +20,14 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => {
const handlers = useNodeEvents(node, 'roof-segment') const handlers = useNodeEvents(node, 'roof-segment')
const debugColors = useViewer((s) => s.debugColors) const debugColors = useViewer((s) => s.debugColors)
const parentNode = const parentNode = node.parentId
node.parentId ? (nodes[node.parentId as AnyNodeId] as RoofNode | undefined) : undefined ? (nodes[node.parentId as AnyNodeId] as RoofNode | undefined)
: undefined
const materialPreview = useViewer((state) =>
state.materialPreview?.target === 'roof' && state.materialPreview.nodeId === parentNode?.id
? state.materialPreview
: null,
)
const placeholderGeometry = useMemo(() => { const placeholderGeometry = useMemo(() => {
const geometry = new THREE.BufferGeometry() const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3)) geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
@@ -26,30 +38,40 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => {
return geometry return geometry
}, []) }, [])
const previewParentNode = !parentNode
? undefined
: materialPreview?.role === 'top'
? {
...parentNode,
topMaterial: materialPreview.material,
topMaterialPreset: materialPreview.materialPreset,
material: undefined,
materialPreset: undefined,
}
: materialPreview?.role === 'edge'
? {
...parentNode,
edgeMaterial: materialPreview.material,
edgeMaterialPreset: materialPreview.materialPreset,
material: undefined,
materialPreset: undefined,
}
: materialPreview?.role === 'wall'
? {
...parentNode,
wallMaterial: materialPreview.material,
wallMaterialPreset: materialPreview.materialPreset,
material: undefined,
materialPreset: undefined,
}
: parentNode
const customMaterial = useMemo(() => { const customMaterial = useMemo(() => {
if (node.material !== undefined || typeof node.materialPreset === 'string') { if (node.material !== undefined || typeof node.materialPreset === 'string') {
return null return null
} }
return parentNode ? getRoofMaterialArray(parentNode) : null return previewParentNode ? getRoofMaterialArray(previewParentNode) : null
}, [ }, [node, previewParentNode])
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,
parentNode?.topMaterial,
parentNode?.topMaterialPreset,
parentNode?.edgeMaterial,
parentNode?.edgeMaterialPreset,
parentNode?.wallMaterial,
parentNode?.wallMaterialPreset,
])
const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials
@@ -14,6 +14,11 @@ export const RoofRenderer = ({ node }: { node: RoofNode }) => {
const handlers = useNodeEvents(node, 'roof') const handlers = useNodeEvents(node, 'roof')
const debugColors = useViewer((s) => s.debugColors) const debugColors = useViewer((s) => s.debugColors)
const materialPreview = useViewer((state) =>
state.materialPreview?.target === 'roof' && state.materialPreview.nodeId === node.id
? state.materialPreview
: null,
)
const placeholderGeometry = useMemo(() => { const placeholderGeometry = useMemo(() => {
const geometry = new THREE.BufferGeometry() const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3)) geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
@@ -24,22 +29,33 @@ export const RoofRenderer = ({ node }: { node: RoofNode }) => {
return geometry return geometry
}, []) }, [])
const customMaterial = useMemo( const previewNode =
() => getRoofMaterialArray(node), materialPreview?.role === 'top'
[ ? {
node.materialPreset, ...node,
node.material, topMaterial: materialPreview.material,
node.material?.preset, topMaterialPreset: materialPreview.materialPreset,
node.material?.properties, material: undefined,
node.material?.texture, materialPreset: undefined,
node.topMaterial, }
node.topMaterialPreset, : materialPreview?.role === 'edge'
node.edgeMaterial, ? {
node.edgeMaterialPreset, ...node,
node.wallMaterial, edgeMaterial: materialPreview.material,
node.wallMaterialPreset, edgeMaterialPreset: materialPreview.materialPreset,
], material: undefined,
) materialPreset: undefined,
}
: materialPreview?.role === 'wall'
? {
...node,
wallMaterial: materialPreview.material,
wallMaterialPreset: materialPreview.materialPreset,
material: undefined,
materialPreset: undefined,
}
: node
const customMaterial = useMemo(() => getRoofMaterialArray(previewNode), [previewNode])
const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials
@@ -1,13 +1,50 @@
import { getMaterialPresetByRef, type SlabNode, useRegistry } from '@pascal-app/core' import { getMaterialPresetByRef, type SlabNode, useRegistry } from '@pascal-app/core'
import { useEffect, useMemo, useRef } from 'react' import { useMemo, useRef } from 'react'
import * as THREE from 'three'
import type { Mesh } from 'three' import type { Mesh } from 'three'
import * as THREE from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events' import { useNodeEvents } from '../../../hooks/use-node-events'
import { import {
applyMaterialPresetToMaterials, applyMaterialPresetToMaterials,
createMaterial, createMaterial,
DEFAULT_SLAB_MATERIAL, DEFAULT_SLAB_MATERIAL,
} from '../../../lib/materials' } from '../../../lib/materials'
import useViewer from '../../../store/use-viewer'
const slabMaterialCache = new Map<string, THREE.MeshStandardMaterial>()
function getSlabMaterial(
cacheKey: string,
params: { material?: SlabNode['material']; materialPreset?: string },
) {
const cached = slabMaterialCache.get(cacheKey)
if (cached) return cached
const preset = getMaterialPresetByRef(params.materialPreset)
const slabMaterial = preset
? new THREE.MeshStandardMaterial()
: params.material
? createMaterial(params.material).clone()
: DEFAULT_SLAB_MATERIAL.clone()
if (preset) {
// Apply the preset to the slab-owned material so async texture loads update
// the instance we actually render after refresh as well.
applyMaterialPresetToMaterials(slabMaterial, preset)
}
// Slabs participate in the WebGPU MRT scene pass. Keeping them opaque avoids
// pipeline variants that can fail when geometry is regenerated while a
// transparent/custom material is attached.
slabMaterial.transparent = false
slabMaterial.opacity = 1
slabMaterial.alphaMap = null
slabMaterial.side = THREE.DoubleSide
slabMaterial.depthWrite = true
slabMaterial.needsUpdate = true
slabMaterialCache.set(cacheKey, slabMaterial)
return slabMaterial
}
export const SlabRenderer = ({ node }: { node: SlabNode }) => { export const SlabRenderer = ({ node }: { node: SlabNode }) => {
const ref = useRef<Mesh>(null!) const ref = useRef<Mesh>(null!)
@@ -15,46 +52,37 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => {
useRegistry(node.id, 'slab', ref) useRegistry(node.id, 'slab', ref)
const handlers = useNodeEvents(node, 'slab') const handlers = useNodeEvents(node, 'slab')
const materialPreview = useViewer((state) =>
state.materialPreview?.target === 'slab' && state.materialPreview.nodeId === node.id
? state.materialPreview
: null,
)
const material = useMemo(() => { const material = useMemo(() => {
const preset = getMaterialPresetByRef(node.materialPreset) const resolvedMaterial = materialPreview?.material ?? node.material
const slabMaterial = preset const resolvedMaterialPreset = materialPreview?.materialPreset ?? node.materialPreset
? new THREE.MeshStandardMaterial() const cacheKey = JSON.stringify({
: node.material material: resolvedMaterial ?? null,
? createMaterial(node.material).clone() materialPreset: resolvedMaterialPreset ?? null,
: DEFAULT_SLAB_MATERIAL.clone() })
if (preset) { return getSlabMaterial(cacheKey, {
// Apply the preset to the slab-owned material so async texture loads update material: resolvedMaterial,
// the instance we actually render after refresh as well. materialPreset: resolvedMaterialPreset,
applyMaterialPresetToMaterials(slabMaterial, preset) })
}
// Slabs participate in the WebGPU MRT scene pass. Keeping them opaque avoids
// pipeline variants that can fail when geometry is regenerated while a
// transparent/custom material is attached.
slabMaterial.transparent = false
slabMaterial.opacity = 1
slabMaterial.alphaMap = null
slabMaterial.side = THREE.DoubleSide
slabMaterial.depthWrite = true
slabMaterial.needsUpdate = true
return slabMaterial
}, [ }, [
node.material, node.material,
node.material?.preset, node.material?.preset,
node.material?.properties, node.material?.properties,
node.material?.texture, node.material?.texture,
node.materialPreset, node.materialPreset,
materialPreview?.material,
materialPreview?.material?.preset,
materialPreview?.material?.properties,
materialPreview?.material?.texture,
materialPreview?.materialPreset,
]) ])
useEffect(() => {
return () => {
material.dispose()
}
}, [material])
return ( return (
<mesh <mesh
castShadow castShadow
@@ -1,7 +1,14 @@
import { type AnyNodeId, type StairNode, type StairSegmentNode, useRegistry, useScene } from '@pascal-app/core' import {
type AnyNodeId,
type StairNode,
type StairSegmentNode,
useRegistry,
useScene,
} from '@pascal-app/core'
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react' import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events' import { useNodeEvents } from '../../../hooks/use-node-events'
import useViewer from '../../../store/use-viewer'
import { getStraightStairSegmentBodyMaterials } from '../../../systems/stair/stair-materials' import { getStraightStairSegmentBodyMaterials } from '../../../systems/stair/stair-materials'
export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => { export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => {
@@ -15,29 +22,45 @@ export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => {
}, [node.id]) }, [node.id])
const handlers = useNodeEvents(node, 'stair-segment') const handlers = useNodeEvents(node, 'stair-segment')
const parentNode = const parentNode = node.parentId
node.parentId ? (nodes[node.parentId as AnyNodeId] as StairNode | undefined) : undefined ? (nodes[node.parentId as AnyNodeId] as StairNode | undefined)
: undefined
const materialPreview = useViewer((state) =>
state.materialPreview?.target === 'stair' && state.materialPreview.nodeId === parentNode?.id
? state.materialPreview
: null,
)
const previewParentNode = !parentNode
? undefined
: materialPreview?.role === 'railing'
? {
...parentNode,
railingMaterial: materialPreview.material,
railingMaterialPreset: materialPreview.materialPreset,
material: undefined,
materialPreset: undefined,
}
: materialPreview?.role === 'tread'
? {
...parentNode,
treadMaterial: materialPreview.material,
treadMaterialPreset: materialPreview.materialPreset,
material: undefined,
materialPreset: undefined,
}
: materialPreview?.role === 'side'
? {
...parentNode,
sideMaterial: materialPreview.material,
sideMaterialPreset: materialPreview.materialPreset,
material: undefined,
materialPreset: undefined,
}
: parentNode
const material = useMemo(() => { const material = useMemo(() => {
return getStraightStairSegmentBodyMaterials(node, parentNode) return getStraightStairSegmentBodyMaterials(node, previewParentNode)
}, [ }, [node, previewParentNode])
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,
parentNode?.railingMaterialPreset,
parentNode?.railingMaterial,
parentNode?.sideMaterialPreset,
parentNode?.sideMaterial,
parentNode?.treadMaterialPreset,
parentNode?.treadMaterial,
])
const placeholderGeometry = useMemo(() => { const placeholderGeometry = useMemo(() => {
const geometry = new THREE.BufferGeometry() const geometry = new THREE.BufferGeometry()
@@ -8,10 +8,15 @@ import {
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react' import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events' import { useNodeEvents } from '../../../hooks/use-node-events'
import { createMaterial, createMaterialFromPresetRef, DEFAULT_STAIR_MATERIAL } from '../../../lib/materials'
import { import {
getStairRailingMaterial, createMaterial,
createMaterialFromPresetRef,
DEFAULT_STAIR_MATERIAL,
} from '../../../lib/materials'
import useViewer from '../../../store/use-viewer'
import {
getStairBodyMaterials, getStairBodyMaterials,
getStairRailingMaterial,
type StairBodyMaterials, type StairBodyMaterials,
} from '../../../systems/stair/stair-materials' } from '../../../systems/stair/stair-materials'
import { NodeRenderer } from '../node-renderer' import { NodeRenderer } from '../node-renderer'
@@ -57,48 +62,55 @@ export const StairRenderer = ({ node }: { node: StairNode }) => {
}, [node.id]) }, [node.id])
const handlers = useNodeEvents(node, 'stair') const handlers = useNodeEvents(node, 'stair')
const materialPreview = useViewer((state) =>
state.materialPreview?.target === 'stair' && state.materialPreview.nodeId === node.id
? state.materialPreview
: null,
)
const previewNode =
materialPreview?.role === 'railing'
? {
...node,
railingMaterial: materialPreview.material,
railingMaterialPreset: materialPreview.materialPreset,
material: undefined,
materialPreset: undefined,
}
: materialPreview?.role === 'tread'
? {
...node,
treadMaterial: materialPreview.material,
treadMaterialPreset: materialPreview.materialPreset,
material: undefined,
materialPreset: undefined,
}
: materialPreview?.role === 'side'
? {
...node,
sideMaterial: materialPreview.material,
sideMaterialPreset: materialPreview.materialPreset,
material: undefined,
materialPreset: undefined,
}
: node
const material = useMemo(() => { const material = useMemo(() => {
const presetMaterial = createMaterialFromPresetRef(node.materialPreset) const presetMaterial = createMaterialFromPresetRef(previewNode.materialPreset)
if (presetMaterial) return presetMaterial if (presetMaterial) return presetMaterial
const mat = node.material const mat = previewNode.material
if (!mat) return DEFAULT_STAIR_MATERIAL if (!mat) return DEFAULT_STAIR_MATERIAL
return createMaterial(mat) return createMaterial(mat)
}, [ }, [
node.materialPreset, previewNode.materialPreset,
node.material, previewNode.material,
node.material?.preset, previewNode.material?.preset,
node.material?.properties, previewNode.material?.properties,
node.material?.texture, previewNode.material?.texture,
]) ])
const straightBodyMaterials = useMemo( const straightBodyMaterials = useMemo(() => getStairBodyMaterials(previewNode), [previewNode])
() => getStairBodyMaterials(node),
[
node.material,
node.materialPreset,
node.railingMaterial,
node.railingMaterialPreset,
node.sideMaterial,
node.sideMaterialPreset,
node.treadMaterial,
node.treadMaterialPreset,
],
)
const railingMaterial = useMemo( const railingMaterial = useMemo(() => getStairRailingMaterial(previewNode), [previewNode])
() => getStairRailingMaterial(node),
[
node.material,
node.materialPreset,
node.railingMaterial,
node.railingMaterialPreset,
node.sideMaterial,
node.sideMaterialPreset,
node.treadMaterial,
node.treadMaterialPreset,
],
)
const straightPlaceholderGeometry = useMemo(() => { const straightPlaceholderGeometry = useMemo(() => {
const geometry = new THREE.BufferGeometry() const geometry = new THREE.BufferGeometry()
@@ -132,7 +144,9 @@ export const StairRenderer = ({ node }: { node: StairNode }) => {
receiveShadow receiveShadow
/> />
) : null} ) : null}
{!isSegmentBasedStair ? <CurvedStairBody bodyMaterials={straightBodyMaterials} stair={node} /> : null} {!isSegmentBasedStair ? (
<CurvedStairBody bodyMaterials={straightBodyMaterials} stair={node} />
) : null}
<StairRailings material={railingMaterial} stair={node} /> <StairRailings material={railingMaterial} stair={node} />
{isSegmentBasedStair ? ( {isSegmentBasedStair ? (
<group name="segments-wrapper" visible={false}> <group name="segments-wrapper" visible={false}>
@@ -2,6 +2,7 @@ import { useRegistry, useScene, type WallNode } from '@pascal-app/core'
import { useLayoutEffect, useRef } from 'react' import { useLayoutEffect, useRef } from 'react'
import type { Mesh } from 'three' import type { Mesh } from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events' import { useNodeEvents } from '../../../hooks/use-node-events'
import useViewer from '../../../store/use-viewer'
import { getVisibleWallMaterials } from '../../../systems/wall/wall-materials' import { getVisibleWallMaterials } from '../../../systems/wall/wall-materials'
import { NodeRenderer } from '../node-renderer' import { NodeRenderer } from '../node-renderer'
@@ -15,7 +16,30 @@ export const WallRenderer = ({ node }: { node: WallNode }) => {
}, [node.id]) }, [node.id])
const handlers = useNodeEvents(node, 'wall') const handlers = useNodeEvents(node, 'wall')
const material = getVisibleWallMaterials(node) const materialPreview = useViewer((state) =>
state.materialPreview?.target === 'wall' && state.materialPreview.nodeId === node.id
? state.materialPreview
: null,
)
const previewNode =
materialPreview && materialPreview.role === 'interior'
? {
...node,
interiorMaterial: materialPreview.material,
interiorMaterialPreset: materialPreview.materialPreset,
material: undefined,
materialPreset: undefined,
}
: materialPreview && materialPreview.role === 'exterior'
? {
...node,
exteriorMaterial: materialPreview.material,
exteriorMaterialPreset: materialPreview.materialPreset,
material: undefined,
materialPreset: undefined,
}
: node
const material = getVisibleWallMaterials(previewNode)
return ( return (
<mesh castShadow material={material} receiveShadow ref={ref} visible={node.visible}> <mesh castShadow material={material} receiveShadow ref={ref} visible={node.visible}>
+41 -3
View File
@@ -1,4 +1,14 @@
import type { AnyNode, BaseNode, BuildingNode, LevelNode, ZoneNode } from '@pascal-app/core' import type {
AnyNode,
BaseNode,
BuildingNode,
LevelNode,
MaterialSchema,
RoofSurfaceMaterialRole,
StairSurfaceMaterialRole,
WallSurfaceSide,
ZoneNode,
} from '@pascal-app/core'
import type { Object3D } from 'three' import type { Object3D } from 'three'
type SelectionPath = { type SelectionPath = {
buildingId: BuildingNode['id'] | null buildingId: BuildingNode['id'] | null
@@ -10,12 +20,40 @@ type Outliner = {
selectedObjects: Object3D[] selectedObjects: Object3D[]
hoveredObjects: Object3D[] hoveredObjects: Object3D[]
} }
type MaterialPreview = {
nodeId: string
target: 'wall'
role: WallSurfaceSide
material?: MaterialSchema
materialPreset?: string
} | {
nodeId: string
target: 'roof'
role: RoofSurfaceMaterialRole
material?: MaterialSchema
materialPreset?: string
} | {
nodeId: string
target: 'stair'
role: StairSurfaceMaterialRole
material?: MaterialSchema
materialPreset?: string
} | {
nodeId: string
target: 'fence' | 'slab' | 'ceiling'
role: 'surface'
material?: MaterialSchema
materialPreset?: string
} | null
type ViewerState = { type ViewerState = {
selection: SelectionPath selection: SelectionPath
previewSelectedIds: BaseNode['id'][] previewSelectedIds: BaseNode['id'][]
setPreviewSelectedIds: (ids: BaseNode['id'][]) => void setPreviewSelectedIds: (ids: BaseNode['id'][]) => void
hoverHighlightMode: 'default' | 'delete' materialPreview: MaterialPreview
setHoverHighlightMode: (mode: 'default' | 'delete') => void setMaterialPreview: (preview: MaterialPreview) => void
clearMaterialPreview: () => void
hoverHighlightMode: 'default' | 'delete' | 'paint-ready' | 'paint-disabled'
setHoverHighlightMode: (mode: 'default' | 'delete' | 'paint-ready' | 'paint-disabled') => void
hoveredId: AnyNode['id'] | ZoneNode['id'] | null hoveredId: AnyNode['id'] | ZoneNode['id'] | null
setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void
cameraMode: 'perspective' | 'orthographic' cameraMode: 'perspective' | 'orthographic'
+69 -1
View File
@@ -1,6 +1,17 @@
'use client' 'use client'
import type { AnyNode, BaseNode, BuildingNode, LevelNode, ZoneNode } from '@pascal-app/core' import type {
AnyNode,
AnyNodeId,
BaseNode,
BuildingNode,
LevelNode,
MaterialSchema,
RoofSurfaceMaterialRole,
StairSurfaceMaterialRole,
WallSurfaceSide,
ZoneNode,
} from '@pascal-app/core'
import type { Object3D } from 'three' import type { Object3D } from 'three'
import { create } from 'zustand' import { create } from 'zustand'
@@ -18,10 +29,57 @@ type Outliner = {
hoveredObjects: Object3D[] hoveredObjects: Object3D[]
} }
type MaterialPreview =
| {
nodeId: AnyNodeId
target: 'wall'
role: WallSurfaceSide
material?: MaterialSchema
materialPreset?: string
}
| {
nodeId: AnyNodeId
target: 'roof'
role: RoofSurfaceMaterialRole
material?: MaterialSchema
materialPreset?: string
}
| {
nodeId: AnyNodeId
target: 'stair'
role: StairSurfaceMaterialRole
material?: MaterialSchema
materialPreset?: string
}
| {
nodeId: AnyNodeId
target: 'fence' | 'slab' | 'ceiling'
role: 'surface'
material?: MaterialSchema
materialPreset?: string
}
| null
function isSameMaterialPreview(left: MaterialPreview, right: MaterialPreview) {
if (left === right) return true
if (!left || !right) return false
return (
left.nodeId === right.nodeId &&
left.target === right.target &&
left.role === right.role &&
left.material === right.material &&
left.materialPreset === right.materialPreset
)
}
type ViewerState = { type ViewerState = {
selection: SelectionPath selection: SelectionPath
previewSelectedIds: BaseNode['id'][] previewSelectedIds: BaseNode['id'][]
setPreviewSelectedIds: (ids: BaseNode['id'][]) => void setPreviewSelectedIds: (ids: BaseNode['id'][]) => void
materialPreview: MaterialPreview
setMaterialPreview: (preview: MaterialPreview) => void
clearMaterialPreview: () => void
hoverHighlightMode: 'default' | 'delete' | 'paint-ready' | 'paint-disabled' hoverHighlightMode: 'default' | 'delete' | 'paint-ready' | 'paint-disabled'
setHoverHighlightMode: (mode: 'default' | 'delete' | 'paint-ready' | 'paint-disabled') => void setHoverHighlightMode: (mode: 'default' | 'delete' | 'paint-ready' | 'paint-disabled') => void
hoveredId: AnyNode['id'] | ZoneNode['id'] | null hoveredId: AnyNode['id'] | ZoneNode['id'] | null
@@ -84,6 +142,15 @@ const useViewer = create<ViewerState>()(
selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] }, selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] },
previewSelectedIds: [], previewSelectedIds: [],
setPreviewSelectedIds: (ids) => set({ previewSelectedIds: ids }), setPreviewSelectedIds: (ids) => set({ previewSelectedIds: ids }),
materialPreview: null,
setMaterialPreview: (materialPreview) =>
set((state) =>
isSameMaterialPreview(state.materialPreview, materialPreview)
? state
: { materialPreview },
),
clearMaterialPreview: () =>
set((state) => (state.materialPreview === null ? state : { materialPreview: null })),
hoverHighlightMode: 'default', hoverHighlightMode: 'default',
setHoverHighlightMode: (mode) => setHoverHighlightMode: (mode) =>
set((state) => (state.hoverHighlightMode === mode ? state : { hoverHighlightMode: mode })), set((state) => (state.hoverHighlightMode === mode ? state : { hoverHighlightMode: mode })),
@@ -188,6 +255,7 @@ const useViewer = create<ViewerState>()(
selectedIds: [], selectedIds: [],
}, },
previewSelectedIds: [], previewSelectedIds: [],
materialPreview: null,
}), }),
outliner: { selectedObjects: [], hoveredObjects: [] }, outliner: { selectedObjects: [], hoveredObjects: [] },
@@ -8,6 +8,17 @@ import { createMaterial, createMaterialFromPresetRef } from '../../lib/materials
export type RoofMaterialArray = [THREE.Material, THREE.Material, THREE.Material, THREE.Material] export type RoofMaterialArray = [THREE.Material, THREE.Material, THREE.Material, THREE.Material]
const roofMaterialArrayCache = new Map<string, RoofMaterialArray>()
function getSurfaceMaterialSignature(
spec: ReturnType<typeof getEffectiveRoofSurfaceMaterial>,
): string {
return JSON.stringify({
material: spec.material ?? null,
materialPreset: spec.materialPreset ?? null,
})
}
function createResolvedMaterial( function createResolvedMaterial(
material: RoofNode['material'] | RoofSegmentNode['material'] | undefined, material: RoofNode['material'] | RoofSegmentNode['material'] | undefined,
materialPreset: string | undefined, materialPreset: string | undefined,
@@ -27,6 +38,14 @@ export function getRoofMaterialArray(node: RoofNode): RoofMaterialArray | null {
const top = getEffectiveRoofSurfaceMaterial(node, 'top') const top = getEffectiveRoofSurfaceMaterial(node, 'top')
const edge = getEffectiveRoofSurfaceMaterial(node, 'edge') const edge = getEffectiveRoofSurfaceMaterial(node, 'edge')
const wall = getEffectiveRoofSurfaceMaterial(node, 'wall') const wall = getEffectiveRoofSurfaceMaterial(node, 'wall')
const cacheKey = JSON.stringify({
top: getSurfaceMaterialSignature(top),
edge: getSurfaceMaterialSignature(edge),
wall: getSurfaceMaterialSignature(wall),
})
const cached = roofMaterialArrayCache.get(cacheKey)
if (cached) return cached
const topMaterial = createResolvedMaterial(top.material, top.materialPreset) const topMaterial = createResolvedMaterial(top.material, top.materialPreset)
const edgeMaterial = createResolvedMaterial(edge.material, edge.materialPreset) const edgeMaterial = createResolvedMaterial(edge.material, edge.materialPreset)
@@ -36,11 +55,13 @@ export function getRoofMaterialArray(node: RoofNode): RoofMaterialArray | null {
return null return null
} }
return [ const materialArray: RoofMaterialArray = [
edgeMaterial ?? wallMaterial ?? topMaterial ?? new THREE.MeshStandardMaterial(), edgeMaterial ?? wallMaterial ?? topMaterial ?? new THREE.MeshStandardMaterial(),
wallMaterial ?? edgeMaterial ?? topMaterial ?? new THREE.MeshStandardMaterial(), wallMaterial ?? edgeMaterial ?? topMaterial ?? new THREE.MeshStandardMaterial(),
wallMaterial ?? edgeMaterial ?? topMaterial ?? new THREE.MeshStandardMaterial(), wallMaterial ?? edgeMaterial ?? topMaterial ?? new THREE.MeshStandardMaterial(),
topMaterial ?? wallMaterial ?? edgeMaterial ?? new THREE.MeshStandardMaterial(), topMaterial ?? wallMaterial ?? edgeMaterial ?? new THREE.MeshStandardMaterial(),
] ]
}
roofMaterialArrayCache.set(cacheKey, materialArray)
return materialArray
}
@@ -12,6 +12,18 @@ import {
export type StairBodyMaterials = [THREE.Material, THREE.Material] export type StairBodyMaterials = [THREE.Material, THREE.Material]
const stairBodyMaterialCache = new Map<string, StairBodyMaterials>()
const stairRailingMaterialCache = new Map<string, THREE.Material>()
function getSurfaceMaterialSignature(
spec: ReturnType<typeof getEffectiveStairSurfaceMaterial>,
): string {
return JSON.stringify({
material: spec.material ?? null,
materialPreset: spec.materialPreset ?? null,
})
}
function createResolvedMaterial( function createResolvedMaterial(
material: StairNode['material'] | StairSegmentNode['material'] | undefined, material: StairNode['material'] | StairSegmentNode['material'] | undefined,
materialPreset: string | undefined, materialPreset: string | undefined,
@@ -30,16 +42,32 @@ function createResolvedMaterial(
export function getStairBodyMaterials(stair: StairNode): StairBodyMaterials { export function getStairBodyMaterials(stair: StairNode): StairBodyMaterials {
const tread = getEffectiveStairSurfaceMaterial(stair, 'tread') const tread = getEffectiveStairSurfaceMaterial(stair, 'tread')
const side = getEffectiveStairSurfaceMaterial(stair, 'side') const side = getEffectiveStairSurfaceMaterial(stair, 'side')
const cacheKey = JSON.stringify({
tread: getSurfaceMaterialSignature(tread),
side: getSurfaceMaterialSignature(side),
})
return [ const cached = stairBodyMaterialCache.get(cacheKey)
if (cached) return cached
const materials: StairBodyMaterials = [
createResolvedMaterial(tread.material, tread.materialPreset), createResolvedMaterial(tread.material, tread.materialPreset),
createResolvedMaterial(side.material, side.materialPreset), createResolvedMaterial(side.material, side.materialPreset),
] ]
stairBodyMaterialCache.set(cacheKey, materials)
return materials
} }
export function getStairRailingMaterial(stair: StairNode): THREE.Material { export function getStairRailingMaterial(stair: StairNode): THREE.Material {
const railing = getEffectiveStairSurfaceMaterial(stair, 'railing') const railing = getEffectiveStairSurfaceMaterial(stair, 'railing')
return createResolvedMaterial(railing.material, railing.materialPreset) const cacheKey = getSurfaceMaterialSignature(railing)
const cached = stairRailingMaterialCache.get(cacheKey)
if (cached) return cached
const material = createResolvedMaterial(railing.material, railing.materialPreset)
stairRailingMaterialCache.set(cacheKey, material)
return material
} }
export function getStraightStairSegmentBodyMaterials( export function getStraightStairSegmentBodyMaterials(