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 {
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<CameraControlsImpl>(null!);
const currentLevelId = useViewer((state) => state.selection.levelId);
const firstLoad = useRef(true);
const controls = useRef<CameraControlsImpl>(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 <CameraControls maxDistance={100}
return (
<CameraControls
makeDefault
maxDistance={100}
maxPolarAngle={Math.PI / 2 - 0.1}
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 { 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 }) {
<ControlModes />
<div className="mx-1 h-5 w-px bg-zinc-700" />
<ViewToggles />
<div className="mx-1 h-5 w-px bg-zinc-700" />
<CameraActions />
</div>
</div>
</TooltipProvider>
@@ -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"
+3 -3
View File
@@ -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",
+3
View File
@@ -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 &