252 lines
7.5 KiB
TypeScript
252 lines
7.5 KiB
TypeScript
'use client'
|
|
|
|
import { type CameraControlEvent, emitter, sceneRegistry, useScene } from '@pascal-app/core'
|
|
import { useViewer } from '@pascal-app/viewer'
|
|
import { CameraControls, CameraControlsImpl } from '@react-three/drei'
|
|
import { useThree } from '@react-three/fiber'
|
|
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
|
import { Vector3 } from 'three'
|
|
import { EDITOR_LAYER } from '@/lib/constants'
|
|
|
|
const currentTarget = new Vector3()
|
|
|
|
export const CustomCameraControls = () => {
|
|
const controls = useRef<CameraControlsImpl>(null!)
|
|
const currentLevelId = useViewer((state) => state.selection.levelId)
|
|
const firstLoad = useRef(true)
|
|
|
|
const camera = useThree((state) => state.camera)
|
|
useEffect(() => {
|
|
camera.layers.enable(EDITOR_LAYER)
|
|
}, [camera])
|
|
|
|
useEffect(() => {
|
|
let targetY = 0
|
|
if (currentLevelId) {
|
|
const levelMesh = sceneRegistry.nodes.get(currentLevelId)
|
|
if (levelMesh) {
|
|
targetY = levelMesh.position.y
|
|
}
|
|
}
|
|
if (firstLoad.current) {
|
|
firstLoad.current = false
|
|
;(controls.current as CameraControlsImpl).setLookAt(20, 20, 20, 0, 0, 0, true)
|
|
}
|
|
;(controls.current as CameraControlsImpl).getTarget(currentTarget)
|
|
;(controls.current as CameraControlsImpl).moveTo(
|
|
currentTarget.x,
|
|
targetY,
|
|
currentTarget.z,
|
|
true,
|
|
)
|
|
}, [currentLevelId])
|
|
|
|
// Configure mouse buttons based on control mode and camera mode
|
|
const cameraMode = useViewer((state) => state.cameraMode)
|
|
const mouseButtons = useMemo(() => {
|
|
// Use ZOOM for orthographic camera, DOLLY for perspective camera
|
|
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(() => {
|
|
const keyState = {
|
|
shiftRight: false,
|
|
shiftLeft: false,
|
|
controlRight: false,
|
|
controlLeft: false,
|
|
space: false,
|
|
}
|
|
|
|
const updateConfig = () => {
|
|
if (!controls.current) return
|
|
|
|
const shift = keyState.shiftRight || keyState.shiftLeft
|
|
const control = keyState.controlRight || keyState.controlLeft
|
|
const space = keyState.space
|
|
|
|
const wheelAction =
|
|
cameraMode === 'orthographic'
|
|
? CameraControlsImpl.ACTION.ZOOM
|
|
: CameraControlsImpl.ACTION.DOLLY
|
|
controls.current.mouseButtons.wheel = wheelAction
|
|
controls.current.mouseButtons.left = CameraControlsImpl.ACTION.NONE
|
|
controls.current.mouseButtons.middle = CameraControlsImpl.ACTION.SCREEN_PAN
|
|
controls.current.mouseButtons.right = CameraControlsImpl.ACTION.ROTATE
|
|
if (space) {
|
|
controls.current.mouseButtons.left = CameraControlsImpl.ACTION.SCREEN_PAN
|
|
}
|
|
}
|
|
|
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
if (event.code === 'Space') {
|
|
keyState.space = true
|
|
document.body.style.cursor = 'grab'
|
|
}
|
|
if (event.code === 'ShiftRight') {
|
|
keyState.shiftRight = true
|
|
}
|
|
if (event.code === 'ShiftLeft') {
|
|
keyState.shiftLeft = true
|
|
}
|
|
if (event.code === 'ControlRight') {
|
|
keyState.controlRight = true
|
|
}
|
|
if (event.code === 'ControlLeft') {
|
|
keyState.controlLeft = true
|
|
}
|
|
updateConfig()
|
|
}
|
|
|
|
const onKeyUp = (event: KeyboardEvent) => {
|
|
if (event.code === 'Space') {
|
|
keyState.space = false
|
|
document.body.style.cursor = ''
|
|
}
|
|
if (event.code === 'ShiftRight') {
|
|
keyState.shiftRight = false
|
|
}
|
|
if (event.code === 'ShiftLeft') {
|
|
keyState.shiftLeft = false
|
|
}
|
|
if (event.code === 'ControlRight') {
|
|
keyState.controlRight = false
|
|
}
|
|
if (event.code === 'ControlLeft') {
|
|
keyState.controlLeft = false
|
|
}
|
|
updateConfig()
|
|
}
|
|
|
|
document.addEventListener('keydown', onKeyDown)
|
|
document.addEventListener('keyup', onKeyUp)
|
|
updateConfig()
|
|
|
|
return () => {
|
|
document.removeEventListener('keydown', onKeyDown)
|
|
document.removeEventListener('keyup', onKeyUp)
|
|
}
|
|
}, [cameraMode])
|
|
|
|
useEffect(() => {
|
|
const handleNodeCapture = ({ nodeId }: CameraControlEvent) => {
|
|
if (!controls.current) return
|
|
|
|
const position = new Vector3()
|
|
const target = new Vector3()
|
|
controls.current.getPosition(position)
|
|
controls.current.getTarget(target)
|
|
|
|
const state = useScene.getState()
|
|
|
|
state.updateNode(nodeId, {
|
|
camera: {
|
|
position: [position.x, position.y, position.z],
|
|
target: [target.x, target.y, target.z],
|
|
mode: useViewer.getState().cameraMode,
|
|
},
|
|
})
|
|
}
|
|
const handleNodeView = ({ nodeId }: CameraControlEvent) => {
|
|
if (!controls.current) return
|
|
|
|
const node = useScene.getState().nodes[nodeId]
|
|
if (!node || !node.camera) return
|
|
const { position, target } = node.camera
|
|
|
|
controls.current.setLookAt(
|
|
position[0],
|
|
position[1],
|
|
position[2],
|
|
target[0],
|
|
target[1],
|
|
target[2],
|
|
true,
|
|
)
|
|
}
|
|
|
|
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:capture', handleNodeCapture)
|
|
emitter.on('camera-controls:view', handleNodeView)
|
|
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:capture', handleNodeCapture)
|
|
emitter.off('camera-controls:view', handleNodeView)
|
|
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
|
|
makeDefault
|
|
maxDistance={100}
|
|
maxPolarAngle={Math.PI / 2 - 0.1}
|
|
minDistance={10}
|
|
minPolarAngle={0}
|
|
ref={controls}
|
|
mouseButtons={mouseButtons}
|
|
onTransitionStart={onTransitionStart}
|
|
onRest={onRest}
|
|
onSleep={onRest}
|
|
restThreshold={0.01}
|
|
/>
|
|
)
|
|
}
|