camera actions
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 &
|
||||
|
||||
Reference in New Issue
Block a user