From 875cf277e09ab7bd34420f670d27ca783f63498b Mon Sep 17 00:00:00 2001 From: wass08 Date: Thu, 5 Feb 2026 13:52:16 +0900 Subject: [PATCH] camera actions --- .../editor/custom-camera-controls.tsx | 151 +++++++++++------- .../ui/action-menu/camera-actions.tsx | 79 +++++++++ .../components/ui/action-menu/index.tsx | 3 + .../ui/action-menu/view-toggles.tsx | 4 +- bun.lock | 6 +- packages/core/src/events/bus.ts | 3 + 6 files changed, 183 insertions(+), 63 deletions(-) create mode 100644 apps/editor/components/ui/action-menu/camera-actions.tsx diff --git a/apps/editor/components/editor/custom-camera-controls.tsx b/apps/editor/components/editor/custom-camera-controls.tsx index 84854413..b6039a52 100644 --- a/apps/editor/components/editor/custom-camera-controls.tsx +++ b/apps/editor/components/editor/custom-camera-controls.tsx @@ -1,54 +1,41 @@ -"use client"; +'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 { useEffect, useMemo, useRef } from "react"; -import { Vector3 } from "three"; +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 { Vector3 } from 'three' -const currentTarget = new Vector3(); +const currentTarget = new Vector3() export const CustomCameraControls = () => { - const controls = useRef(null!); - const currentLevelId = useViewer((state) => state.selection.levelId); - const firstLoad = useRef(true); + const controls = useRef(null!) + const currentLevelId = useViewer((state) => state.selection.levelId) + const firstLoad = useRef(true) useEffect(() => { - let targetY = 0; + let targetY = 0 if (currentLevelId) { - const levelMesh = sceneRegistry.nodes.get(currentLevelId); + const levelMesh = sceneRegistry.nodes.get(currentLevelId) if (levelMesh) { - targetY = levelMesh.position.y; + targetY = levelMesh.position.y } } if (firstLoad.current) { - firstLoad.current = false; - (controls.current as CameraControlsImpl).setLookAt( - 20, - 20, - 20, - 0, - 0, - 0, - true, - ); + 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( + ;(controls.current as CameraControlsImpl).getTarget(currentTarget) + ;(controls.current as CameraControlsImpl).moveTo( currentTarget.x, targetY, currentTarget.z, true, - ); - }, [currentLevelId]); + ) + }, [currentLevelId]) // Configure mouse buttons based on control mode and camera mode - const cameraMode = useViewer((state) => state.cameraMode); + const cameraMode = useViewer((state) => state.cameraMode) const mouseButtons = useMemo(() => { // Use ZOOM for orthographic camera, DOLLY for perspective camera const wheelAction = @@ -62,19 +49,18 @@ export const CustomCameraControls = () => { right: CameraControlsImpl.ACTION.ROTATE, wheel: wheelAction, } - }, [cameraMode]); - + }, [cameraMode]) useEffect(() => { const handleNodeCapture = ({ nodeId }: CameraControlEvent) => { - if (!controls.current) return; + if (!controls.current) return - const position = new Vector3(); - const target = new Vector3(); - controls.current.getPosition(position); - controls.current.getTarget(target); + const position = new Vector3() + const target = new Vector3() + controls.current.getPosition(position) + controls.current.getTarget(target) - const state = useScene.getState(); + const state = useScene.getState() state.updateNode(nodeId, { camera: { @@ -82,14 +68,14 @@ export const CustomCameraControls = () => { target: [target.x, target.y, target.z], mode: useViewer.getState().cameraMode, }, - }); - }; + }) + } const handleNodeView = ({ nodeId }: CameraControlEvent) => { - if (!controls.current) return; + if (!controls.current) return - const node = useScene.getState().nodes[nodeId]; - if (!node || !node.camera) return; - const { position, target } = node.camera; + const node = useScene.getState().nodes[nodeId] + if (!node || !node.camera) return + const { position, target } = node.camera controls.current.setLookAt( position[0], @@ -99,20 +85,69 @@ export const CustomCameraControls = () => { target[1], target[2], true, - ); - }; + ) + } - emitter.on("camera-controls:capture", handleNodeCapture); - emitter.on("camera-controls:view", handleNodeView); + 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: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) + } + }, []) - return ; -}; + minPolarAngle={0} + ref={controls} + mouseButtons={mouseButtons} + /> + ) +} diff --git a/apps/editor/components/ui/action-menu/camera-actions.tsx b/apps/editor/components/ui/action-menu/camera-actions.tsx new file mode 100644 index 00000000..929b557f --- /dev/null +++ b/apps/editor/components/ui/action-menu/camera-actions.tsx @@ -0,0 +1,79 @@ +'use client' + +import { emitter } from '@pascal-app/core' +import { RotateCcw, RotateCw, Rotate3D } from 'lucide-react' +import { Button } from '@/components/ui/primitives/button' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/primitives/tooltip' + +export function CameraActions() { + const goToTopView = () => { + emitter.emit('camera-controls:top-view') + } + + const orbitCW = () => { + emitter.emit('camera-controls:orbit-cw') + } + + const orbitCCW = () => { + emitter.emit('camera-controls:orbit-ccw') + } + + return ( +
+ {/* Orbit CCW */} + + + + + +

Orbit Left

+
+
+ + {/* Orbit CW */} + + + + + +

Orbit Right

+
+
+ + {/* Top View */} + + + + + +

Top View

+
+
+
+ ) +} diff --git a/apps/editor/components/ui/action-menu/index.tsx b/apps/editor/components/ui/action-menu/index.tsx index 6c5b16c8..cc08a43c 100644 --- a/apps/editor/components/ui/action-menu/index.tsx +++ b/apps/editor/components/ui/action-menu/index.tsx @@ -3,6 +3,7 @@ import { TooltipProvider } from "@/components/ui/primitives/tooltip"; import { cn } from "@/lib/utils"; +import { CameraActions } from "./camera-actions"; import { ControlModes } from "./control-modes"; import { PhaseSwitcher } from "./phase-switcher"; import { StructureTools } from "./structure-tools"; @@ -140,6 +141,8 @@ export function ActionMenu({ className }: { className?: string }) {
+
+
diff --git a/apps/editor/components/ui/action-menu/view-toggles.tsx b/apps/editor/components/ui/action-menu/view-toggles.tsx index 4985e405..5b12263a 100644 --- a/apps/editor/components/ui/action-menu/view-toggles.tsx +++ b/apps/editor/components/ui/action-menu/view-toggles.tsx @@ -88,7 +88,7 @@ export function ViewToggles() { 'h-8 w-8 text-zinc-400 transition-all', cameraMode === 'orthographic' ? 'bg-violet-500/20 text-violet-400' - : 'hover:bg-zinc-800', + : 'hover:text-violet-400', )} onClick={toggleCameraMode} size="icon" @@ -110,7 +110,7 @@ export function ViewToggles() { 'h-8 w-8 text-zinc-400 transition-all', levelMode !== 'stacked' ? 'bg-amber-500/20 text-amber-400' - : 'hover:bg-zinc-800', + : 'hover:text-amber-400', )} onClick={cycleLevelMode} size="icon" diff --git a/bun.lock b/bun.lock index d9c9ff13..eba03d97 100644 --- a/bun.lock +++ b/bun.lock @@ -62,7 +62,7 @@ }, "packages/core": { "name": "@pascal-app/core", - "version": "0.1.3", + "version": "0.1.10", "dependencies": { "dedent": "^1.7.1", "idb-keyval": "^6.2.2", @@ -126,7 +126,7 @@ }, "packages/viewer": { "name": "@pascal-app/viewer", - "version": "0.1.3", + "version": "0.1.10", "dependencies": { "zustand": "^5", }, @@ -137,7 +137,7 @@ "typescript": "5.9.2", }, "peerDependencies": { - "@pascal-app/core": "^0.1.3", + "@pascal-app/core": "^0.1.4", "@react-three/drei": "^10", "@react-three/fiber": "^9", "react": "^18 || ^19", diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 79590943..080da1ce 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -56,6 +56,9 @@ export interface CameraControlEvent { type CameraControlEvents = { 'camera-controls:view': CameraControlEvent 'camera-controls:capture': CameraControlEvent + 'camera-controls:top-view': undefined + 'camera-controls:orbit-cw': undefined + 'camera-controls:orbit-ccw': undefined } type EditorEvents = GridEvents &