From 8edcda1b756f9c4a9cfa177c966413591c3378e2 Mon Sep 17 00:00:00 2001 From: wass08 Date: Fri, 30 Jan 2026 08:30:57 +0900 Subject: [PATCH] default seleciton manager --- apps/editor/components/editor/index.tsx | 2 +- packages/core/src/events/bus.ts | 4 +- packages/core/src/index.ts | 1 + .../renderers/level/level-renderer.tsx | 8 +- .../viewer/src/components/viewer/index.tsx | 5 +- .../components/viewer/selection-manager.tsx | 186 ++++++++++++++++++ packages/viewer/src/hooks/use-node-events.ts | 3 + 7 files changed, 201 insertions(+), 8 deletions(-) create mode 100644 packages/viewer/src/components/viewer/selection-manager.tsx 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 ( = ({ children }) => { + {selectionManager === 'default' && } {children} ) 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 }