Adds a Preview button next to Radio Pascal that switches the editor into a viewer-like experience without leaving the page. The preview mode swaps in the viewer's selection manager (hierarchical drill-down), zone system, camera controls (left-click pan, auto-navigate), and interactive item system while hiding editor-only UI (tools, panels, sidebar, grid). Back arrow in the viewer overlay returns to the editor. Also widens the default sidebar from 288px to 432px for better project title visibility. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
322 lines
9.8 KiB
TypeScript
322 lines
9.8 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 { Box3, Vector3 } from 'three'
|
|
import { EDITOR_LAYER } from '@/lib/constants'
|
|
import useEditor from '@/store/use-editor'
|
|
|
|
const currentTarget = new Vector3()
|
|
const tempBox = new Box3()
|
|
const tempCenter = new Vector3()
|
|
const tempSize = new Vector3()
|
|
|
|
export const CustomCameraControls = () => {
|
|
const controls = useRef<CameraControlsImpl>(null!)
|
|
const isPreviewMode = useEditor((s) => s.isPreviewMode)
|
|
const selection = useViewer((s) => s.selection)
|
|
const currentLevelId = selection.levelId
|
|
const firstLoad = useRef(true)
|
|
|
|
const camera = useThree((state) => state.camera)
|
|
const raycaster = useThree((state) => state.raycaster)
|
|
useEffect(() => {
|
|
camera.layers.enable(EDITOR_LAYER)
|
|
raycaster.layers.enable(EDITOR_LAYER)
|
|
}, [camera, raycaster])
|
|
|
|
useEffect(() => {
|
|
if (isPreviewMode) return // Preview mode uses auto-navigate instead
|
|
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, isPreviewMode])
|
|
|
|
// 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: isPreviewMode
|
|
? CameraControlsImpl.ACTION.SCREEN_PAN
|
|
: CameraControlsImpl.ACTION.NONE,
|
|
middle: CameraControlsImpl.ACTION.SCREEN_PAN,
|
|
right: CameraControlsImpl.ACTION.ROTATE,
|
|
wheel: wheelAction,
|
|
}
|
|
}, [cameraMode, isPreviewMode])
|
|
|
|
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.middle = CameraControlsImpl.ACTION.SCREEN_PAN
|
|
controls.current.mouseButtons.right = CameraControlsImpl.ACTION.ROTATE
|
|
if (isPreviewMode) {
|
|
// In preview mode, left-click is always pan (viewer-style)
|
|
controls.current.mouseButtons.left = CameraControlsImpl.ACTION.SCREEN_PAN
|
|
} else if (space) {
|
|
controls.current.mouseButtons.left = CameraControlsImpl.ACTION.SCREEN_PAN
|
|
} else {
|
|
controls.current.mouseButtons.left = CameraControlsImpl.ACTION.NONE
|
|
}
|
|
}
|
|
|
|
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, isPreviewMode])
|
|
|
|
// Preview mode: auto-navigate camera to selected node (viewer behavior)
|
|
const previewTargetNodeId = isPreviewMode
|
|
? (selection.zoneId ?? selection.levelId ?? selection.buildingId)
|
|
: null
|
|
|
|
useEffect(() => {
|
|
if (!isPreviewMode || !controls.current) return
|
|
|
|
const nodes = useScene.getState().nodes
|
|
let node = previewTargetNodeId ? nodes[previewTargetNodeId] : null
|
|
|
|
if (!previewTargetNodeId) {
|
|
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(() => {
|
|
if (!controls.current) return
|
|
controls.current.setLookAt(
|
|
position[0], position[1], position[2],
|
|
target[0], target[1], target[2],
|
|
true,
|
|
)
|
|
})
|
|
return
|
|
}
|
|
|
|
if (!previewTargetNodeId) return
|
|
|
|
// Calculate camera position from bounding box
|
|
const object3D = sceneRegistry.nodes.get(previewTargetNodeId)
|
|
if (!object3D) return
|
|
|
|
tempBox.setFromObject(object3D)
|
|
tempBox.getCenter(tempCenter)
|
|
tempBox.getSize(tempSize)
|
|
|
|
const maxDim = Math.max(tempSize.x, tempSize.y, tempSize.z)
|
|
const distance = Math.max(maxDim * 2, 15)
|
|
|
|
controls.current.setLookAt(
|
|
tempCenter.x + distance * 0.7,
|
|
tempCenter.y + distance * 0.5,
|
|
tempCenter.z + distance * 0.7,
|
|
tempCenter.x,
|
|
tempCenter.y,
|
|
tempCenter.z,
|
|
true,
|
|
)
|
|
}, [isPreviewMode, previewTargetNodeId])
|
|
|
|
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}
|
|
/>
|
|
)
|
|
}
|