camera controls enhancement

This commit is contained in:
wass08
2026-02-17 08:52:58 +09:00
parent 06e6ccb88f
commit 144a2bd18f
6 changed files with 171 additions and 22 deletions
@@ -3,7 +3,7 @@
import { sceneRegistry, useScene } from '@pascal-app/core' import { sceneRegistry, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { CameraControls, CameraControlsImpl } from '@react-three/drei' import { CameraControls, CameraControlsImpl } from '@react-three/drei'
import { useEffect, useMemo, useRef } from 'react' import { useCallback, useEffect, useMemo, useRef } from 'react'
import { Box3, Vector3 } from 'three' import { Box3, Vector3 } from 'three'
const tempBox = new Box3() const tempBox = new Box3()
@@ -28,7 +28,7 @@ export const ViewerCameraControls = () => {
: CameraControlsImpl.ACTION.DOLLY : CameraControlsImpl.ACTION.DOLLY
return { return {
left: CameraControlsImpl.ACTION.NONE, left: CameraControlsImpl.ACTION.SCREEN_PAN,
middle: CameraControlsImpl.ACTION.SCREEN_PAN, middle: CameraControlsImpl.ACTION.SCREEN_PAN,
right: CameraControlsImpl.ACTION.ROTATE, right: CameraControlsImpl.ACTION.ROTATE,
wheel: wheelAction, wheel: wheelAction,
@@ -59,7 +59,7 @@ export const ViewerCameraControls = () => {
target[0], target[0],
target[1], target[1],
target[2], target[2],
true true,
) )
return return
} }
@@ -81,7 +81,7 @@ export const ViewerCameraControls = () => {
const cameraPos = new Vector3( const cameraPos = new Vector3(
tempCenter.x + distance * 0.7, tempCenter.x + distance * 0.7,
tempCenter.y + distance * 0.5, tempCenter.y + distance * 0.5,
tempCenter.z + distance * 0.7 tempCenter.z + distance * 0.7,
) )
controls.current.setLookAt( controls.current.setLookAt(
@@ -91,10 +91,18 @@ export const ViewerCameraControls = () => {
tempCenter.x, tempCenter.x,
tempCenter.y, tempCenter.y,
tempCenter.z, tempCenter.z,
true true,
) )
}, [targetNodeId, nodes]) }, [targetNodeId, nodes])
const onTransitionStart = useCallback(() => {
useViewer.getState().setCameraDragging(true)
}, [])
const onRest = useCallback(() => {
useViewer.getState().setCameraDragging(false)
}, [])
return ( return (
<CameraControls <CameraControls
ref={controls} ref={controls}
@@ -103,6 +111,10 @@ export const ViewerCameraControls = () => {
maxPolarAngle={Math.PI / 2 - 0.1} maxPolarAngle={Math.PI / 2 - 0.1}
minPolarAngle={0} minPolarAngle={0}
mouseButtons={mouseButtons} mouseButtons={mouseButtons}
onTransitionStart={onTransitionStart}
onRest={onRest}
restThreshold={0.01}
/> />
) )
} }
@@ -3,7 +3,7 @@
import { type CameraControlEvent, emitter, sceneRegistry, useScene } from '@pascal-app/core' import { type CameraControlEvent, emitter, sceneRegistry, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { CameraControls, CameraControlsImpl } from '@react-three/drei' import { CameraControls, CameraControlsImpl } from '@react-three/drei'
import { useEffect, useMemo, useRef } from 'react' import { useCallback, useEffect, useMemo, useRef } from 'react'
import { Vector3 } from 'three' import { Vector3 } from 'three'
const currentTarget = new Vector3() const currentTarget = new Vector3()
@@ -51,6 +51,86 @@ export const CustomCameraControls = () => {
} }
}, [cameraMode]) }, [cameraMode])
useEffect(() => {
console.log('ohla')
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(() => { useEffect(() => {
const handleNodeCapture = ({ nodeId }: CameraControlEvent) => { const handleNodeCapture = ({ nodeId }: CameraControlEvent) => {
if (!controls.current) return if (!controls.current) return
@@ -139,6 +219,14 @@ export const CustomCameraControls = () => {
} }
}, []) }, [])
const onTransitionStart = useCallback(() => {
useViewer.getState().setCameraDragging(true)
}, [])
const onRest = useCallback(() => {
useViewer.getState().setCameraDragging(false)
}, [])
return ( return (
<CameraControls <CameraControls
makeDefault makeDefault
@@ -148,6 +236,9 @@ export const CustomCameraControls = () => {
minPolarAngle={0} minPolarAngle={0}
ref={controls} ref={controls}
mouseButtons={mouseButtons} mouseButtons={mouseButtons}
onTransitionStart={onTransitionStart}
onRest={onRest}
restThreshold={0.01}
/> />
) )
} }
+8 -1
View File
@@ -1,4 +1,5 @@
import { type EventSuffix, emitter, type GridEvent } from '@pascal-app/core' import { type EventSuffix, emitter, type GridEvent } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useThree } from '@react-three/fiber' import { useThree } from '@react-three/fiber'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { Plane, Raycaster, Vector2, Vector3 } from 'three' import { Plane, Raycaster, Vector2, Vector3 } from 'three'
@@ -53,29 +54,35 @@ export function useGridEvents(gridY: number) {
} }
const handlePointerDown = (e: PointerEvent) => { const handlePointerDown = (e: PointerEvent) => {
if (useViewer.getState().cameraDragging) return
if (e.button !== 0) return if (e.button !== 0) return
emit('pointerdown', e) emit('pointerdown', e)
} }
const handlePointerUp = (e: PointerEvent) => { const handlePointerUp = (e: PointerEvent) => {
if (useViewer.getState().cameraDragging) return
if (e.button !== 0) return if (e.button !== 0) return
emit('pointerup', e) emit('pointerup', e)
} }
const handleClick = (e: PointerEvent) => { const handleClick = (e: PointerEvent) => {
if (useViewer.getState().cameraDragging) return
if (e.button !== 0) return if (e.button !== 0) return
emit('click', e) emit('click', e)
} }
const handlePointerMove = (e: PointerEvent) => { const handlePointerMove = (e: PointerEvent) => {
if (useViewer.getState().cameraDragging) return
emit('move', e) emit('move', e)
} }
const handleDoubleClick = (e: MouseEvent) => { const handleDoubleClick = (e: MouseEvent) => {
if (useViewer.getState().cameraDragging) return
emit('double-click', e) emit('double-click', e)
} }
const handleContextMenu = (e: MouseEvent) => { const handleContextMenu = (e: MouseEvent) => {
if (useViewer.getState().cameraDragging) return
emit('context-menu', e) emit('context-menu', e)
} }
@@ -95,5 +102,5 @@ export function useGridEvents(gridY: number) {
canvas.removeEventListener('dblclick', handleDoubleClick) canvas.removeEventListener('dblclick', handleDoubleClick)
canvas.removeEventListener('contextmenu', handleContextMenu) canvas.removeEventListener('contextmenu', handleContextMenu)
} }
}, [camera, gl, gridY]) }, [camera, gl])
} }
@@ -3,15 +3,15 @@
import { import {
type AnyNode, type AnyNode,
type BuildingNode, type BuildingNode,
emitter,
type ItemNode, type ItemNode,
type LevelNode, type LevelNode,
type NodeEvent, type NodeEvent,
type WallNode,
type ZoneNode,
emitter,
pointInPolygon, pointInPolygon,
sceneRegistry, sceneRegistry,
useScene, useScene,
type WallNode,
type ZoneNode,
} 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'
@@ -23,14 +23,23 @@ const tempWorldPos = new Vector3()
// Tolerance for edge detection (in meters) // Tolerance for edge detection (in meters)
const EDGE_TOLERANCE = 0.5 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 // Expand polygon outward by a small amount to include items on edges
const expandPolygon = (polygon: [number, number][], tolerance: number): [number, number][] => { const expandPolygon = (polygon: [number, number][], tolerance: number): [number, number][] => {
if (polygon.length < 3) return polygon if (polygon.length < 3) return polygon
// Calculate centroid // Calculate centroid
let cx = 0, cz = 0 let cx = 0,
cz = 0
for (const [x, z] of polygon) { for (const [x, z] of polygon) {
cx += x cx += x
cz += z cz += z
@@ -50,7 +59,11 @@ const expandPolygon = (polygon: [number, number][], tolerance: number): [number,
} }
// Check if point is in polygon with tolerance for edges // Check if point is in polygon with tolerance for edges
const pointInPolygonWithTolerance = (x: number, z: number, polygon: [number, number][]): boolean => { const pointInPolygonWithTolerance = (
x: number,
z: number,
polygon: [number, number][],
): boolean => {
// First try exact check // First try exact check
if (pointInPolygon(x, z, polygon)) return true if (pointInPolygon(x, z, polygon)) return true
// Then try with expanded polygon for edge tolerance // Then try with expanded polygon for edge tolerance
@@ -186,7 +199,9 @@ const getStrategy = (): SelectionStrategy | null => {
const { selectedIds } = useViewer.getState().selection const { selectedIds } = useViewer.getState().selection
// Toggle selection - if already selected, deselect; otherwise select // Toggle selection - if already selected, deselect; otherwise select
if (selectedIds.includes(node.id)) { if (selectedIds.includes(node.id)) {
useViewer.getState().setSelection({ selectedIds: selectedIds.filter((id) => id !== node.id) }) useViewer
.getState()
.setSelection({ selectedIds: selectedIds.filter((id) => id !== node.id) })
} else { } else {
useViewer.getState().setSelection({ selectedIds: [node.id] }) useViewer.getState().setSelection({ selectedIds: [node.id] })
} }
@@ -244,7 +259,16 @@ export const SelectionManager = () => {
} }
// Subscribe to all node types // Subscribe to all node types
const allTypes: SelectableNodeType[] = ['building', 'level', 'zone', 'wall', 'item', 'slab', 'ceiling', 'roof'] const allTypes: SelectableNodeType[] = [
'building',
'level',
'zone',
'wall',
'item',
'slab',
'ceiling',
'roof',
]
for (const type of allTypes) { for (const type of allTypes) {
emitter.on(`${type}:enter`, onEnter) emitter.on(`${type}:enter`, onEnter)
emitter.on(`${type}:leave`, onLeave) emitter.on(`${type}:leave`, onLeave)
@@ -258,7 +282,7 @@ export const SelectionManager = () => {
emitter.off(`${type}:click`, onClick) emitter.off(`${type}:click`, onClick)
} }
} }
}, [selection]) }, [])
return ( return (
<> <>
@@ -268,12 +292,17 @@ export const SelectionManager = () => {
) )
} }
const PointerMissedHandler = ({ clickHandledRef }: { clickHandledRef: React.MutableRefObject<boolean> }) => { const PointerMissedHandler = ({
clickHandledRef,
}: {
clickHandledRef: React.MutableRefObject<boolean>
}) => {
const gl = useThree((s) => s.gl) const gl = useThree((s) => s.gl)
useEffect(() => { useEffect(() => {
const handleClick = (event: MouseEvent) => { const handleClick = (event: MouseEvent) => {
// Only handle left clicks // Only handle left clicks
if (useViewer.getState().cameraDragging) return
if (event.button !== 0) return if (event.button !== 0) return
// Use requestAnimationFrame to check after R3F event handlers // Use requestAnimationFrame to check after R3F event handlers
+9 -5
View File
@@ -21,6 +21,7 @@ import {
type ZoneNode, type ZoneNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import type { ThreeEvent } from '@react-three/fiber' import type { ThreeEvent } from '@react-three/fiber'
import useViewer from '../store/use-viewer';
type NodeConfig = { type NodeConfig = {
site: { node: SiteNode; event: SiteEvent } site: { node: SiteNode; event: SiteEvent }
@@ -54,21 +55,24 @@ export function useNodeEvents<T extends NodeType>(node: NodeConfig[T]['node'], t
return { return {
onPointerDown: (e: ThreeEvent<PointerEvent>) => { onPointerDown: (e: ThreeEvent<PointerEvent>) => {
if (useViewer.getState().cameraDragging) return
if (e.button !== 0) return if (e.button !== 0) return
emit('pointerdown', e) emit('pointerdown', e)
}, },
onPointerUp: (e: ThreeEvent<PointerEvent>) => { onPointerUp: (e: ThreeEvent<PointerEvent>) => {
if (useViewer.getState().cameraDragging) return
if (e.button !== 0) return if (e.button !== 0) return
emit('pointerup', e) emit('pointerup', e)
}, },
onClick: (e: ThreeEvent<PointerEvent>) => { onClick: (e: ThreeEvent<PointerEvent>) => {
if (useViewer.getState().cameraDragging) return
if (e.button !== 0) return if (e.button !== 0) return
emit('click', e) emit('click', e)
}, },
onPointerEnter: (e: ThreeEvent<PointerEvent>) => emit('enter', e), onPointerEnter: (e: ThreeEvent<PointerEvent>) => {if (useViewer.getState().cameraDragging) return; emit('enter', e)},
onPointerLeave: (e: ThreeEvent<PointerEvent>) => emit('leave', e), onPointerLeave: (e: ThreeEvent<PointerEvent>) => {if (useViewer.getState().cameraDragging) return; emit('leave', e)},
onPointerMove: (e: ThreeEvent<PointerEvent>) => emit('move', e), onPointerMove: (e: ThreeEvent<PointerEvent>) => {if (useViewer.getState().cameraDragging) return; emit('move', e)},
onDoubleClick: (e: ThreeEvent<PointerEvent>) => emit('double-click', e), onDoubleClick: (e: ThreeEvent<PointerEvent>) => {if (useViewer.getState().cameraDragging) return; emit('double-click', e)},
onContextMenu: (e: ThreeEvent<PointerEvent>) => emit('context-menu', e), onContextMenu: (e: ThreeEvent<PointerEvent>) => {if (useViewer.getState().cameraDragging) return; emit('context-menu', e)},
} }
} }
+6
View File
@@ -52,6 +52,9 @@ type ViewerState = {
// Export functionality // Export functionality
exportScene: (() => Promise<void>) | null exportScene: (() => Promise<void>) | null
setExportScene: (fn: (() => Promise<void>) | null) => void setExportScene: (fn: (() => Promise<void>) | null) => void
cameraDragging: boolean
setCameraDragging: (dragging: boolean) => void
} }
const useViewer = create<ViewerState>()((set, get) => ({ const useViewer = create<ViewerState>()((set, get) => ({
@@ -107,6 +110,9 @@ const useViewer = create<ViewerState>()((set, get) => ({
exportScene: null, exportScene: null,
setExportScene: (fn) => set({ exportScene: fn }), setExportScene: (fn) => set({ exportScene: fn }),
cameraDragging: false,
setCameraDragging: (dragging) => set({ cameraDragging: dragging }),
})); }));
export default useViewer; export default useViewer;