camera actions

This commit is contained in:
wass08
2026-02-05 13:52:16 +09:00
parent 3579dccf9a
commit 875cf277e0
6 changed files with 183 additions and 63 deletions
@@ -1,54 +1,41 @@
"use client"; 'use client'
import { import { type CameraControlEvent, emitter, sceneRegistry, useScene } from '@pascal-app/core'
type CameraControlEvent, import { useViewer } from '@pascal-app/viewer'
emitter, import { CameraControls, CameraControlsImpl } from '@react-three/drei'
sceneRegistry, import { useEffect, useMemo, useRef } from 'react'
useScene, import { Vector3 } from 'three'
} 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 = () => { export const CustomCameraControls = () => {
const controls = useRef<CameraControlsImpl>(null!); const controls = useRef<CameraControlsImpl>(null!)
const currentLevelId = useViewer((state) => state.selection.levelId); const currentLevelId = useViewer((state) => state.selection.levelId)
const firstLoad = useRef(true); const firstLoad = useRef(true)
useEffect(() => { useEffect(() => {
let targetY = 0; let targetY = 0
if (currentLevelId) { if (currentLevelId) {
const levelMesh = sceneRegistry.nodes.get(currentLevelId); const levelMesh = sceneRegistry.nodes.get(currentLevelId)
if (levelMesh) { if (levelMesh) {
targetY = levelMesh.position.y; targetY = levelMesh.position.y
} }
} }
if (firstLoad.current) { if (firstLoad.current) {
firstLoad.current = false; firstLoad.current = false
(controls.current as CameraControlsImpl).setLookAt( ;(controls.current as CameraControlsImpl).setLookAt(20, 20, 20, 0, 0, 0, true)
20,
20,
20,
0,
0,
0,
true,
);
} }
(controls.current as CameraControlsImpl).getTarget(currentTarget); ;(controls.current as CameraControlsImpl).getTarget(currentTarget)
(controls.current as CameraControlsImpl).moveTo( ;(controls.current as CameraControlsImpl).moveTo(
currentTarget.x, currentTarget.x,
targetY, targetY,
currentTarget.z, currentTarget.z,
true, true,
); )
}, [currentLevelId]); }, [currentLevelId])
// Configure mouse buttons based on control mode and camera mode // 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(() => { const mouseButtons = useMemo(() => {
// Use ZOOM for orthographic camera, DOLLY for perspective camera // Use ZOOM for orthographic camera, DOLLY for perspective camera
const wheelAction = const wheelAction =
@@ -62,19 +49,18 @@ export const CustomCameraControls = () => {
right: CameraControlsImpl.ACTION.ROTATE, right: CameraControlsImpl.ACTION.ROTATE,
wheel: wheelAction, wheel: wheelAction,
} }
}, [cameraMode]); }, [cameraMode])
useEffect(() => { useEffect(() => {
const handleNodeCapture = ({ nodeId }: CameraControlEvent) => { const handleNodeCapture = ({ nodeId }: CameraControlEvent) => {
if (!controls.current) return; if (!controls.current) return
const position = new Vector3(); const position = new Vector3()
const target = new Vector3(); const target = new Vector3()
controls.current.getPosition(position); controls.current.getPosition(position)
controls.current.getTarget(target); controls.current.getTarget(target)
const state = useScene.getState(); const state = useScene.getState()
state.updateNode(nodeId, { state.updateNode(nodeId, {
camera: { camera: {
@@ -82,14 +68,14 @@ export const CustomCameraControls = () => {
target: [target.x, target.y, target.z], target: [target.x, target.y, target.z],
mode: useViewer.getState().cameraMode, mode: useViewer.getState().cameraMode,
}, },
}); })
}; }
const handleNodeView = ({ nodeId }: CameraControlEvent) => { const handleNodeView = ({ nodeId }: CameraControlEvent) => {
if (!controls.current) return; if (!controls.current) return
const node = useScene.getState().nodes[nodeId]; const node = useScene.getState().nodes[nodeId]
if (!node || !node.camera) return; if (!node || !node.camera) return
const { position, target } = node.camera; const { position, target } = node.camera
controls.current.setLookAt( controls.current.setLookAt(
position[0], position[0],
@@ -99,20 +85,69 @@ export const CustomCameraControls = () => {
target[1], target[1],
target[2], target[2],
true, true,
); )
}; }
emitter.on("camera-controls:capture", handleNodeCapture); const handleTopView = () => {
emitter.on("camera-controls:view", handleNodeView); 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 () => { return () => {
emitter.off("camera-controls:capture", handleNodeCapture); emitter.off('camera-controls:capture', handleNodeCapture)
emitter.off("camera-controls:view", handleNodeView); 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 <CameraControls maxDistance={100} return (
<CameraControls
makeDefault
maxDistance={100}
maxPolarAngle={Math.PI / 2 - 0.1} maxPolarAngle={Math.PI / 2 - 0.1}
minDistance={10} minDistance={10}
minPolarAngle={0} ref={controls} mouseButtons={mouseButtons} />; minPolarAngle={0}
}; ref={controls}
mouseButtons={mouseButtons}
/>
)
}
@@ -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 (
<div className="flex items-center gap-1">
{/* Orbit CCW */}
<Tooltip>
<TooltipTrigger asChild>
<Button
className="h-8 w-8 text-zinc-400 transition-all hover:text-sky-400"
onClick={orbitCCW}
size="icon"
variant="ghost"
>
<RotateCcw className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Orbit Left</p>
</TooltipContent>
</Tooltip>
{/* Orbit CW */}
<Tooltip>
<TooltipTrigger asChild>
<Button
className="h-8 w-8 text-zinc-400 transition-all hover:text-sky-400"
onClick={orbitCW}
size="icon"
variant="ghost"
>
<RotateCw className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Orbit Right</p>
</TooltipContent>
</Tooltip>
{/* Top View */}
<Tooltip>
<TooltipTrigger asChild>
<Button
className="h-8 w-8 text-zinc-400 transition-all hover:text-sky-400"
onClick={goToTopView}
size="icon"
variant="ghost"
>
<Rotate3D className="h-4 w-4 -rotate-90" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Top View</p>
</TooltipContent>
</Tooltip>
</div>
)
}
@@ -3,6 +3,7 @@
import { TooltipProvider } from "@/components/ui/primitives/tooltip"; import { TooltipProvider } from "@/components/ui/primitives/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { CameraActions } from "./camera-actions";
import { ControlModes } from "./control-modes"; import { ControlModes } from "./control-modes";
import { PhaseSwitcher } from "./phase-switcher"; import { PhaseSwitcher } from "./phase-switcher";
import { StructureTools } from "./structure-tools"; import { StructureTools } from "./structure-tools";
@@ -140,6 +141,8 @@ export function ActionMenu({ className }: { className?: string }) {
<ControlModes /> <ControlModes />
<div className="mx-1 h-5 w-px bg-zinc-700" /> <div className="mx-1 h-5 w-px bg-zinc-700" />
<ViewToggles /> <ViewToggles />
<div className="mx-1 h-5 w-px bg-zinc-700" />
<CameraActions />
</div> </div>
</div> </div>
</TooltipProvider> </TooltipProvider>
@@ -88,7 +88,7 @@ export function ViewToggles() {
'h-8 w-8 text-zinc-400 transition-all', 'h-8 w-8 text-zinc-400 transition-all',
cameraMode === 'orthographic' cameraMode === 'orthographic'
? 'bg-violet-500/20 text-violet-400' ? 'bg-violet-500/20 text-violet-400'
: 'hover:bg-zinc-800', : 'hover:text-violet-400',
)} )}
onClick={toggleCameraMode} onClick={toggleCameraMode}
size="icon" size="icon"
@@ -110,7 +110,7 @@ export function ViewToggles() {
'h-8 w-8 text-zinc-400 transition-all', 'h-8 w-8 text-zinc-400 transition-all',
levelMode !== 'stacked' levelMode !== 'stacked'
? 'bg-amber-500/20 text-amber-400' ? 'bg-amber-500/20 text-amber-400'
: 'hover:bg-zinc-800', : 'hover:text-amber-400',
)} )}
onClick={cycleLevelMode} onClick={cycleLevelMode}
size="icon" size="icon"
+3 -3
View File
@@ -62,7 +62,7 @@
}, },
"packages/core": { "packages/core": {
"name": "@pascal-app/core", "name": "@pascal-app/core",
"version": "0.1.3", "version": "0.1.10",
"dependencies": { "dependencies": {
"dedent": "^1.7.1", "dedent": "^1.7.1",
"idb-keyval": "^6.2.2", "idb-keyval": "^6.2.2",
@@ -126,7 +126,7 @@
}, },
"packages/viewer": { "packages/viewer": {
"name": "@pascal-app/viewer", "name": "@pascal-app/viewer",
"version": "0.1.3", "version": "0.1.10",
"dependencies": { "dependencies": {
"zustand": "^5", "zustand": "^5",
}, },
@@ -137,7 +137,7 @@
"typescript": "5.9.2", "typescript": "5.9.2",
}, },
"peerDependencies": { "peerDependencies": {
"@pascal-app/core": "^0.1.3", "@pascal-app/core": "^0.1.4",
"@react-three/drei": "^10", "@react-three/drei": "^10",
"@react-three/fiber": "^9", "@react-three/fiber": "^9",
"react": "^18 || ^19", "react": "^18 || ^19",
+3
View File
@@ -56,6 +56,9 @@ export interface CameraControlEvent {
type CameraControlEvents = { type CameraControlEvents = {
'camera-controls:view': CameraControlEvent 'camera-controls:view': CameraControlEvent
'camera-controls:capture': CameraControlEvent 'camera-controls:capture': CameraControlEvent
'camera-controls:top-view': undefined
'camera-controls:orbit-cw': undefined
'camera-controls:orbit-ccw': undefined
} }
type EditorEvents = GridEvents & type EditorEvents = GridEvents &