"""Camera controllers."""
from __future__ import annotations
import enum
from abc import ABC, abstractmethod
from functools import reduce
from typing import Dict, List, Optional
from direct.showbase.DirectObject import DirectObject
from panda3d.core import (
GraphicsBuffer,
LPoint2f,
LVecBase3,
LVecBase3f,
LVector3d,
MouseWatcher,
NodePath,
)
[docs]class KeyCombo(enum.Enum):
"""Represents a keyboard key combination (aka key combo)."""
W = enum.auto()
A = enum.auto()
S = enum.auto()
D = enum.auto()
SPACE = enum.auto()
SHIFT_SPACE = enum.auto()
[docs] @staticmethod
def get_wasd_keys() -> List[KeyCombo]:
"""Return list containing W, A, S, D KeyCombos."""
return [
KeyCombo.W,
KeyCombo.A,
KeyCombo.S,
KeyCombo.D,
]
[docs]class Direction(enum.Enum):
"""Represents a direction vector."""
LEFT = LVector3d(-1, 0, 0)
RIGHT = LVector3d(1, 0, 0)
FORWARD = LVector3d(0, 1, 0)
BACKWARD = LVector3d(0, -1, 0)
UP = LVector3d(0, 0, 1)
DOWN = LVector3d(0, 0, -1)
[docs]class CameraController(DirectObject, ABC):
"""Generic camera controller."""
def __init__(
self,
camera: NodePath,
camera_anchor: NodePath,
focal_point,
render: NodePath,
mouse_watcher_node: MouseWatcher,
win: GraphicsBuffer,
speed: float,
):
"""
Parameters
----------
camera : NodePath
Camera NodePath.
camera_anchor : NodePath
Camera anchor NodePath.
focal_point
Camera's focal point.
render : NodePath
Render NodePath.
mouse_watcher_node : MouseWatcher
Mouse watcher node.
win : GraphicsBuffer
Panda3D window.
speed : float
Camera movement/rotation speed.
"""
super().__init__()
self._camera: NodePath = camera
self._camera_anchor: NodePath = camera_anchor
self._focal_point = focal_point
self._render: NodePath = render
self._mouse_watcher_node: NodePath = mouse_watcher_node
self._win = win
self._speed = speed
self._continue_tasks: bool = True
self._key_combo_to_is_pressed: Dict[KeyCombo, bool] = {}
self._mouse_button_to_is_pressed: Dict[MouseButton, bool] = {}
# Public
[docs] def activate(self):
"""Activate this controller.
Starts relevant tasks.
"""
self._setup_tasks()
[docs] def deactivate(self):
"""Deactivate this controller.
Stops any ongoing camera movement.
Stops any running tasks.
"""
self.stop_movement()
self.ignoreAll()
self._continue_tasks = False
[docs] def stop_movement(self):
"""Stop any ongoing camera movement."""
for dict_obj in [
self._key_combo_to_is_pressed,
self._mouse_button_to_is_pressed,
]:
for key in dict_obj:
dict_obj[key] = False
[docs] @abstractmethod
def look_at_focal_point(self):
"""Focus the camera at focal point."""
raise NotImplementedError
# Protected
@property
def _camera_anchor_parent(self) -> NodePath:
"""Return camera anchor's parent."""
return self._camera_anchor.getParent()
[docs] @abstractmethod
def _handle_mouse_pos_delta(self, delta_px_x: int, delta_px_y: int):
"""Handle mouse position delta (change)."""
raise NotImplementedError
# Inputs
[docs] def _on_key(self, key_combo: KeyCombo, is_pressed: bool):
"""Handle keypress event."""
self._key_combo_to_is_pressed[key_combo] = is_pressed
if key_combo in [KeyCombo.SPACE, KeyCombo.SHIFT_SPACE]:
if is_pressed:
if key_combo == KeyCombo.SPACE:
self._key_combo_to_is_pressed[KeyCombo.SHIFT_SPACE] = False
else:
self._key_combo_to_is_pressed[KeyCombo.SPACE] = False
else:
self._key_combo_to_is_pressed[KeyCombo.SPACE] = False
self._key_combo_to_is_pressed[KeyCombo.SHIFT_SPACE] = False
# Tasks
[docs] def _setup_tasks(self):
"""Setup this controller's tasks."""
self.addTask(self._task_handle_key, "task_handle_key")
self.addTask(self._task_handle_mouse, "task_handle_mouse")
[docs] def _task_handle_key(self, task):
"""Handle keypress event.
This is a task function.
"""
# Calc translation vector, based on pressed keys
rel_translate_vec: LVector3d = reduce(
lambda x, y: x + y,
[
self._key_combo_to_direction[key]
for key, is_pressed in self._key_combo_to_is_pressed.items()
if is_pressed and key in self._key_combo_to_direction
],
LVector3d(0, 0, 0),
)
hpr_backup = self._camera_anchor.getHpr()
self._camera_anchor.setP(0)
self._camera_anchor.setR(0)
self._camera_anchor.setPos(self._camera_anchor, *(rel_translate_vec * 0.75))
self._camera_anchor.setHpr(hpr_backup)
if self._continue_tasks:
return task.cont
[docs] def _task_handle_mouse(self, task):
"""Handle mouse move event.
This is a task function.
"""
if self._mouse_watcher_node.hasMouse():
mouse_pos_cur = self._mouse_watcher_node.getMouse()
# No previous mouse position recorded
if not self._mouse_pos_prev:
self._mouse_pos_prev = LPoint2f(mouse_pos_cur)
if self._continue_tasks:
return task.cont
# Mouse hasn't moved since last task execution
if mouse_pos_cur == self._mouse_pos_prev:
if self._continue_tasks:
return task.cont
# Mouse moved
mouse_pos_delta = mouse_pos_cur - self._mouse_pos_prev
width, height = self._win.fb_size
delta_px_x, delta_px_y = (
width * mouse_pos_delta.x,
height * mouse_pos_delta.y,
)
self._handle_mouse_pos_delta(delta_px_x=delta_px_x, delta_px_y=delta_px_y)
self._mouse_pos_prev = LPoint2f(mouse_pos_cur)
if self._continue_tasks:
return task.cont
# Other
@property
@abstractmethod
def _key_combo_to_direction(self) -> Dict[KeyCombo, LVector3d]:
"""Return dict that maps KeyCombo to Direction."""
pass
[docs] def _print_camera_status(self):
"""Print camera (and camera anchor's) position and HPR."""
print(
f"=============================================\n"
f"Anchor ={self._camera_anchor}\n"
f"Anchor POS ABS ={self._camera_anchor.getPos()}\n"
f"Anchor HPR ={self._camera_anchor.getHpr()}\n"
f"Camera ={self._camera}\n"
f"Camera POS ={self._camera.getPos()}\n"
f"Camera POS ABS ={self._camera.getPos(self._camera_anchor.getParent())}\n"
f"Camera HPR ={self._camera.getHpr()}\n"
f"=============================================\n"
)
[docs] @staticmethod
def _limit_hpr(val: float) -> float:
"""Sanitize the provided heading/pitch/rotation value."""
return (val + 180) % 360 - 180
[docs]class OrbitCameraController(CameraController):
"""Orbit camera controller (spherical).
Allows for rotation around a distant focal point.
The camera is always focused on the focal point.
"""
_KEY_COMBO_TO_DIRECTION: Dict[KeyCombo, LVector3d] = {
KeyCombo.W: -Direction.FORWARD.value,
KeyCombo.A: -Direction.LEFT.value,
KeyCombo.S: -Direction.BACKWARD.value,
KeyCombo.D: -Direction.RIGHT.value,
KeyCombo.SPACE: Direction.UP.value,
KeyCombo.SHIFT_SPACE: Direction.DOWN.value,
}
def __init__(
self,
camera: NodePath,
camera_anchor: NodePath,
focal_point: LVector3d,
render: NodePath,
mouse_watcher_node: MouseWatcher,
win,
speed: Optional[float] = 1.0,
distance_min: Optional[float] = 1,
distance_max: Optional[float] = 2000,
):
"""
Parameters
----------
camera : NodePath
Camera NodePath.
camera_anchor : NodePath
Camera anchor NodePath.
focal_point
Camera's focal point.
render : NodePath
Render NodePath.
mouse_watcher_node : MouseWatcher
Mouse watcher node.
win : GraphicsBuffer
Panda3D window.
speed : float
Camera movement/rotation speed.
"""
super().__init__(
camera=camera,
camera_anchor=camera_anchor,
focal_point=focal_point,
render=render,
mouse_watcher_node=mouse_watcher_node,
win=win,
speed=speed,
)
# Attributes
self._dist_min = distance_min
self._dist_max = distance_max
# State
self._mouse_pos_prev: Optional[LPoint2f] = None
self._is_mouse_1_pressed: bool = False
# Setup
self._setup_camera(focal_point=focal_point)
[docs] def activate(self):
self._setup_tasks()
self._setup_input()
# Setup
[docs] def _setup_camera(self, focal_point: LVector3d):
"""Setup camera for this camera controller."""
node_render: NodePath = self._camera_anchor.getParent()
focal_point = LVector3d(*focal_point)
last_camera_pos_abs = LVector3d(*self._camera.getPos(node_render))
# Place anchor at focal point
self._camera_anchor.setPos(*focal_point)
# Place camera in front of anchor
dist_anchor_to_camera: float = (
last_camera_pos_abs - LVector3d(*self._camera_anchor.getPos())
).length()
self._camera.setPos(0, dist_anchor_to_camera, 0)
self._camera.setHpr(180, 0, 0)
# Anchor look at camera abs position from before setup
self._camera_anchor.lookAt(node_render, *last_camera_pos_abs)
# Input callbacks
# Other
@property
def _key_combo_to_direction(self) -> Dict[KeyCombo, LVector3d]:
return self._KEY_COMBO_TO_DIRECTION
[docs] def _handle_mouse_pos_delta(self, delta_px_x: int, delta_px_y: int):
if self._mouse_button_to_is_pressed.get(MouseButton.LEFT):
delta_h = -delta_px_x * self._speed / 20
delta_p = -delta_px_y * self._speed / 20
cur_h = self._camera_anchor.getH()
cur_p = self._camera_anchor.getP()
new_h = self._limit_hpr(cur_h + delta_h)
new_p = self._limit_hpr(cur_p + delta_p)
self._camera_anchor.setH(new_h)
self._camera_anchor.setP(new_p)
[docs] def look_at_focal_point(self):
# In this mode the camera is always focused on the model.
self.stop_movement()
self._camera_anchor.setPos(*self._focal_point)
[docs]class FreeCameraController(CameraController):
"""Free camera controller.
Allows rotation around the camera's position.
"""
_KEY_COMBO_TO_DIRECTION: Dict[KeyCombo, LVector3d] = {
KeyCombo.W: Direction.FORWARD.value,
KeyCombo.A: Direction.LEFT.value,
KeyCombo.S: Direction.BACKWARD.value,
KeyCombo.D: Direction.RIGHT.value,
KeyCombo.SPACE: Direction.UP.value,
KeyCombo.SHIFT_SPACE: Direction.DOWN.value,
}
def __init__(
self,
camera: NodePath,
camera_anchor: NodePath,
focal_point: LVector3d,
render: NodePath,
mouse_watcher_node: MouseWatcher,
win,
speed: Optional[float] = 1.0,
):
"""
Parameters
----------
camera : NodePath
Camera NodePath.
camera_anchor : NodePath
Camera anchor NodePath.
focal_point
Camera's focal point.
render : NodePath
Render NodePath.
mouse_watcher_node : MouseWatcher
Mouse watcher node.
win : GraphicsBuffer
Panda3D window.
speed : float
Camera movement/rotation speed.
"""
super().__init__(
camera=camera,
camera_anchor=camera_anchor,
focal_point=focal_point,
render=render,
mouse_watcher_node=mouse_watcher_node,
win=win,
speed=speed,
)
# State
self._mouse_pos_prev: Optional[LPoint2f] = None
# Setup
self._setup_camera()
self._setup_tasks()
self._setup_input()
[docs] def activate(self):
self._setup_tasks()
self._setup_input()
# Setup
[docs] def _setup_camera(self):
"""Setup camera for this camera controller."""
last_camera_pos_abs = LVector3d(
*self._camera.getPos(self._camera_anchor_parent)
)
target_anchor_hpr: LVecBase3 = self._camera.getHpr(self._camera_anchor_parent)
# Place camera at anchor, reset camera's hpr
self._camera.setPos(0, 0, 0)
self._camera.setHpr(0, 0, 0)
# Place anchor at last camera abs pos
self._camera_anchor.setPos(
self._camera_anchor_parent, LVecBase3f(*last_camera_pos_abs)
)
self._camera_anchor.setHpr(self._camera_anchor_parent, target_anchor_hpr)
# Input callbacks
# Other
@property
def _key_combo_to_direction(self) -> Dict[KeyCombo, LVector3d]:
return self._KEY_COMBO_TO_DIRECTION
[docs] def _handle_mouse_pos_delta(self, delta_px_x: int, delta_px_y: int):
if self._mouse_button_to_is_pressed.get(MouseButton.LEFT):
delta_h = -delta_px_x * self._speed / 40
delta_p = delta_px_y * self._speed / 40
cur_h = self._camera_anchor.getH()
cur_p = self._camera_anchor.getP()
new_h = self._limit_hpr(cur_h + delta_h)
new_p = self._limit_hpr(cur_p + delta_p)
self._camera_anchor.setH(new_h)
self._camera_anchor.setP(new_p)
[docs] def look_at_focal_point(self):
self.stop_movement()
self._camera_anchor.lookAt(*self._focal_point)