diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index ca310349..853f783a 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 08ee4eaf..648e9cb8 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 51e17f4a..398abf54 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 7f22d5e8..c5448ada 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 cd0fbd09..daebf9fb 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 a5fd4ee6..4a4eb866 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 f491cef2..0e97f23d 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 b79ecec2..4bc8821a 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 9ff38006..a61fbb82 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 4dde28a6..64586624 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 f390edd6..c431ab71 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 3991050a..8510cfa6 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 81983479..fb63a628 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(