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') {
return {
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') {
return {
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 activePaintTarget = useEditor((s) => s.activePaintTarget)
const paintDisabledFeedbackTick = useEditor((s) => s.paintDisabledFeedbackTick)
const hoverHighlightMode = useViewer((s) => s.hoverHighlightMode)
const badgeRef = useRef<HTMLDivElement>(null)
const lastPointerPositionRef = useRef<{ x: number; y: number } | null>(null)
const [showDisabledFeedback, setShowDisabledFeedback] = useState(false)
const active = mode === 'material-paint' && !isVersionPreviewMode
const showBlockedState = showDisabledFeedback || hoverHighlightMode === 'paint-disabled'
useEffect(() => {
if (!active) {
@@ -748,10 +750,26 @@ function PaintCursorLayer({
)
const label = !hasMaterial
? 'Choose material'
: showDisabledFeedback
: showBlockedState
? 'Unsupported'
: `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(() => {
if (!paintDisabledFeedbackTick) return
@@ -797,7 +815,7 @@ function PaintCursorLayer({
style={{ display: 'none', position: 'absolute', left: 0, top: 0 }}
>
<PaintCursorBadge
disabled={!hasMaterial || showDisabledFeedback}
disabled={!hasMaterial || showBlockedState}
icon={icon}
label={label}
position={{ x: 0, y: 0 }}
@@ -70,6 +70,14 @@ type ModifierKeys = {
ctrl: boolean
}
type SceneMaterialPreview = ReturnType<typeof useViewer.getState>['materialPreview']
type PaintInteraction = {
apply: (() => void) | null
preview: SceneMaterialPreview
hoveredId: AnyNodeId
}
interface SelectionStrategy {
types: SelectableNodeType[]
handleSelect: (node: AnyNode, nativeEvent?: any, modifierKeys?: ModifierKeys) => void
@@ -503,6 +511,32 @@ export const SelectionManager = () => {
if (movingNode || curvingWall) return
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 = () =>
useEditor.getState().activePaintMaterial ??
@@ -515,11 +549,7 @@ export const SelectionManager = () => {
selectedMaterialTarget: useEditor.getState().selectedMaterialTarget,
})
const getPaintInteraction = (
event: NodeEvent,
): {
apply: (() => void) | null
} | null => {
const getPaintInteraction = (event: NodeEvent): PaintInteraction | null => {
const activePaintMaterial = resolveActivePaintMaterial()
const node = event.node
@@ -530,6 +560,7 @@ export const SelectionManager = () => {
const compatible =
role !== null && isActivePaintMaterialCompatible(activePaintMaterial, 'wall')
return {
hoveredId: node.id as AnyNodeId,
apply:
compatible && hasActivePaintMaterial(activePaintMaterial)
? () => {
@@ -546,6 +577,16 @@ export const SelectionManager = () => {
)
}
: 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 =
role !== null && isActivePaintMaterialCompatible(activePaintMaterial, 'roof')
return {
hoveredId: roofNode.id as AnyNodeId,
apply:
compatible && hasActivePaintMaterial(activePaintMaterial)
? () => {
@@ -578,6 +620,16 @@ export const SelectionManager = () => {
)
}
: 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 =
role !== null && isActivePaintMaterialCompatible(activePaintMaterial, 'stair')
return {
hoveredId: stairNode.id as AnyNodeId,
apply:
compatible && hasActivePaintMaterial(activePaintMaterial)
? () => {
@@ -610,6 +663,16 @@ export const SelectionManager = () => {
)
}
: 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)
return {
hoveredId: node.id as AnyNodeId,
apply: compatible
? () => {
useScene
@@ -633,19 +697,62 @@ export const SelectionManager = () => {
)
}
: null,
preview: compatible
? {
nodeId: node.id as AnyNodeId,
target,
role: 'surface',
material: activePaintMaterial.material,
materialPreset: activePaintMaterial.materialPreset,
}
: null,
}
}
const disabledNodeTypes = ['item', 'window', 'door', 'zone']
if (disabledNodeTypes.includes(node.type)) {
return {
hoveredId: node.id as AnyNodeId,
apply: null,
preview: 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) => {
if (boxSelectHandled) return
@@ -654,12 +761,19 @@ export const SelectionManager = () => {
event.stopPropagation()
if (hoverFrame !== 0) {
window.cancelAnimationFrame(hoverFrame)
flushHoverState()
}
if (!interaction.apply) {
useViewer.getState().clearMaterialPreview()
triggerPaintDisabledFeedback()
return
}
interaction.apply()
useViewer.getState().clearMaterialPreview()
}
const allTypes = [
@@ -679,14 +793,24 @@ export const SelectionManager = () => {
for (const type of allTypes) {
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 () => {
for (const type of allTypes) {
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)
}
}, [curvingWall, mode, movingNode])
useViewer.setState({ hoveredId: null })
setHoverHighlightMode('default')
useViewer.getState().clearMaterialPreview()
}
}, [curvingWall, mode, movingNode, setHoverHighlightMode])
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
@@ -1241,7 +1365,8 @@ const SelectionMaterialSync = () => {
}, [hoverHighlightMode, hoveredId, previewSelectedIds, selectedIds, syncSelectionMaterials])
useEffect(() => {
return useScene.subscribe(() => {
return useScene.subscribe((state, prevState) => {
if (state.nodes === prevState.nodes) return
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 { 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,27 +1,29 @@
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'
export const SlabRenderer = ({ node }: { node: SlabNode }) => {
const ref = useRef<Mesh>(null!)
const slabMaterialCache = new Map<string, THREE.MeshStandardMaterial>()
useRegistry(node.id, 'slab', ref)
function getSlabMaterial(
cacheKey: string,
params: { material?: SlabNode['material']; materialPreset?: string },
) {
const cached = slabMaterialCache.get(cacheKey)
if (cached) return cached
const handlers = useNodeEvents(node, 'slab')
const material = useMemo(() => {
const preset = getMaterialPresetByRef(node.materialPreset)
const preset = getMaterialPresetByRef(params.materialPreset)
const slabMaterial = preset
? new THREE.MeshStandardMaterial()
: node.material
? createMaterial(node.material).clone()
: params.material
? createMaterial(params.material).clone()
: DEFAULT_SLAB_MATERIAL.clone()
if (preset) {
@@ -40,21 +42,47 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => {
slabMaterial.depthWrite = true
slabMaterial.needsUpdate = true
slabMaterialCache.set(cacheKey, slabMaterial)
return slabMaterial
}
export const SlabRenderer = ({ node }: { node: SlabNode }) => {
const ref = useRef<Mesh>(null!)
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 resolvedMaterial = materialPreview?.material ?? node.material
const resolvedMaterialPreset = materialPreview?.materialPreset ?? node.materialPreset
const cacheKey = JSON.stringify({
material: resolvedMaterial ?? null,
materialPreset: resolvedMaterialPreset ?? null,
})
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}>
+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'
type SelectionPath = {
buildingId: BuildingNode['id'] | null
@@ -10,12 +20,40 @@ type Outliner = {
selectedObjects: 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 = {
selection: SelectionPath
previewSelectedIds: BaseNode['id'][]
setPreviewSelectedIds: (ids: BaseNode['id'][]) => void
hoverHighlightMode: 'default' | 'delete'
setHoverHighlightMode: (mode: 'default' | 'delete') => void
materialPreview: MaterialPreview
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
setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void
cameraMode: 'perspective' | 'orthographic'
+69 -1
View File
@@ -1,6 +1,17 @@
'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 { create } from 'zustand'
@@ -18,10 +29,57 @@ type Outliner = {
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 = {
selection: SelectionPath
previewSelectedIds: BaseNode['id'][]
setPreviewSelectedIds: (ids: BaseNode['id'][]) => void
materialPreview: MaterialPreview
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
@@ -84,6 +142,15 @@ const useViewer = create<ViewerState>()(
selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] },
previewSelectedIds: [],
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',
setHoverHighlightMode: (mode) =>
set((state) => (state.hoverHighlightMode === mode ? state : { hoverHighlightMode: mode })),
@@ -188,6 +255,7 @@ const useViewer = create<ViewerState>()(
selectedIds: [],
},
previewSelectedIds: [],
materialPreview: null,
}),
outliner: { selectedObjects: [], hoveredObjects: [] },
@@ -8,6 +8,17 @@ import { createMaterial, createMaterialFromPresetRef } from '../../lib/materials
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(
material: RoofNode['material'] | RoofSegmentNode['material'] | undefined,
materialPreset: string | undefined,
@@ -27,6 +38,14 @@ export function getRoofMaterialArray(node: RoofNode): RoofMaterialArray | null {
const top = getEffectiveRoofSurfaceMaterial(node, 'top')
const edge = getEffectiveRoofSurfaceMaterial(node, 'edge')
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 edgeMaterial = createResolvedMaterial(edge.material, edge.materialPreset)
@@ -36,11 +55,13 @@ export function getRoofMaterialArray(node: RoofNode): RoofMaterialArray | null {
return null
}
return [
const materialArray: RoofMaterialArray = [
edgeMaterial ?? wallMaterial ?? topMaterial ?? new THREE.MeshStandardMaterial(),
wallMaterial ?? edgeMaterial ?? topMaterial ?? new THREE.MeshStandardMaterial(),
wallMaterial ?? edgeMaterial ?? topMaterial ?? 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]
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(
material: StairNode['material'] | StairSegmentNode['material'] | undefined,
materialPreset: string | undefined,
@@ -30,16 +42,32 @@ function createResolvedMaterial(
export function getStairBodyMaterials(stair: StairNode): StairBodyMaterials {
const tread = getEffectiveStairSurfaceMaterial(stair, 'tread')
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(side.material, side.materialPreset),
]
stairBodyMaterialCache.set(cacheKey, materials)
return materials
}
export function getStairRailingMaterial(stair: StairNode): THREE.Material {
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(