179 lines
5.1 KiB
TypeScript
179 lines
5.1 KiB
TypeScript
'use client'
|
|
|
|
import { emitter, sceneRegistry, useScene } from '@pascal-app/core'
|
|
import { useViewer } from '@pascal-app/viewer'
|
|
import { CameraControls, CameraControlsImpl } from '@react-three/drei'
|
|
import { useCallback, 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.SCREEN_PAN,
|
|
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)
|
|
}
|
|
|
|
|
|
let node = targetNodeId ? nodes[targetNodeId] : null;
|
|
if (!targetNodeId) {
|
|
const site = Object.values(nodes).find((n) => n.type === 'site')
|
|
node = site || null
|
|
}
|
|
if (!node) return
|
|
|
|
// Check if node has a saved camera
|
|
if (node.camera) {
|
|
|
|
const { position, target } = node.camera
|
|
requestAnimationFrame(() => {
|
|
controls.current.setLookAt(
|
|
position[0],
|
|
position[1],
|
|
position[2],
|
|
target[0],
|
|
target[1],
|
|
target[2],
|
|
true,
|
|
)
|
|
})
|
|
return
|
|
}
|
|
if (!targetNodeId) {
|
|
// No selection and no site - do nothing
|
|
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])
|
|
|
|
useEffect(() => {
|
|
const handleTopView = () => {
|
|
if (!controls.current) return
|
|
|
|
const currentPolarAngle = controls.current.polarAngle
|
|
|
|
// Toggle: if already near top view (< 0.1 radians ≈ 5.7°), go back to 45°
|
|
// Otherwise, go to top view (0°)
|
|
const targetAngle = currentPolarAngle < 0.1 ? Math.PI / 4 : 0
|
|
|
|
controls.current.rotatePolarTo(targetAngle, true)
|
|
}
|
|
|
|
const handleOrbitCW = () => {
|
|
if (!controls.current) return
|
|
|
|
const currentAzimuth = controls.current.azimuthAngle
|
|
const currentPolar = controls.current.polarAngle
|
|
// Round to nearest 90° increment, then rotate 90° clockwise
|
|
const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)
|
|
const target = rounded - Math.PI / 2
|
|
|
|
controls.current.rotateTo(target, currentPolar, true)
|
|
}
|
|
|
|
const handleOrbitCCW = () => {
|
|
if (!controls.current) return
|
|
|
|
const currentAzimuth = controls.current.azimuthAngle
|
|
const currentPolar = controls.current.polarAngle
|
|
// Round to nearest 90° increment, then rotate 90° counter-clockwise
|
|
const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)
|
|
const target = rounded + Math.PI / 2
|
|
|
|
controls.current.rotateTo(target, currentPolar, true)
|
|
}
|
|
|
|
emitter.on('camera-controls:top-view', handleTopView)
|
|
emitter.on('camera-controls:orbit-cw', handleOrbitCW)
|
|
emitter.on('camera-controls:orbit-ccw', handleOrbitCCW)
|
|
|
|
return () => {
|
|
emitter.off('camera-controls:top-view', handleTopView)
|
|
emitter.off('camera-controls:orbit-cw', handleOrbitCW)
|
|
emitter.off('camera-controls:orbit-ccw', handleOrbitCCW)
|
|
}
|
|
}, [])
|
|
|
|
const onTransitionStart = useCallback(() => {
|
|
useViewer.getState().setCameraDragging(true)
|
|
}, [])
|
|
|
|
const onRest = useCallback(() => {
|
|
useViewer.getState().setCameraDragging(false)
|
|
}, [])
|
|
|
|
return (
|
|
<CameraControls
|
|
ref={controls}
|
|
maxDistance={100}
|
|
minDistance={5}
|
|
maxPolarAngle={Math.PI / 2 - 0.1}
|
|
minPolarAngle={0}
|
|
mouseButtons={mouseButtons}
|
|
onTransitionStart={onTransitionStart}
|
|
onRest={onRest}
|
|
restThreshold={0.01}
|
|
|
|
/>
|
|
)
|
|
}
|