diff --git a/apps/editor/app/viewer/[id]/page.tsx b/apps/editor/app/viewer/[id]/page.tsx index 30b0cf08..c8144adb 100644 --- a/apps/editor/app/viewer/[id]/page.tsx +++ b/apps/editor/app/viewer/[id]/page.tsx @@ -5,6 +5,7 @@ import { Viewer } from '@pascal-app/viewer' import { OrbitControls } from '@react-three/drei' import { useParams } from 'next/navigation' import { useEffect, useState } from 'react' +import { ViewerOverlay } from './viewer-overlay' export default function ViewerPage() { const params = useParams() @@ -52,7 +53,8 @@ export default function ViewerPage() { } return ( -
+
+ diff --git a/apps/editor/app/viewer/[id]/viewer-overlay.tsx b/apps/editor/app/viewer/[id]/viewer-overlay.tsx new file mode 100644 index 00000000..c8856fbc --- /dev/null +++ b/apps/editor/app/viewer/[id]/viewer-overlay.tsx @@ -0,0 +1,128 @@ +'use client' + +import { type AnyNode, type BuildingNode, type LevelNode, type ZoneNode, useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { ChevronRight } from 'lucide-react' + +const getNodeName = (node: AnyNode): string => { + if ('name' in node && node.name) return node.name + if (node.type === 'wall') return 'Wall' + if (node.type === 'item') return (node as { asset: { name: string } }).asset?.name || 'Item' + if (node.type === 'slab') return 'Slab' + if (node.type === 'ceiling') return 'Ceiling' + if (node.type === 'roof') return 'Roof' + return node.type +} + +export const ViewerOverlay = () => { + const selection = useViewer((s) => s.selection) + const nodes = useScene((s) => s.nodes) + + const building = selection.buildingId ? (nodes[selection.buildingId] as BuildingNode | undefined) : null + const level = selection.levelId ? (nodes[selection.levelId] as LevelNode | undefined) : null + const zone = selection.zoneId ? (nodes[selection.zoneId] as ZoneNode | undefined) : null + + // Get the first selected item (if any) + const selectedNode = selection.selectedIds.length > 0 + ? (nodes[selection.selectedIds[0]!] as AnyNode | undefined) + : null + + // Get all levels for the selected building + const levels = building?.children + .map((id) => nodes[id] as LevelNode | undefined) + .filter((n): n is LevelNode => n?.type === 'level') + .sort((a, b) => a.level - b.level) ?? [] + + const handleLevelClick = (levelId: string) => { + // When switching levels, deselect zone and items + useViewer.getState().setSelection({ levelId }) + } + + const handleBreadcrumbClick = (depth: 'root' | 'building' | 'level' | 'zone') => { + switch (depth) { + case 'root': + useViewer.getState().resetSelection() + break + case 'building': + useViewer.getState().setSelection({ levelId: null }) + break + case 'level': + useViewer.getState().setSelection({ zoneId: null }) + break + } + } + + return ( +
+ {/* Breadcrumb */} +
+ + + {building && ( + <> + + + + )} + + {level && ( + <> + + + + )} + + {zone && ( + <> + + + {zone.name} + + + )} + + {selectedNode && zone && ( + <> + + {getNodeName(selectedNode)} + + )} +
+ + {/* Level List (only when building is selected) */} + {building && levels.length > 0 && ( +
+ Levels + {levels.map((lvl) => ( + + ))} +
+ )} +
+ ) +} diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index 09954fb8..14687e9c 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -26,38 +26,38 @@ interface ViewerProps { const Viewer: React.FC = ({ children, selectionManager = 'default' }) => { return ( { - const renderer = new THREE.WebGPURenderer(props as any) - await renderer.init() - return renderer - }} - shadows - camera={{ position: [50, 50, 50], fov: 50 }} - > - - + className={'bg-[#303035]'} + gl={async (props) => { + const renderer = new THREE.WebGPURenderer(props as any) + await renderer.init() + return renderer + }} + shadows + camera={{ position: [50, 50, 50], fov: 50 }} + > + + - - - - - + + + + + - {/* Default Systems */} - - - - {/* Core systems */} - - - - - - + {/* Default Systems */} + + + + {/* Core systems */} + + + + + + - {selectionManager === 'default' && } - {children} + {selectionManager === 'default' && } + {children} ) } diff --git a/packages/viewer/src/components/viewer/selection-manager.tsx b/packages/viewer/src/components/viewer/selection-manager.tsx index e6dd2128..8f2a79f7 100644 --- a/packages/viewer/src/components/viewer/selection-manager.tsx +++ b/packages/viewer/src/components/viewer/selection-manager.tsx @@ -111,14 +111,26 @@ const getStrategy = (): SelectionStrategy | null => { } } - // Zone selected -> can hover contents (walls, items, slabs, ceilings, roofs) + // Zone selected -> can select/hover contents (walls, items, slabs, ceilings, roofs) return { types: ['wall', 'item', 'slab', 'ceiling', 'roof'], - handleClick: () => { - // No action on click at zone content level + handleClick: (node) => { + const { selectedIds } = useViewer.getState().selection + // Toggle selection - if already selected, deselect; otherwise select + if (selectedIds.includes(node.id)) { + useViewer.getState().setSelection({ selectedIds: selectedIds.filter((id) => id !== node.id) }) + } else { + useViewer.getState().setSelection({ selectedIds: [node.id] }) + } }, handleDeselect: () => { - useViewer.getState().setSelection({ zoneId: null }) + const { selectedIds } = useViewer.getState().selection + // If items are selected, deselect them first; otherwise go back to level + if (selectedIds.length > 0) { + useViewer.getState().setSelection({ selectedIds: [] }) + } else { + useViewer.getState().setSelection({ zoneId: null }) + } }, isValid: (node) => { const validTypes = ['wall', 'item', 'slab', 'ceiling', 'roof'] @@ -222,12 +234,17 @@ const PointerMissedHandler = ({ clickHandledRef }: { clickHandledRef: React.Muta } const OutlinerSync = () => { + const selection = useViewer((s) => s.selection) const hoveredId = useViewer((s) => s.hoveredId) const outliner = useViewer((s) => s.outliner) useEffect(() => { - // Clear selected objects - we only show hover in viewer + // Sync selected objects outliner.selectedObjects.length = 0 + for (const id of selection.selectedIds) { + const obj = sceneRegistry.nodes.get(id) + if (obj) outliner.selectedObjects.push(obj) + } // Sync hovered objects outliner.hoveredObjects.length = 0 @@ -235,7 +252,7 @@ const OutlinerSync = () => { const obj = sceneRegistry.nodes.get(hoveredId) if (obj) outliner.hoveredObjects.push(obj) } - }, [hoveredId, outliner]) + }, [selection, hoveredId, outliner]) return null }