+
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 (
)
}
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
}