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