From 144a2bd18fbbb61c40a47309b16a11bfc18b00bb Mon Sep 17 00:00:00 2001 From: wass08 Date: Tue, 17 Feb 2026 08:52:58 +0900 Subject: [PATCH] camera controls enhancement --- .../viewer/[id]/viewer-camera-controls.tsx | 22 ++++- .../editor/custom-camera-controls.tsx | 93 ++++++++++++++++++- apps/editor/hooks/use-grid-events.ts | 9 +- .../components/viewer/selection-manager.tsx | 49 ++++++++-- packages/viewer/src/hooks/use-node-events.ts | 14 ++- packages/viewer/src/store/use-viewer.ts | 6 ++ 6 files changed, 171 insertions(+), 22 deletions(-) diff --git a/apps/editor/app/viewer/[id]/viewer-camera-controls.tsx b/apps/editor/app/viewer/[id]/viewer-camera-controls.tsx index e43bd28e..ab59152b 100644 --- a/apps/editor/app/viewer/[id]/viewer-camera-controls.tsx +++ b/apps/editor/app/viewer/[id]/viewer-camera-controls.tsx @@ -3,7 +3,7 @@ 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 { useCallback, useEffect, useMemo, useRef } from 'react' import { Box3, Vector3 } from 'three' const tempBox = new Box3() @@ -28,7 +28,7 @@ export const ViewerCameraControls = () => { : CameraControlsImpl.ACTION.DOLLY return { - left: CameraControlsImpl.ACTION.NONE, + left: CameraControlsImpl.ACTION.SCREEN_PAN, middle: CameraControlsImpl.ACTION.SCREEN_PAN, right: CameraControlsImpl.ACTION.ROTATE, wheel: wheelAction, @@ -59,7 +59,7 @@ export const ViewerCameraControls = () => { target[0], target[1], target[2], - true + true, ) return } @@ -81,7 +81,7 @@ export const ViewerCameraControls = () => { const cameraPos = new Vector3( tempCenter.x + distance * 0.7, tempCenter.y + distance * 0.5, - tempCenter.z + distance * 0.7 + tempCenter.z + distance * 0.7, ) controls.current.setLookAt( @@ -91,10 +91,18 @@ export const ViewerCameraControls = () => { tempCenter.x, tempCenter.y, tempCenter.z, - true + true, ) }, [targetNodeId, nodes]) + const onTransitionStart = useCallback(() => { + useViewer.getState().setCameraDragging(true) + }, []) + + const onRest = useCallback(() => { + useViewer.getState().setCameraDragging(false) + }, []) + return ( { maxPolarAngle={Math.PI / 2 - 0.1} minPolarAngle={0} mouseButtons={mouseButtons} + onTransitionStart={onTransitionStart} + onRest={onRest} + restThreshold={0.01} + /> ) } diff --git a/apps/editor/components/editor/custom-camera-controls.tsx b/apps/editor/components/editor/custom-camera-controls.tsx index b6039a52..e96e916a 100644 --- a/apps/editor/components/editor/custom-camera-controls.tsx +++ b/apps/editor/components/editor/custom-camera-controls.tsx @@ -3,7 +3,7 @@ import { type CameraControlEvent, emitter, 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 { useCallback, useEffect, useMemo, useRef } from 'react' import { Vector3 } from 'three' const currentTarget = new Vector3() @@ -51,6 +51,86 @@ export const CustomCameraControls = () => { } }, [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(() => { const handleNodeCapture = ({ nodeId }: CameraControlEvent) => { 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 ( { minPolarAngle={0} ref={controls} mouseButtons={mouseButtons} + onTransitionStart={onTransitionStart} + onRest={onRest} + restThreshold={0.01} /> ) } diff --git a/apps/editor/hooks/use-grid-events.ts b/apps/editor/hooks/use-grid-events.ts index 2a48514e..ddee9065 100644 --- a/apps/editor/hooks/use-grid-events.ts +++ b/apps/editor/hooks/use-grid-events.ts @@ -1,4 +1,5 @@ import { type EventSuffix, emitter, type GridEvent } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' import { useThree } from '@react-three/fiber' import { useEffect, useRef } from 'react' import { Plane, Raycaster, Vector2, Vector3 } from 'three' @@ -53,29 +54,35 @@ export function useGridEvents(gridY: number) { } const handlePointerDown = (e: PointerEvent) => { + if (useViewer.getState().cameraDragging) return if (e.button !== 0) return emit('pointerdown', e) } const handlePointerUp = (e: PointerEvent) => { + if (useViewer.getState().cameraDragging) return if (e.button !== 0) return emit('pointerup', e) } const handleClick = (e: PointerEvent) => { + if (useViewer.getState().cameraDragging) return if (e.button !== 0) return emit('click', e) } const handlePointerMove = (e: PointerEvent) => { + if (useViewer.getState().cameraDragging) return emit('move', e) } const handleDoubleClick = (e: MouseEvent) => { + if (useViewer.getState().cameraDragging) return emit('double-click', e) } const handleContextMenu = (e: MouseEvent) => { + if (useViewer.getState().cameraDragging) return emit('context-menu', e) } @@ -95,5 +102,5 @@ export function useGridEvents(gridY: number) { canvas.removeEventListener('dblclick', handleDoubleClick) canvas.removeEventListener('contextmenu', handleContextMenu) } - }, [camera, gl, gridY]) + }, [camera, gl]) } diff --git a/packages/viewer/src/components/viewer/selection-manager.tsx b/packages/viewer/src/components/viewer/selection-manager.tsx index 2f45a870..433a0657 100644 --- a/packages/viewer/src/components/viewer/selection-manager.tsx +++ b/packages/viewer/src/components/viewer/selection-manager.tsx @@ -3,15 +3,15 @@ import { type AnyNode, type BuildingNode, + emitter, type ItemNode, type LevelNode, type NodeEvent, - type WallNode, - type ZoneNode, - emitter, pointInPolygon, sceneRegistry, useScene, + type WallNode, + type ZoneNode, } from '@pascal-app/core' import { useThree } from '@react-three/fiber' import { useEffect, useRef } from 'react' @@ -23,14 +23,23 @@ 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 + let cx = 0, + cz = 0 for (const [x, z] of polygon) { cx += x cz += z @@ -50,7 +59,11 @@ const expandPolygon = (polygon: [number, number][], tolerance: number): [number, } // 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 if (pointInPolygon(x, z, polygon)) return true // Then try with expanded polygon for edge tolerance @@ -186,7 +199,9 @@ const getStrategy = (): SelectionStrategy | null => { const { selectedIds } = useViewer.getState().selection // Toggle selection - if already selected, deselect; otherwise select 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 { useViewer.getState().setSelection({ selectedIds: [node.id] }) } @@ -244,7 +259,16 @@ export const SelectionManager = () => { } // 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) { emitter.on(`${type}:enter`, onEnter) emitter.on(`${type}:leave`, onLeave) @@ -258,7 +282,7 @@ export const SelectionManager = () => { emitter.off(`${type}:click`, onClick) } } - }, [selection]) + }, []) return ( <> @@ -268,12 +292,17 @@ export const SelectionManager = () => { ) } -const PointerMissedHandler = ({ clickHandledRef }: { clickHandledRef: React.MutableRefObject }) => { +const PointerMissedHandler = ({ + clickHandledRef, +}: { + clickHandledRef: React.MutableRefObject +}) => { const gl = useThree((s) => s.gl) useEffect(() => { const handleClick = (event: MouseEvent) => { // Only handle left clicks + if (useViewer.getState().cameraDragging) return if (event.button !== 0) return // Use requestAnimationFrame to check after R3F event handlers diff --git a/packages/viewer/src/hooks/use-node-events.ts b/packages/viewer/src/hooks/use-node-events.ts index c350edb7..41d4a453 100644 --- a/packages/viewer/src/hooks/use-node-events.ts +++ b/packages/viewer/src/hooks/use-node-events.ts @@ -21,6 +21,7 @@ import { type ZoneNode, } from '@pascal-app/core' import type { ThreeEvent } from '@react-three/fiber' +import useViewer from '../store/use-viewer'; type NodeConfig = { site: { node: SiteNode; event: SiteEvent } @@ -54,21 +55,24 @@ export function useNodeEvents(node: NodeConfig[T]['node'], t return { onPointerDown: (e: ThreeEvent) => { + if (useViewer.getState().cameraDragging) return if (e.button !== 0) return emit('pointerdown', e) }, onPointerUp: (e: ThreeEvent) => { + if (useViewer.getState().cameraDragging) return if (e.button !== 0) return emit('pointerup', e) }, onClick: (e: ThreeEvent) => { + if (useViewer.getState().cameraDragging) return if (e.button !== 0) return emit('click', e) }, - onPointerEnter: (e: ThreeEvent) => emit('enter', e), - onPointerLeave: (e: ThreeEvent) => emit('leave', e), - onPointerMove: (e: ThreeEvent) => emit('move', e), - onDoubleClick: (e: ThreeEvent) => emit('double-click', e), - onContextMenu: (e: ThreeEvent) => emit('context-menu', e), + onPointerEnter: (e: ThreeEvent) => {if (useViewer.getState().cameraDragging) return; emit('enter', e)}, + onPointerLeave: (e: ThreeEvent) => {if (useViewer.getState().cameraDragging) return; emit('leave', e)}, + onPointerMove: (e: ThreeEvent) => {if (useViewer.getState().cameraDragging) return; emit('move', e)}, + onDoubleClick: (e: ThreeEvent) => {if (useViewer.getState().cameraDragging) return; emit('double-click', e)}, + onContextMenu: (e: ThreeEvent) => {if (useViewer.getState().cameraDragging) return; emit('context-menu', e)}, } } diff --git a/packages/viewer/src/store/use-viewer.ts b/packages/viewer/src/store/use-viewer.ts index b772e157..d87dc117 100644 --- a/packages/viewer/src/store/use-viewer.ts +++ b/packages/viewer/src/store/use-viewer.ts @@ -52,6 +52,9 @@ type ViewerState = { // Export functionality exportScene: (() => Promise) | null setExportScene: (fn: (() => Promise) | null) => void + + cameraDragging: boolean + setCameraDragging: (dragging: boolean) => void } const useViewer = create()((set, get) => ({ @@ -107,6 +110,9 @@ const useViewer = create()((set, get) => ({ exportScene: null, setExportScene: (fn) => set({ exportScene: fn }), + + cameraDragging: false, + setCameraDragging: (dragging) => set({ cameraDragging: dragging }), })); export default useViewer;