fix selection
This commit is contained in:
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
import { initSpatialGridSync, useScene } from '@pascal-app/core'
|
import { initSpatialGridSync, useScene } from '@pascal-app/core'
|
||||||
import { Viewer } from '@pascal-app/viewer'
|
import { Viewer } from '@pascal-app/viewer'
|
||||||
import { OrbitControls } from '@react-three/drei'
|
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams } from 'next/navigation'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { ViewerCameraControls } from './viewer-camera-controls'
|
||||||
import { ViewerOverlay } from './viewer-overlay'
|
import { ViewerOverlay } from './viewer-overlay'
|
||||||
|
|
||||||
export default function ViewerPage() {
|
export default function ViewerPage() {
|
||||||
@@ -56,7 +56,7 @@ export default function ViewerPage() {
|
|||||||
<div className="relative h-screen w-full">
|
<div className="relative h-screen w-full">
|
||||||
<ViewerOverlay />
|
<ViewerOverlay />
|
||||||
<Viewer>
|
<Viewer>
|
||||||
<OrbitControls makeDefault />
|
<ViewerCameraControls />
|
||||||
</Viewer>
|
</Viewer>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { sceneRegistry, useScene } from '@pascal-app/core'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { CameraControls, CameraControlsImpl } from '@react-three/drei'
|
||||||
|
import { useEffect, useMemo, useRef } from 'react'
|
||||||
|
import { Box3, Vector3 } from 'three'
|
||||||
|
|
||||||
|
const tempBox = new Box3()
|
||||||
|
const tempCenter = new Vector3()
|
||||||
|
const tempSize = new Vector3()
|
||||||
|
|
||||||
|
export const ViewerCameraControls = () => {
|
||||||
|
const controls = useRef<CameraControlsImpl>(null!)
|
||||||
|
const selection = useViewer((s) => s.selection)
|
||||||
|
const nodes = useScene((s) => s.nodes)
|
||||||
|
const cameraMode = useViewer((s) => s.cameraMode)
|
||||||
|
const firstLoad = useRef(true)
|
||||||
|
|
||||||
|
// Get the deepest selected node ID (excluding selectedIds)
|
||||||
|
const targetNodeId = selection.zoneId ?? selection.levelId ?? selection.buildingId
|
||||||
|
|
||||||
|
// Configure mouse buttons - same as editor
|
||||||
|
const mouseButtons = useMemo(() => {
|
||||||
|
const wheelAction =
|
||||||
|
cameraMode === 'orthographic'
|
||||||
|
? CameraControlsImpl.ACTION.ZOOM
|
||||||
|
: CameraControlsImpl.ACTION.DOLLY
|
||||||
|
|
||||||
|
return {
|
||||||
|
left: CameraControlsImpl.ACTION.NONE,
|
||||||
|
middle: CameraControlsImpl.ACTION.SCREEN_PAN,
|
||||||
|
right: CameraControlsImpl.ACTION.ROTATE,
|
||||||
|
wheel: wheelAction,
|
||||||
|
}
|
||||||
|
}, [cameraMode])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!controls.current) return
|
||||||
|
|
||||||
|
// On first load, set a default camera position
|
||||||
|
if (firstLoad.current) {
|
||||||
|
firstLoad.current = false
|
||||||
|
controls.current.setLookAt(30, 30, 30, 0, 0, 0, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetNodeId) return
|
||||||
|
|
||||||
|
const node = nodes[targetNodeId]
|
||||||
|
if (!node) return
|
||||||
|
|
||||||
|
// Check if node has a saved camera
|
||||||
|
if (node.camera) {
|
||||||
|
const { position, target } = node.camera
|
||||||
|
controls.current.setLookAt(
|
||||||
|
position[0],
|
||||||
|
position[1],
|
||||||
|
position[2],
|
||||||
|
target[0],
|
||||||
|
target[1],
|
||||||
|
target[2],
|
||||||
|
true
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate camera position based on the node's 3D object
|
||||||
|
const object3D = sceneRegistry.nodes.get(targetNodeId)
|
||||||
|
if (!object3D) return
|
||||||
|
|
||||||
|
// Compute bounding box
|
||||||
|
tempBox.setFromObject(object3D)
|
||||||
|
tempBox.getCenter(tempCenter)
|
||||||
|
tempBox.getSize(tempSize)
|
||||||
|
|
||||||
|
// Calculate a good viewing distance based on the object size
|
||||||
|
const maxDim = Math.max(tempSize.x, tempSize.y, tempSize.z)
|
||||||
|
const distance = Math.max(maxDim * 2, 15)
|
||||||
|
|
||||||
|
// Position camera at an angle looking at the center
|
||||||
|
const cameraPos = new Vector3(
|
||||||
|
tempCenter.x + distance * 0.7,
|
||||||
|
tempCenter.y + distance * 0.5,
|
||||||
|
tempCenter.z + distance * 0.7
|
||||||
|
)
|
||||||
|
|
||||||
|
controls.current.setLookAt(
|
||||||
|
cameraPos.x,
|
||||||
|
cameraPos.y,
|
||||||
|
cameraPos.z,
|
||||||
|
tempCenter.x,
|
||||||
|
tempCenter.y,
|
||||||
|
tempCenter.z,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
}, [targetNodeId, nodes])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CameraControls
|
||||||
|
ref={controls}
|
||||||
|
maxDistance={100}
|
||||||
|
minDistance={5}
|
||||||
|
maxPolarAngle={Math.PI / 2 - 0.1}
|
||||||
|
minPolarAngle={0}
|
||||||
|
mouseButtons={mouseButtons}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { type AnyNode, type BuildingNode, type LevelNode, type ZoneNode, useScene } from '@pascal-app/core'
|
import { type AnyNode, type BuildingNode, type LevelNode, type ZoneNode, useScene } from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { ChevronRight } from 'lucide-react'
|
import { Box, ChevronRight, Diamond, Eye, EyeOff, Image, Layers, Layers2 } from 'lucide-react'
|
||||||
|
|
||||||
const getNodeName = (node: AnyNode): string => {
|
const getNodeName = (node: AnyNode): string => {
|
||||||
if ('name' in node && node.name) return node.name
|
if ('name' in node && node.name) return node.name
|
||||||
@@ -17,6 +17,10 @@ const getNodeName = (node: AnyNode): string => {
|
|||||||
export const ViewerOverlay = () => {
|
export const ViewerOverlay = () => {
|
||||||
const selection = useViewer((s) => s.selection)
|
const selection = useViewer((s) => s.selection)
|
||||||
const nodes = useScene((s) => s.nodes)
|
const nodes = useScene((s) => s.nodes)
|
||||||
|
const showScans = useViewer((s) => s.showScans)
|
||||||
|
const showGuides = useViewer((s) => s.showGuides)
|
||||||
|
const cameraMode = useViewer((s) => s.cameraMode)
|
||||||
|
const levelMode = useViewer((s) => s.levelMode)
|
||||||
|
|
||||||
const building = selection.buildingId ? (nodes[selection.buildingId] as BuildingNode | undefined) : null
|
const building = selection.buildingId ? (nodes[selection.buildingId] as BuildingNode | undefined) : null
|
||||||
const level = selection.levelId ? (nodes[selection.levelId] as LevelNode | undefined) : null
|
const level = selection.levelId ? (nodes[selection.levelId] as LevelNode | undefined) : null
|
||||||
@@ -53,6 +57,7 @@ export const ViewerOverlay = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className="absolute top-4 left-4 z-10 flex flex-col gap-3">
|
<div className="absolute top-4 left-4 z-10 flex flex-col gap-3">
|
||||||
{/* Breadcrumb */}
|
{/* Breadcrumb */}
|
||||||
<div className="flex items-center gap-1 text-sm">
|
<div className="flex items-center gap-1 text-sm">
|
||||||
@@ -124,5 +129,80 @@ export const ViewerOverlay = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Controls Panel - Top Right */}
|
||||||
|
<div className="absolute top-4 right-4 z-10 flex flex-col gap-2">
|
||||||
|
{/* Visibility Controls */}
|
||||||
|
<div className="flex flex-col gap-1 bg-white/80 backdrop-blur-sm rounded-lg p-2 shadow-sm border border-neutral-200">
|
||||||
|
<span className="text-xs text-neutral-500 px-2 pb-1">Visibility</span>
|
||||||
|
<button
|
||||||
|
onClick={() => useViewer.getState().setShowScans(!showScans)}
|
||||||
|
className={`flex items-center gap-2 px-2 py-1 rounded text-sm transition-colors ${
|
||||||
|
showScans ? 'bg-blue-500 text-white' : 'text-neutral-700 hover:bg-neutral-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Box className="w-4 h-4" />
|
||||||
|
3D Scans
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => useViewer.getState().setShowGuides(!showGuides)}
|
||||||
|
className={`flex items-center gap-2 px-2 py-1 rounded text-sm transition-colors ${
|
||||||
|
showGuides ? 'bg-blue-500 text-white' : 'text-neutral-700 hover:bg-neutral-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Image className="w-4 h-4" />
|
||||||
|
Guides
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Camera Mode */}
|
||||||
|
<div className="flex flex-col gap-1 bg-white/80 backdrop-blur-sm rounded-lg p-2 shadow-sm border border-neutral-200">
|
||||||
|
<span className="text-xs text-neutral-500 px-2 pb-1">Camera</span>
|
||||||
|
<button
|
||||||
|
onClick={() => useViewer.getState().setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective')}
|
||||||
|
className="flex items-center gap-2 px-2 py-1 rounded text-sm text-neutral-700 hover:bg-neutral-100 transition-colors"
|
||||||
|
>
|
||||||
|
{cameraMode === 'perspective' ? (
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<EyeOff className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
{cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Level Mode */}
|
||||||
|
<div className="flex flex-col gap-1 bg-white/80 backdrop-blur-sm rounded-lg p-2 shadow-sm border border-neutral-200">
|
||||||
|
<span className="text-xs text-neutral-500 px-2 pb-1">Level Mode</span>
|
||||||
|
<button
|
||||||
|
onClick={() => useViewer.getState().setLevelMode('stacked')}
|
||||||
|
className={`flex items-center gap-2 px-2 py-1 rounded text-sm transition-colors ${
|
||||||
|
levelMode === 'stacked' ? 'bg-blue-500 text-white' : 'text-neutral-700 hover:bg-neutral-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Layers className="w-4 h-4" />
|
||||||
|
Stacked
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => useViewer.getState().setLevelMode('exploded')}
|
||||||
|
className={`flex items-center gap-2 px-2 py-1 rounded text-sm transition-colors ${
|
||||||
|
levelMode === 'exploded' ? 'bg-blue-500 text-white' : 'text-neutral-700 hover:bg-neutral-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Layers2 className="w-4 h-4" />
|
||||||
|
Exploded
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => useViewer.getState().setLevelMode('solo')}
|
||||||
|
className={`flex items-center gap-2 px-2 py-1 rounded text-sm transition-colors ${
|
||||||
|
levelMode === 'solo' ? 'bg-blue-500 text-white' : 'text-neutral-700 hover:bg-neutral-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Diamond className="w-4 h-4" />
|
||||||
|
Solo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+2213
-17565
File diff suppressed because one or more lines are too long
@@ -15,10 +15,49 @@ import {
|
|||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { useThree } from '@react-three/fiber'
|
import { useThree } from '@react-three/fiber'
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { Vector3 } from 'three'
|
||||||
import useViewer from '../../store/use-viewer'
|
import useViewer from '../../store/use-viewer'
|
||||||
|
|
||||||
|
const tempWorldPos = new Vector3()
|
||||||
|
|
||||||
|
// Tolerance for edge detection (in meters)
|
||||||
|
const EDGE_TOLERANCE = 0.5
|
||||||
|
|
||||||
type SelectableNodeType = 'building' | 'level' | 'zone' | 'wall' | 'item' | 'slab' | 'ceiling' | 'roof'
|
type SelectableNodeType = 'building' | 'level' | 'zone' | 'wall' | 'item' | 'slab' | 'ceiling' | 'roof'
|
||||||
|
|
||||||
|
// Expand polygon outward by a small amount to include items on edges
|
||||||
|
const expandPolygon = (polygon: [number, number][], tolerance: number): [number, number][] => {
|
||||||
|
if (polygon.length < 3) return polygon
|
||||||
|
|
||||||
|
// Calculate centroid
|
||||||
|
let cx = 0, cz = 0
|
||||||
|
for (const [x, z] of polygon) {
|
||||||
|
cx += x
|
||||||
|
cz += z
|
||||||
|
}
|
||||||
|
cx /= polygon.length
|
||||||
|
cz /= polygon.length
|
||||||
|
|
||||||
|
// Expand each point outward from centroid
|
||||||
|
return polygon.map(([x, z]) => {
|
||||||
|
const dx = x - cx
|
||||||
|
const dz = z - cz
|
||||||
|
const len = Math.sqrt(dx * dx + dz * dz)
|
||||||
|
if (len === 0) return [x, z] as [number, number]
|
||||||
|
const scale = (len + tolerance) / len
|
||||||
|
return [cx + dx * scale, cz + dz * scale] as [number, number]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if point is in polygon with tolerance for edges
|
||||||
|
const pointInPolygonWithTolerance = (x: number, z: number, polygon: [number, number][]): boolean => {
|
||||||
|
// First try exact check
|
||||||
|
if (pointInPolygon(x, z, polygon)) return true
|
||||||
|
// Then try with expanded polygon for edge tolerance
|
||||||
|
const expanded = expandPolygon(polygon, EDGE_TOLERANCE)
|
||||||
|
return pointInPolygon(x, z, expanded)
|
||||||
|
}
|
||||||
|
|
||||||
interface SelectionStrategy {
|
interface SelectionStrategy {
|
||||||
types: SelectableNodeType[]
|
types: SelectableNodeType[]
|
||||||
handleClick: (node: AnyNode) => void
|
handleClick: (node: AnyNode) => void
|
||||||
@@ -26,30 +65,59 @@ interface SelectionStrategy {
|
|||||||
isValid: (node: AnyNode) => boolean
|
isValid: (node: AnyNode) => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if a node is within the selected zone's polygon
|
// Check if a node belongs to the selected level (directly or via wall parent)
|
||||||
const isNodeInZone = (node: AnyNode, zoneId: string): boolean => {
|
const isNodeOnLevel = (node: AnyNode, levelId: string): boolean => {
|
||||||
const nodes = useScene.getState().nodes
|
const nodes = useScene.getState().nodes
|
||||||
const zone = nodes[zoneId] as ZoneNode | undefined
|
|
||||||
|
// Direct child of level
|
||||||
|
if (node.parentId === levelId) return true
|
||||||
|
|
||||||
|
// Wall-attached items (windows/doors): check if parent wall is on the level
|
||||||
|
if (node.type === 'item' && node.parentId) {
|
||||||
|
const parentNode = nodes[node.parentId as keyof typeof nodes]
|
||||||
|
if (parentNode?.type === 'wall' && parentNode.parentId === levelId) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a node is on the selected level and within the selected zone's polygon
|
||||||
|
const isNodeInZone = (node: AnyNode, levelId: string, zoneId: string): boolean => {
|
||||||
|
const nodes = useScene.getState().nodes
|
||||||
|
const zone = nodes[zoneId as keyof typeof nodes] as ZoneNode | undefined
|
||||||
if (!zone?.polygon?.length) return false
|
if (!zone?.polygon?.length) return false
|
||||||
|
|
||||||
|
// First check: node must be on the same level (directly or via wall)
|
||||||
|
if (!isNodeOnLevel(node, levelId)) return false
|
||||||
|
|
||||||
|
// Use world position from scene registry for accurate polygon check
|
||||||
|
const object3D = sceneRegistry.nodes.get(node.id)
|
||||||
|
if (object3D) {
|
||||||
|
object3D.getWorldPosition(tempWorldPos)
|
||||||
|
return pointInPolygonWithTolerance(tempWorldPos.x, tempWorldPos.z, zone.polygon)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to node data if 3D object not available
|
||||||
if (node.type === 'item') {
|
if (node.type === 'item') {
|
||||||
const item = node as ItemNode
|
const item = node as ItemNode
|
||||||
return pointInPolygon(item.position[0], item.position[2], zone.polygon)
|
return pointInPolygonWithTolerance(item.position[0], item.position[2], zone.polygon)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.type === 'wall') {
|
if (node.type === 'wall') {
|
||||||
const wall = node as WallNode
|
const wall = node as WallNode
|
||||||
const startIn = pointInPolygon(wall.start[0], wall.start[1], zone.polygon)
|
const startIn = pointInPolygonWithTolerance(wall.start[0], wall.start[1], zone.polygon)
|
||||||
const endIn = pointInPolygon(wall.end[0], wall.end[1], zone.polygon)
|
const endIn = pointInPolygonWithTolerance(wall.end[0], wall.end[1], zone.polygon)
|
||||||
return startIn || endIn
|
return startIn || endIn
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.type === 'slab' || node.type === 'ceiling') {
|
if (node.type === 'slab' || node.type === 'ceiling') {
|
||||||
const poly = (node as { polygon: [number, number][] }).polygon
|
const poly = (node as { polygon: [number, number][] }).polygon
|
||||||
if (!poly?.length) return false
|
if (!poly?.length) return false
|
||||||
// Check if any point of the node's polygon is in the zone
|
// Check if any point of the node's polygon is in the zone (with tolerance)
|
||||||
for (const [px, pz] of poly) {
|
for (const [px, pz] of poly) {
|
||||||
if (pointInPolygon(px, pz, zone.polygon)) return true
|
if (pointInPolygonWithTolerance(px, pz, zone.polygon)) return true
|
||||||
}
|
}
|
||||||
// Check if any point of the zone is in the node's polygon
|
// Check if any point of the zone is in the node's polygon
|
||||||
for (const [zx, zz] of zone.polygon) {
|
for (const [zx, zz] of zone.polygon) {
|
||||||
@@ -59,8 +127,8 @@ const isNodeInZone = (node: AnyNode, zoneId: string): boolean => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (node.type === 'roof') {
|
if (node.type === 'roof') {
|
||||||
// Roofs may not have a polygon, check by parent level
|
// Roofs on the same level are valid when zone is selected
|
||||||
return true // Allow all roofs when zone is selected
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
@@ -97,7 +165,7 @@ const getStrategy = (): SelectionStrategy | null => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Level selected, no zone -> can select zones
|
// Level selected, no zone -> can select zones (only zones on the selected level)
|
||||||
if (!zoneId) {
|
if (!zoneId) {
|
||||||
return {
|
return {
|
||||||
types: ['zone'],
|
types: ['zone'],
|
||||||
@@ -107,7 +175,7 @@ const getStrategy = (): SelectionStrategy | null => {
|
|||||||
handleDeselect: () => {
|
handleDeselect: () => {
|
||||||
useViewer.getState().setSelection({ levelId: null })
|
useViewer.getState().setSelection({ levelId: null })
|
||||||
},
|
},
|
||||||
isValid: (node) => node.type === 'zone',
|
isValid: (node) => node.type === 'zone' && node.parentId === levelId,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +203,7 @@ const getStrategy = (): SelectionStrategy | null => {
|
|||||||
isValid: (node) => {
|
isValid: (node) => {
|
||||||
const validTypes = ['wall', 'item', 'slab', 'ceiling', 'roof']
|
const validTypes = ['wall', 'item', 'slab', 'ceiling', 'roof']
|
||||||
if (!validTypes.includes(node.type)) return false
|
if (!validTypes.includes(node.type)) return false
|
||||||
return isNodeInZone(node, zoneId)
|
return isNodeInZone(node, levelId, zoneId)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,6 +239,8 @@ export const SelectionManager = () => {
|
|||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
clickHandledRef.current = true
|
clickHandledRef.current = true
|
||||||
strategy.handleClick(event.node)
|
strategy.handleClick(event.node)
|
||||||
|
// Clear hover immediately after clicking on building/level/zone
|
||||||
|
useViewer.setState({ hoveredId: null })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to all node types
|
// Subscribe to all node types
|
||||||
|
|||||||
Reference in New Issue
Block a user