diff --git a/apps/editor/components/editor/index.tsx b/apps/editor/components/editor/index.tsx
index 633b2b70..49b90abd 100644
--- a/apps/editor/components/editor/index.tsx
+++ b/apps/editor/components/editor/index.tsx
@@ -37,7 +37,7 @@ export default function Editor() {
-
+
{/* Editor only system to toggle zone visibility */}
diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts
index df5cbb40..79590943 100644
--- a/packages/core/src/events/bus.ts
+++ b/packages/core/src/events/bus.ts
@@ -1,6 +1,6 @@
import type { ThreeEvent } from '@react-three/fiber'
import mitt from 'mitt'
-import type { BuildingNode, CeilingNode, ItemNode, RoofNode, SlabNode, WallNode, ZoneNode } from '../schema'
+import type { BuildingNode, CeilingNode, ItemNode, LevelNode, RoofNode, SlabNode, WallNode, ZoneNode } from '../schema'
import type { AnyNode } from '../schema/types'
// Base event interfaces
@@ -21,6 +21,7 @@ export interface NodeEvent {
export type WallEvent = NodeEvent
export type ItemEvent = NodeEvent
export type BuildingEvent = NodeEvent
+export type LevelEvent = NodeEvent
export type ZoneEvent = NodeEvent
export type SlabEvent = NodeEvent
export type CeilingEvent = NodeEvent
@@ -61,6 +62,7 @@ type EditorEvents = GridEvents &
NodeEvents<'wall', WallEvent> &
NodeEvents<'item', ItemEvent> &
NodeEvents<'building', BuildingEvent> &
+ NodeEvents<'level', LevelEvent> &
NodeEvents<'zone', ZoneEvent> &
NodeEvents<'slab', SlabEvent> &
NodeEvents<'ceiling', CeilingEvent> &
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 3187a00d..7f58d8a5 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -6,6 +6,7 @@ export type {
EventSuffix,
GridEvent,
ItemEvent,
+ LevelEvent,
NodeEvent,
SlabEvent,
WallEvent,
diff --git a/packages/viewer/src/components/renderers/level/level-renderer.tsx b/packages/viewer/src/components/renderers/level/level-renderer.tsx
index 0ee20ed9..b81e9c80 100644
--- a/packages/viewer/src/components/renderers/level/level-renderer.tsx
+++ b/packages/viewer/src/components/renderers/level/level-renderer.tsx
@@ -1,19 +1,17 @@
import { type LevelNode, useRegistry } from '@pascal-app/core'
import { useRef } from 'react'
import type { Group } from 'three'
+import { useNodeEvents } from '../../../hooks/use-node-events'
import { NodeRenderer } from '../node-renderer'
export const LevelRenderer = ({ node }: { node: LevelNode }) => {
const ref = useRef(null!)
useRegistry(node.id, node.type, ref)
+ const handlers = useNodeEvents(node, 'level')
return (
-
- {/*
-
-
- */}
+
{node.children.map((childId) => (
))}
diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx
index a1617836..09954fb8 100644
--- a/packages/viewer/src/components/viewer/index.tsx
+++ b/packages/viewer/src/components/viewer/index.tsx
@@ -9,6 +9,7 @@ import { LevelSystem } from '../../systems/level/level-system'
import { ScanSystem } from '../../systems/scan/scan-system'
import { SceneRenderer } from '../renderers/scene-renderer'
import PostProcessing from './post-processing'
+import { SelectionManager } from './selection-manager'
import { ViewerCamera } from './viewer-camera'
declare module '@react-three/fiber' {
@@ -19,9 +20,10 @@ extend(THREE as any)
interface ViewerProps {
children?: React.ReactNode
+ selectionManager?: 'default' | 'custom'
}
-const Viewer: React.FC = ({ children }) => {
+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
new file mode 100644
index 00000000..c820bc9d
--- /dev/null
+++ b/packages/viewer/src/components/viewer/selection-manager.tsx
@@ -0,0 +1,186 @@
+'use client'
+
+import {
+ type BuildingNode,
+ type LevelNode,
+ type NodeEvent,
+ emitter,
+ sceneRegistry,
+} from '@pascal-app/core'
+import { useEffect } from 'react'
+import useViewer from '../../store/use-viewer'
+
+export const SelectionManager = () => {
+ const selection = useViewer((s) => s.selection)
+
+ useEffect(() => {
+ const { buildingId, levelId, zoneId } = selection
+
+ // Determine current selection depth and what can be clicked
+ // 0: no building → can click buildings
+ // 1: building selected → can click levels
+ // 2: level selected → can click/hover zones
+ // 3: zone selected → can hover items/walls (no click needed)
+
+ const onBuildingClick = (event: NodeEvent) => {
+ if (buildingId) return // Already have a building selected
+ event.stopPropagation()
+ useViewer.getState().setSelection({ buildingId: event.node.id })
+ }
+
+ const onBuildingEnter = (event: NodeEvent) => {
+ if (buildingId) return
+ event.stopPropagation()
+ useViewer.setState({ hoveredId: event.node.id })
+ }
+
+ const onBuildingLeave = (event: NodeEvent) => {
+ if (buildingId) return
+ event.stopPropagation()
+ useViewer.setState({ hoveredId: null })
+ }
+
+ const onLevelClick = (event: NodeEvent) => {
+ if (!buildingId || levelId) return // Need building, but no level yet
+ event.stopPropagation()
+ useViewer.getState().setSelection({ levelId: event.node.id })
+ }
+
+ const onLevelEnter = (event: NodeEvent) => {
+ if (!buildingId || levelId) return
+ event.stopPropagation()
+ useViewer.setState({ hoveredId: event.node.id })
+ }
+
+ const onLevelLeave = (event: NodeEvent) => {
+ if (!buildingId || levelId) return
+ event.stopPropagation()
+ useViewer.setState({ hoveredId: null })
+ }
+
+ const onZoneClick = (event: NodeEvent) => {
+ if (!levelId || zoneId) return // Need level, but no zone yet
+ event.stopPropagation()
+ useViewer.getState().setSelection({ zoneId: event.node.id })
+ }
+
+ const onZoneEnter = (event: NodeEvent) => {
+ if (!levelId) return
+ event.stopPropagation()
+ useViewer.setState({ hoveredId: event.node.id })
+ }
+
+ const onZoneLeave = (event: NodeEvent) => {
+ if (!levelId) return
+ event.stopPropagation()
+ useViewer.setState({ hoveredId: null })
+ }
+
+ // Hover for items/walls when zone is selected
+ const onItemEnter = (event: NodeEvent) => {
+ if (!zoneId) return
+ event.stopPropagation()
+ useViewer.setState({ hoveredId: event.node.id })
+ }
+
+ const onItemLeave = (event: NodeEvent) => {
+ if (!zoneId) return
+ event.stopPropagation()
+ useViewer.setState({ hoveredId: null })
+ }
+
+ // Grid click deselects in reverse order
+ const onGridClick = () => {
+ const { buildingId, levelId, zoneId, selectedIds } = useViewer.getState().selection
+
+ if (selectedIds.length > 0) {
+ useViewer.getState().setSelection({ selectedIds: [] })
+ } else if (zoneId) {
+ useViewer.getState().setSelection({ zoneId: null })
+ } else if (levelId) {
+ useViewer.getState().setSelection({ levelId: null })
+ } else if (buildingId) {
+ useViewer.getState().setSelection({ buildingId: null })
+ }
+ }
+
+ // Subscribe to events
+ emitter.on('building:click', onBuildingClick)
+ emitter.on('building:enter', onBuildingEnter)
+ emitter.on('building:leave', onBuildingLeave)
+
+ emitter.on('level:click', onLevelClick)
+ emitter.on('level:enter', onLevelEnter)
+ emitter.on('level:leave', onLevelLeave)
+
+ emitter.on('zone:click', onZoneClick)
+ emitter.on('zone:enter', onZoneEnter)
+ emitter.on('zone:leave', onZoneLeave)
+
+ emitter.on('item:enter', onItemEnter)
+ emitter.on('item:leave', onItemLeave)
+ emitter.on('wall:enter', onItemEnter)
+ emitter.on('wall:leave', onItemLeave)
+
+ emitter.on('grid:click', onGridClick)
+
+ return () => {
+ emitter.off('building:click', onBuildingClick)
+ emitter.off('building:enter', onBuildingEnter)
+ emitter.off('building:leave', onBuildingLeave)
+
+ emitter.off('level:click', onLevelClick)
+ emitter.off('level:enter', onLevelEnter)
+ emitter.off('level:leave', onLevelLeave)
+
+ emitter.off('zone:click', onZoneClick)
+ emitter.off('zone:enter', onZoneEnter)
+ emitter.off('zone:leave', onZoneLeave)
+
+ emitter.off('item:enter', onItemEnter)
+ emitter.off('item:leave', onItemLeave)
+ emitter.off('wall:enter', onItemEnter)
+ emitter.off('wall:leave', onItemLeave)
+
+ emitter.off('grid:click', onGridClick)
+ }
+ }, [selection])
+
+ return
+}
+
+const OutlinerSync = () => {
+ const selection = useViewer((s) => s.selection)
+ const hoveredId = useViewer((s) => s.hoveredId)
+ const outliner = useViewer((s) => s.outliner)
+
+ useEffect(() => {
+ let idsToHighlight: string[] = []
+
+ // Highlight based on the "deepest" selection
+ if (selection.selectedIds.length > 0) {
+ idsToHighlight = selection.selectedIds
+ } else if (selection.zoneId) {
+ idsToHighlight = [selection.zoneId]
+ } else if (selection.levelId) {
+ idsToHighlight = [selection.levelId]
+ } else if (selection.buildingId) {
+ idsToHighlight = [selection.buildingId]
+ }
+
+ // Sync with the imperative outliner arrays
+ outliner.selectedObjects.length = 0
+ for (const id of idsToHighlight) {
+ const obj = sceneRegistry.nodes.get(id)
+ if (obj) outliner.selectedObjects.push(obj)
+ }
+
+ outliner.hoveredObjects.length = 0
+ if (hoveredId) {
+ const obj = sceneRegistry.nodes.get(hoveredId)
+ if (obj) outliner.hoveredObjects.push(obj)
+ }
+ }, [selection, hoveredId, outliner])
+
+ return null
+}
diff --git a/packages/viewer/src/hooks/use-node-events.ts b/packages/viewer/src/hooks/use-node-events.ts
index 36e33893..64042113 100644
--- a/packages/viewer/src/hooks/use-node-events.ts
+++ b/packages/viewer/src/hooks/use-node-events.ts
@@ -7,6 +7,8 @@ import {
emitter,
type ItemEvent,
type ItemNode,
+ type LevelEvent,
+ type LevelNode,
type RoofEvent,
type RoofNode,
type SlabEvent,
@@ -22,6 +24,7 @@ type NodeConfig = {
item: { node: ItemNode; event: ItemEvent }
wall: { node: WallNode; event: WallEvent }
building: { node: BuildingNode; event: BuildingEvent }
+ level: { node: LevelNode; event: LevelEvent }
zone: { node: ZoneNode; event: ZoneEvent }
slab: { node: SlabNode; event: SlabEvent }
ceiling: { node: CeilingNode; event: CeilingEvent }