better selection-manager
This commit is contained in:
@@ -26,6 +26,7 @@ export {
|
|||||||
resolveLevelId,
|
resolveLevelId,
|
||||||
} from './hooks/spatial-grid/spatial-grid-sync'
|
} from './hooks/spatial-grid/spatial-grid-sync'
|
||||||
export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query'
|
export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query'
|
||||||
|
export { pointInPolygon } from './hooks/spatial-grid/spatial-grid-manager'
|
||||||
// Schema
|
// Schema
|
||||||
export * from './schema'
|
export * from './schema'
|
||||||
export { default as useScene } from './store/use-scene'
|
export { default as useScene } from './store/use-scene'
|
||||||
|
|||||||
@@ -172,7 +172,9 @@ export const ZoneRenderer = ({ node }: { node: ZoneNode }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<group ref={ref} {...handlers}>
|
<group ref={ref} {...handlers}>
|
||||||
<Html name="label" position={[centroid[0], 1, centroid[1]]}>
|
<Html name="label" position={[centroid[0], 1, centroid[1]]} style={{
|
||||||
|
pointerEvents: 'none'
|
||||||
|
}}>
|
||||||
<div style={{
|
<div style={{
|
||||||
transform: 'translate3d(-50%, -50%, 0)',
|
transform: 'translate3d(-50%, -50%, 0)',
|
||||||
width: 'max-content',
|
width: 'max-content',
|
||||||
@@ -182,12 +184,6 @@ export const ZoneRenderer = ({ node }: { node: ZoneNode }) => {
|
|||||||
gap: '8px',
|
gap: '8px',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
}}>
|
}}>
|
||||||
{/* <div style={{
|
|
||||||
height: '8px',
|
|
||||||
width: '8px',
|
|
||||||
borderRadius: "9999px",
|
|
||||||
backgroundColor: node.color
|
|
||||||
}}></div> */}
|
|
||||||
{node.name}</div>
|
{node.name}</div>
|
||||||
</Html>
|
</Html>
|
||||||
{/* Floor fill */}
|
{/* Floor fill */}
|
||||||
|
|||||||
@@ -1,186 +1,241 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
type AnyNode,
|
||||||
type BuildingNode,
|
type BuildingNode,
|
||||||
|
type ItemNode,
|
||||||
type LevelNode,
|
type LevelNode,
|
||||||
type NodeEvent,
|
type NodeEvent,
|
||||||
|
type WallNode,
|
||||||
|
type ZoneNode,
|
||||||
emitter,
|
emitter,
|
||||||
|
pointInPolygon,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { useEffect } from 'react'
|
import { useThree } from '@react-three/fiber'
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
import useViewer from '../../store/use-viewer'
|
import useViewer from '../../store/use-viewer'
|
||||||
|
|
||||||
|
type SelectableNodeType = 'building' | 'level' | 'zone' | 'wall' | 'item' | 'slab' | 'ceiling' | 'roof'
|
||||||
|
|
||||||
|
interface SelectionStrategy {
|
||||||
|
types: SelectableNodeType[]
|
||||||
|
handleClick: (node: AnyNode) => void
|
||||||
|
handleDeselect: () => void
|
||||||
|
isValid: (node: AnyNode) => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a node is within the selected zone's polygon
|
||||||
|
const isNodeInZone = (node: AnyNode, zoneId: string): boolean => {
|
||||||
|
const nodes = useScene.getState().nodes
|
||||||
|
const zone = nodes[zoneId] as ZoneNode | undefined
|
||||||
|
if (!zone?.polygon?.length) return false
|
||||||
|
|
||||||
|
if (node.type === 'item') {
|
||||||
|
const item = node as ItemNode
|
||||||
|
return pointInPolygon(item.position[0], item.position[2], zone.polygon)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'wall') {
|
||||||
|
const wall = node as WallNode
|
||||||
|
const startIn = pointInPolygon(wall.start[0], wall.start[1], zone.polygon)
|
||||||
|
const endIn = pointInPolygon(wall.end[0], wall.end[1], zone.polygon)
|
||||||
|
return startIn || endIn
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'slab' || node.type === 'ceiling') {
|
||||||
|
const poly = (node as { polygon: [number, number][] }).polygon
|
||||||
|
if (!poly?.length) return false
|
||||||
|
// Check if any point of the node's polygon is in the zone
|
||||||
|
for (const [px, pz] of poly) {
|
||||||
|
if (pointInPolygon(px, pz, zone.polygon)) return true
|
||||||
|
}
|
||||||
|
// Check if any point of the zone is in the node's polygon
|
||||||
|
for (const [zx, zz] of zone.polygon) {
|
||||||
|
if (pointInPolygon(zx, zz, poly)) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'roof') {
|
||||||
|
// Roofs may not have a polygon, check by parent level
|
||||||
|
return true // Allow all roofs when zone is selected
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStrategy = (): SelectionStrategy | null => {
|
||||||
|
const { buildingId, levelId, zoneId } = useViewer.getState().selection
|
||||||
|
|
||||||
|
// No building selected -> can select buildings
|
||||||
|
if (!buildingId) {
|
||||||
|
return {
|
||||||
|
types: ['building'],
|
||||||
|
handleClick: (node) => {
|
||||||
|
useViewer.getState().setSelection({ buildingId: (node as BuildingNode).id })
|
||||||
|
},
|
||||||
|
handleDeselect: () => {
|
||||||
|
// Nothing to deselect at root level
|
||||||
|
},
|
||||||
|
isValid: (node) => node.type === 'building',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Building selected, no level -> can select levels
|
||||||
|
if (!levelId) {
|
||||||
|
return {
|
||||||
|
types: ['level'],
|
||||||
|
handleClick: (node) => {
|
||||||
|
useViewer.getState().setSelection({ levelId: (node as LevelNode).id })
|
||||||
|
},
|
||||||
|
handleDeselect: () => {
|
||||||
|
useViewer.getState().setSelection({ buildingId: null })
|
||||||
|
},
|
||||||
|
isValid: (node) => node.type === 'level',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Level selected, no zone -> can select zones
|
||||||
|
if (!zoneId) {
|
||||||
|
return {
|
||||||
|
types: ['zone'],
|
||||||
|
handleClick: (node) => {
|
||||||
|
useViewer.getState().setSelection({ zoneId: (node as ZoneNode).id })
|
||||||
|
},
|
||||||
|
handleDeselect: () => {
|
||||||
|
useViewer.getState().setSelection({ levelId: null })
|
||||||
|
},
|
||||||
|
isValid: (node) => node.type === 'zone',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zone selected -> can hover contents (walls, items, slabs, ceilings, roofs)
|
||||||
|
return {
|
||||||
|
types: ['wall', 'item', 'slab', 'ceiling', 'roof'],
|
||||||
|
handleClick: () => {
|
||||||
|
// No action on click at zone content level
|
||||||
|
},
|
||||||
|
handleDeselect: () => {
|
||||||
|
useViewer.getState().setSelection({ zoneId: null })
|
||||||
|
},
|
||||||
|
isValid: (node) => {
|
||||||
|
const validTypes = ['wall', 'item', 'slab', 'ceiling', 'roof']
|
||||||
|
if (!validTypes.includes(node.type)) return false
|
||||||
|
return isNodeInZone(node, zoneId)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const SelectionManager = () => {
|
export const SelectionManager = () => {
|
||||||
const selection = useViewer((s) => s.selection)
|
const selection = useViewer((s) => s.selection)
|
||||||
|
const clickHandledRef = useRef(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const { buildingId, levelId, zoneId } = selection
|
const onEnter = (event: NodeEvent) => {
|
||||||
|
const strategy = getStrategy()
|
||||||
// Determine current selection depth and what can be clicked
|
if (!strategy) return
|
||||||
// 0: no building → can click buildings
|
if (strategy.isValid(event.node)) {
|
||||||
// 1: building selected → can click levels
|
event.stopPropagation()
|
||||||
// 2: level selected → can click/hover zones
|
useViewer.setState({ hoveredId: event.node.id })
|
||||||
// 3: zone selected → can hover items/walls (no click needed)
|
|
||||||
|
|
||||||
const onBuildingClick = (event: NodeEvent<BuildingNode>) => {
|
|
||||||
if (buildingId) return // Already have a building selected
|
|
||||||
event.stopPropagation()
|
|
||||||
useViewer.getState().setSelection({ buildingId: event.node.id })
|
|
||||||
}
|
|
||||||
|
|
||||||
const onBuildingEnter = (event: NodeEvent<BuildingNode>) => {
|
|
||||||
if (buildingId) return
|
|
||||||
event.stopPropagation()
|
|
||||||
useViewer.setState({ hoveredId: event.node.id })
|
|
||||||
}
|
|
||||||
|
|
||||||
const onBuildingLeave = (event: NodeEvent<BuildingNode>) => {
|
|
||||||
if (buildingId) return
|
|
||||||
event.stopPropagation()
|
|
||||||
useViewer.setState({ hoveredId: null })
|
|
||||||
}
|
|
||||||
|
|
||||||
const onLevelClick = (event: NodeEvent<LevelNode>) => {
|
|
||||||
if (!buildingId || levelId) return // Need building, but no level yet
|
|
||||||
event.stopPropagation()
|
|
||||||
useViewer.getState().setSelection({ levelId: event.node.id })
|
|
||||||
}
|
|
||||||
|
|
||||||
const onLevelEnter = (event: NodeEvent<LevelNode>) => {
|
|
||||||
if (!buildingId || levelId) return
|
|
||||||
event.stopPropagation()
|
|
||||||
useViewer.setState({ hoveredId: event.node.id })
|
|
||||||
}
|
|
||||||
|
|
||||||
const onLevelLeave = (event: NodeEvent<LevelNode>) => {
|
|
||||||
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
|
const onLeave = (event: NodeEvent) => {
|
||||||
emitter.on('building:click', onBuildingClick)
|
const strategy = getStrategy()
|
||||||
emitter.on('building:enter', onBuildingEnter)
|
if (!strategy) return
|
||||||
emitter.on('building:leave', onBuildingLeave)
|
if (strategy.isValid(event.node)) {
|
||||||
|
event.stopPropagation()
|
||||||
|
useViewer.setState({ hoveredId: null })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
emitter.on('level:click', onLevelClick)
|
const onClick = (event: NodeEvent) => {
|
||||||
emitter.on('level:enter', onLevelEnter)
|
const strategy = getStrategy()
|
||||||
emitter.on('level:leave', onLevelLeave)
|
if (!strategy) return
|
||||||
|
if (!strategy.isValid(event.node)) return
|
||||||
|
|
||||||
emitter.on('zone:click', onZoneClick)
|
event.stopPropagation()
|
||||||
emitter.on('zone:enter', onZoneEnter)
|
clickHandledRef.current = true
|
||||||
emitter.on('zone:leave', onZoneLeave)
|
strategy.handleClick(event.node)
|
||||||
|
}
|
||||||
|
|
||||||
emitter.on('item:enter', onItemEnter)
|
// Subscribe to all node types
|
||||||
emitter.on('item:leave', onItemLeave)
|
const allTypes: SelectableNodeType[] = ['building', 'level', 'zone', 'wall', 'item', 'slab', 'ceiling', 'roof']
|
||||||
emitter.on('wall:enter', onItemEnter)
|
for (const type of allTypes) {
|
||||||
emitter.on('wall:leave', onItemLeave)
|
emitter.on(`${type}:enter`, onEnter)
|
||||||
|
emitter.on(`${type}:leave`, onLeave)
|
||||||
emitter.on('grid:click', onGridClick)
|
emitter.on(`${type}:click`, onClick)
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
emitter.off('building:click', onBuildingClick)
|
for (const type of allTypes) {
|
||||||
emitter.off('building:enter', onBuildingEnter)
|
emitter.off(`${type}:enter`, onEnter)
|
||||||
emitter.off('building:leave', onBuildingLeave)
|
emitter.off(`${type}:leave`, onLeave)
|
||||||
|
emitter.off(`${type}:click`, onClick)
|
||||||
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])
|
}, [selection])
|
||||||
|
|
||||||
return <OutlinerSync />
|
return (
|
||||||
|
<>
|
||||||
|
<PointerMissedHandler clickHandledRef={clickHandledRef} />
|
||||||
|
<OutlinerSync />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const PointerMissedHandler = ({ clickHandledRef }: { clickHandledRef: React.MutableRefObject<boolean> }) => {
|
||||||
|
const gl = useThree((s) => s.gl)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClick = (event: MouseEvent) => {
|
||||||
|
// Only handle left clicks
|
||||||
|
if (event.button !== 0) return
|
||||||
|
|
||||||
|
// Use requestAnimationFrame to check after R3F event handlers
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (clickHandledRef.current) {
|
||||||
|
clickHandledRef.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click was not handled by any 3D object -> deselect
|
||||||
|
const strategy = getStrategy()
|
||||||
|
if (strategy) {
|
||||||
|
strategy.handleDeselect()
|
||||||
|
useViewer.setState({ hoveredId: null })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = gl.domElement
|
||||||
|
canvas.addEventListener('click', handleClick)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
canvas.removeEventListener('click', handleClick)
|
||||||
|
}
|
||||||
|
}, [gl, clickHandledRef])
|
||||||
|
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const OutlinerSync = () => {
|
const OutlinerSync = () => {
|
||||||
const selection = useViewer((s) => s.selection)
|
|
||||||
const hoveredId = useViewer((s) => s.hoveredId)
|
const hoveredId = useViewer((s) => s.hoveredId)
|
||||||
const outliner = useViewer((s) => s.outliner)
|
const outliner = useViewer((s) => s.outliner)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let idsToHighlight: string[] = []
|
// Clear selected objects - we only show hover in viewer
|
||||||
|
|
||||||
// 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
|
outliner.selectedObjects.length = 0
|
||||||
for (const id of idsToHighlight) {
|
|
||||||
const obj = sceneRegistry.nodes.get(id)
|
|
||||||
if (obj) outliner.selectedObjects.push(obj)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Sync hovered objects
|
||||||
outliner.hoveredObjects.length = 0
|
outliner.hoveredObjects.length = 0
|
||||||
if (hoveredId) {
|
if (hoveredId) {
|
||||||
const obj = sceneRegistry.nodes.get(hoveredId)
|
const obj = sceneRegistry.nodes.get(hoveredId)
|
||||||
if (obj) outliner.hoveredObjects.push(obj)
|
if (obj) outliner.hoveredObjects.push(obj)
|
||||||
}
|
}
|
||||||
}, [selection, hoveredId, outliner])
|
}, [hoveredId, outliner])
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user