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
@@ -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 { float, mix, positionWorld, smoothstep } from 'three/tsl'
import { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu'
import { useNodeEvents } from '../../../hooks/use-node-events'
import useViewer from '../../../store/use-viewer'
import { NodeRenderer } from '../node-renderer'
const gridScale = 5
@@ -32,18 +38,47 @@ function createCeilingMaterials(color = '#999999') {
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 }) => {
const ref = useRef<Mesh>(null!)
useRegistry(node.id, 'ceiling', ref)
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 preset = getMaterialPresetByRef(node.materialPreset)
const props = preset?.mapProperties ?? resolveMaterial(node.material)
const preset = getMaterialPresetByRef(materialPreview?.materialPreset ?? node.materialPreset)
const props =
preset?.mapProperties ?? resolveMaterial(materialPreview?.material ?? node.material)
const color = props.color || '#999999'
return createCeilingMaterials(color)
}, [node.materialPreset, node.material, node.material?.preset, node.material?.properties, node.material?.texture])
return getCeilingMaterials(color)
}, [
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 (
<mesh material={materials.bottomMaterial} ref={ref}>
@@ -7,14 +7,22 @@ import {
createMaterialFromPresetRef,
DEFAULT_STAIR_MATERIAL,
} from '../../../lib/materials'
import useViewer from '../../../store/use-viewer'
export const FenceRenderer = ({ node }: { node: FenceNode }) => {
const ref = useRef<Mesh>(null!)
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 presetMaterial = createMaterialFromPresetRef(node.materialPreset)
const presetMaterial = createMaterialFromPresetRef(
materialPreview?.materialPreset ?? node.materialPreset,
)
if (presetMaterial) return presetMaterial
const mat = node.material
const mat = materialPreview?.material ?? node.material
if (!mat) return DEFAULT_STAIR_MATERIAL
return createMaterial(mat)
}, [
@@ -23,6 +31,11 @@ export const FenceRenderer = ({ node }: { node: FenceNode }) => {
node.material?.preset,
node.material?.properties,
node.material?.texture,
materialPreview?.materialPreset,
materialPreview?.material,
materialPreview?.material?.preset,
materialPreview?.material?.properties,
materialPreview?.material?.texture,
])
useRegistry(node.id, 'fence', ref)
@@ -31,7 +44,14 @@ export const FenceRenderer = ({ node }: { node: FenceNode }) => {
}, [node.id])
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]} />
</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 * as THREE from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
@@ -14,8 +20,14 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => {
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 parentNode = node.parentId
? (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 geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
@@ -26,30 +38,40 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => {
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(() => {
if (node.material !== undefined || typeof node.materialPreset === 'string') {
return null
}
return parentNode ? getRoofMaterialArray(parentNode) : null
}, [
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,
])
return previewParentNode ? getRoofMaterialArray(previewParentNode) : null
}, [node, previewParentNode])
const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials
@@ -14,6 +14,11 @@ export const RoofRenderer = ({ node }: { node: RoofNode }) => {
const handlers = useNodeEvents(node, 'roof')
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 geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
@@ -24,22 +29,33 @@ export const RoofRenderer = ({ node }: { node: RoofNode }) => {
return geometry
}, [])
const customMaterial = useMemo(
() => getRoofMaterialArray(node),
[
node.materialPreset,
node.material,
node.material?.preset,
node.material?.properties,
node.material?.texture,
node.topMaterial,
node.topMaterialPreset,
node.edgeMaterial,
node.edgeMaterialPreset,
node.wallMaterial,
node.wallMaterialPreset,
],
)
const previewNode =
materialPreview?.role === 'top'
? {
...node,
topMaterial: materialPreview.material,
topMaterialPreset: materialPreview.materialPreset,
material: undefined,
materialPreset: undefined,
}
: materialPreview?.role === 'edge'
? {
...node,
edgeMaterial: materialPreview.material,
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
@@ -1,13 +1,50 @@
import { getMaterialPresetByRef, type SlabNode, useRegistry } from '@pascal-app/core'
import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { useMemo, useRef } from 'react'
import type { Mesh } from 'three'
import * as THREE from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
import {
applyMaterialPresetToMaterials,
createMaterial,
DEFAULT_SLAB_MATERIAL,
} 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 }) => {
const ref = useRef<Mesh>(null!)
@@ -15,46 +52,37 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => {
useRegistry(node.id, 'slab', ref)
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 preset = getMaterialPresetByRef(node.materialPreset)
const slabMaterial = preset
? new THREE.MeshStandardMaterial()
: node.material
? createMaterial(node.material).clone()
: DEFAULT_SLAB_MATERIAL.clone()
const resolvedMaterial = materialPreview?.material ?? node.material
const resolvedMaterialPreset = materialPreview?.materialPreset ?? node.materialPreset
const cacheKey = JSON.stringify({
material: resolvedMaterial ?? null,
materialPreset: resolvedMaterialPreset ?? null,
})
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
return slabMaterial
return getSlabMaterial(cacheKey, {
material: resolvedMaterial,
materialPreset: resolvedMaterialPreset,
})
}, [
node.material,
node.material?.preset,
node.material?.properties,
node.material?.texture,
node.materialPreset,
materialPreview?.material,
materialPreview?.material?.preset,
materialPreview?.material?.properties,
materialPreview?.material?.texture,
materialPreview?.materialPreset,
])
useEffect(() => {
return () => {
material.dispose()
}
}, [material])
return (
<mesh
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 * as THREE from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
import useViewer from '../../../store/use-viewer'
import { getStraightStairSegmentBodyMaterials } from '../../../systems/stair/stair-materials'
export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => {
@@ -15,29 +22,45 @@ 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 parentNode = node.parentId
? (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(() => {
return getStraightStairSegmentBodyMaterials(node, parentNode)
}, [
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,
])
return getStraightStairSegmentBodyMaterials(node, previewParentNode)
}, [node, previewParentNode])
const placeholderGeometry = useMemo(() => {
const geometry = new THREE.BufferGeometry()
@@ -8,10 +8,15 @@ import {
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
import { createMaterial, createMaterialFromPresetRef, DEFAULT_STAIR_MATERIAL } from '../../../lib/materials'
import {
getStairRailingMaterial,
createMaterial,
createMaterialFromPresetRef,
DEFAULT_STAIR_MATERIAL,
} from '../../../lib/materials'
import useViewer from '../../../store/use-viewer'
import {
getStairBodyMaterials,
getStairRailingMaterial,
type StairBodyMaterials,
} from '../../../systems/stair/stair-materials'
import { NodeRenderer } from '../node-renderer'
@@ -57,48 +62,55 @@ export const StairRenderer = ({ node }: { node: StairNode }) => {
}, [node.id])
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 presetMaterial = createMaterialFromPresetRef(node.materialPreset)
const presetMaterial = createMaterialFromPresetRef(previewNode.materialPreset)
if (presetMaterial) return presetMaterial
const mat = node.material
const mat = previewNode.material
if (!mat) return DEFAULT_STAIR_MATERIAL
return createMaterial(mat)
}, [
node.materialPreset,
node.material,
node.material?.preset,
node.material?.properties,
node.material?.texture,
previewNode.materialPreset,
previewNode.material,
previewNode.material?.preset,
previewNode.material?.properties,
previewNode.material?.texture,
])
const straightBodyMaterials = useMemo(
() => getStairBodyMaterials(node),
[
node.material,
node.materialPreset,
node.railingMaterial,
node.railingMaterialPreset,
node.sideMaterial,
node.sideMaterialPreset,
node.treadMaterial,
node.treadMaterialPreset,
],
)
const straightBodyMaterials = useMemo(() => getStairBodyMaterials(previewNode), [previewNode])
const railingMaterial = useMemo(
() => getStairRailingMaterial(node),
[
node.material,
node.materialPreset,
node.railingMaterial,
node.railingMaterialPreset,
node.sideMaterial,
node.sideMaterialPreset,
node.treadMaterial,
node.treadMaterialPreset,
],
)
const railingMaterial = useMemo(() => getStairRailingMaterial(previewNode), [previewNode])
const straightPlaceholderGeometry = useMemo(() => {
const geometry = new THREE.BufferGeometry()
@@ -132,7 +144,9 @@ export const StairRenderer = ({ node }: { node: StairNode }) => {
receiveShadow
/>
) : null}
{!isSegmentBasedStair ? <CurvedStairBody bodyMaterials={straightBodyMaterials} stair={node} /> : null}
{!isSegmentBasedStair ? (
<CurvedStairBody bodyMaterials={straightBodyMaterials} stair={node} />
) : null}
<StairRailings material={railingMaterial} stair={node} />
{isSegmentBasedStair ? (
<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 type { Mesh } from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
import useViewer from '../../../store/use-viewer'
import { getVisibleWallMaterials } from '../../../systems/wall/wall-materials'
import { NodeRenderer } from '../node-renderer'
@@ -15,7 +16,30 @@ export const WallRenderer = ({ node }: { node: WallNode }) => {
}, [node.id])
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 (
<mesh castShadow material={material} receiveShadow ref={ref} visible={node.visible}>