From 87dfdf1b381e37dc8f6c2b28c1a9457c20a813ae Mon Sep 17 00:00:00 2001 From: sudhir Date: Wed, 22 Apr 2026 10:52:35 +0530 Subject: [PATCH] Add paint hover previews to viewer --- packages/core/src/store/use-scene.ts | 6 +- .../editor/src/components/editor/index.tsx | 24 ++- .../components/editor/selection-manager.tsx | 139 +++++++++++++++++- .../renderers/ceiling/ceiling-renderer.tsx | 45 +++++- .../renderers/fence/fence-renderer.tsx | 26 +++- .../roof-segment/roof-segment-renderer.tsx | 66 ++++++--- .../renderers/roof/roof-renderer.tsx | 48 ++++-- .../renderers/slab/slab-renderer.tsx | 90 ++++++++---- .../stair-segment/stair-segment-renderer.tsx | 67 ++++++--- .../renderers/stair/stair-renderer.tsx | 86 ++++++----- .../renderers/wall/wall-renderer.tsx | 26 +++- packages/viewer/src/store/use-viewer.d.ts | 44 +++++- packages/viewer/src/store/use-viewer.ts | 70 ++++++++- .../viewer/src/systems/roof/roof-materials.ts | 25 +++- .../src/systems/stair/stair-materials.ts | 32 +++- 15 files changed, 638 insertions(+), 156 deletions(-) diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index ca3103499..853f783a0 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -157,14 +157,16 @@ function migrateStairSurfaceMaterials(node: Record) { 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, } } diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 08ee4eaf7..648e9cb8f 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -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(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 }} > ['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) + } + useViewer.setState({ hoveredId: null }) + setHoverHighlightMode('default') + useViewer.getState().clearMaterialPreview() } - }, [curvingWall, mode, movingNode]) + }, [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]) diff --git a/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx b/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx index 51e17f4a6..398abf54c 100644 --- a/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx +++ b/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx @@ -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>() + +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(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 ( diff --git a/packages/viewer/src/components/renderers/fence/fence-renderer.tsx b/packages/viewer/src/components/renderers/fence/fence-renderer.tsx index 7f22d5e84..c5448ada8 100644 --- a/packages/viewer/src/components/renderers/fence/fence-renderer.tsx +++ b/packages/viewer/src/components/renderers/fence/fence-renderer.tsx @@ -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(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 ( - + ) diff --git a/packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx b/packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx index cd0fbd092..daebf9fba 100644 --- a/packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx +++ b/packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx @@ -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 diff --git a/packages/viewer/src/components/renderers/roof/roof-renderer.tsx b/packages/viewer/src/components/renderers/roof/roof-renderer.tsx index a5fd4ee68..4a4eb866c 100644 --- a/packages/viewer/src/components/renderers/roof/roof-renderer.tsx +++ b/packages/viewer/src/components/renderers/roof/roof-renderer.tsx @@ -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 diff --git a/packages/viewer/src/components/renderers/slab/slab-renderer.tsx b/packages/viewer/src/components/renderers/slab/slab-renderer.tsx index f491cef2b..0e97f23dd 100644 --- a/packages/viewer/src/components/renderers/slab/slab-renderer.tsx +++ b/packages/viewer/src/components/renderers/slab/slab-renderer.tsx @@ -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() + +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(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 ( { @@ -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() diff --git a/packages/viewer/src/components/renderers/stair/stair-renderer.tsx b/packages/viewer/src/components/renderers/stair/stair-renderer.tsx index b79ecec28..4bc8821ac 100644 --- a/packages/viewer/src/components/renderers/stair/stair-renderer.tsx +++ b/packages/viewer/src/components/renderers/stair/stair-renderer.tsx @@ -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 ? : null} + {!isSegmentBasedStair ? ( + + ) : null} {isSegmentBasedStair ? ( diff --git a/packages/viewer/src/components/renderers/wall/wall-renderer.tsx b/packages/viewer/src/components/renderers/wall/wall-renderer.tsx index 9ff380069..a61fbb821 100644 --- a/packages/viewer/src/components/renderers/wall/wall-renderer.tsx +++ b/packages/viewer/src/components/renderers/wall/wall-renderer.tsx @@ -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 ( diff --git a/packages/viewer/src/store/use-viewer.d.ts b/packages/viewer/src/store/use-viewer.d.ts index 4dde28a6b..645866242 100644 --- a/packages/viewer/src/store/use-viewer.d.ts +++ b/packages/viewer/src/store/use-viewer.d.ts @@ -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' diff --git a/packages/viewer/src/store/use-viewer.ts b/packages/viewer/src/store/use-viewer.ts index f390edd62..c431ab714 100644 --- a/packages/viewer/src/store/use-viewer.ts +++ b/packages/viewer/src/store/use-viewer.ts @@ -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()( 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()( selectedIds: [], }, previewSelectedIds: [], + materialPreview: null, }), outliner: { selectedObjects: [], hoveredObjects: [] }, diff --git a/packages/viewer/src/systems/roof/roof-materials.ts b/packages/viewer/src/systems/roof/roof-materials.ts index 3991050a7..8510cfa61 100644 --- a/packages/viewer/src/systems/roof/roof-materials.ts +++ b/packages/viewer/src/systems/roof/roof-materials.ts @@ -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() + +function getSurfaceMaterialSignature( + spec: ReturnType, +): 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 +} diff --git a/packages/viewer/src/systems/stair/stair-materials.ts b/packages/viewer/src/systems/stair/stair-materials.ts index 819834792..fb63a6280 100644 --- a/packages/viewer/src/systems/stair/stair-materials.ts +++ b/packages/viewer/src/systems/stair/stair-materials.ts @@ -12,6 +12,18 @@ import { export type StairBodyMaterials = [THREE.Material, THREE.Material] +const stairBodyMaterialCache = new Map() +const stairRailingMaterialCache = new Map() + +function getSurfaceMaterialSignature( + spec: ReturnType, +): 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(