Source code for pySSV.ssv_camera

#  Copyright (c) 2023-2024 Thomas Mathieson.
#  Distributed under the terms of the MIT license.
import logging
from enum import Enum
import numpy as np
import math
from abc import ABC, abstractmethod
from numpy import typing as npt
from typing import Tuple

from .ssv_logging import log


[docs] class MoveDir(Enum): """ Represents a cardinal direction to move in. """ NONE = 0 FORWARD = 1 BACKWARD = 2 LEFT = 3 RIGHT = 4 UP = 5 DOWN = 6
[docs] class SSVCamera: """ A simple class representing a camera. """ position: npt.NDArray[np.float32] """The camera's position in 3D space.""" direction: npt.NDArray[np.float32] """A normalised vector pointing in the direction the camera is facing.""" fov: float """The field of view of the camera in degrees.""" clip_dist: Tuple[float, float] """The distances of the near and far clipping planes respectively.""" aspect_ratio: float """The aspect ratio of the render buffer.""" def __init__(self): self.position = np.array([0., 0., 0.], dtype=np.float32) self.direction = np.array([0., 0., -1.], dtype=np.float32) self.fov = 60 self.clip_dist = (0.1, 1000.0) self.aspect_ratio = 1 self._mouse_old_pos = np.array([0., 0.], dtype=np.int32) self._rotation = np.array([math.pi, 0.], dtype=np.float32) self._mouse_was_pressed = False self._view_matrix: npt.NDArray[np.float32] = np.identity(4, dtype=np.float32) # type: ignore[annotation-unchecked] self._projection_matrix = np.identity(4, dtype=np.float32) self._up_vec = np.array([0., 1., 0.], dtype=np.float32) @staticmethod def _cross_3d(a: npt.NDArray[np.float32], b: npt.NDArray[np.float32]) -> npt.NDArray[np.float32]: res: npt.NDArray[np.float32] = np.empty(3, dtype=np.float32) res[0] = a[1] * b[2] - a[2] * b[1] res[1] = -(a[0] * b[2] - a[2] * b[0]) res[2] = a[0] * b[1] - a[1] * b[0] return res @staticmethod def _length_3d(a: npt.NDArray[np.float32]) -> float: return np.sqrt(a[0]*a[0] + a[1]*a[1] + a[2]*a[2]) @property def rotation_matrix(self) -> npt.NDArray[np.float32]: """ Gets the current view matrix of the camera, without the translation component. """ right = SSVCamera._cross_3d(self.direction, self._up_vec) right /= SSVCamera._length_3d(right) up = SSVCamera._cross_3d(right, self.direction) up /= SSVCamera._length_3d(up) rot_matrix: npt.NDArray[np.float32] = np.identity(4, dtype=np.float32) rot_matrix[0:3, 0] = right rot_matrix[0:3, 1] = up rot_matrix[0:3, 2] = self.direction return rot_matrix @property def view_matrix(self) -> npt.NDArray[np.float32]: """ Gets the current view matrix of the camera. """ # noinspection PyTypeChecker self._view_matrix = np.array(( (1, 0, 0, 0), (0, 1, 0, 0), (0, 0, 1, 0), (-self.position[0], -self.position[1], -self.position[2], 1), ), dtype=np.float32) @ self.rotation_matrix # noinspection PyTypeChecker return self._view_matrix @property def projection_matrix(self): """ Gets the current projection matrix of the camera. """ s = 1.0 / math.tan(math.radians(self.fov) / 2.0) s1 = s / self.aspect_ratio self._projection_matrix[0, 0] = s self._projection_matrix[1, 1] = s1 self._projection_matrix[2, 2] = self.clip_dist[1] / (self.clip_dist[0] - self.clip_dist[1]) self._projection_matrix[2, 3] = -1 self._projection_matrix[3, 2] = ((self.clip_dist[0] * self.clip_dist[1]) / (self.clip_dist[0] - self.clip_dist[1])) self._projection_matrix[3, 3] = 0 return self._projection_matrix
[docs] class SSVCameraController(SSVCamera, ABC): """ A simple class representing a camera controller. """ move_speed: float """The movement speed of the camera.""" zoom_speed: float """The zooming speed of the camera.""" pan_speed: float """The panning speed of the camera in radians per pixel of mouse movement.""" inhibit: bool = False """Whether the camera controls should be inhibited""" def __init__(self): super().__init__() self.move_speed = 1.0 self.zoom_speed = 0.1 self.pan_speed = 0.005
[docs] @abstractmethod def mouse_change(self, mouse_pos: Tuple[int, int], mouse_down: Tuple[bool, bool, bool]): ...
[docs] @abstractmethod def move(self, direction: MoveDir, distance: float = 1.0): ...
[docs] class SSVLookCameraController(SSVCameraController): """ A camera controller which supports mouse look controls. """
[docs] def mouse_change(self, mouse_pos: Tuple[int, int], mouse_down: Tuple[bool, bool, bool]): """ Updates the camera with a mouse event. :param mouse_pos: the new mouse position. :param mouse_down: whether the mouse button is pressed. """ if self.inhibit: return if mouse_down[0]: if not self._mouse_was_pressed: self._mouse_was_pressed = True self._mouse_old_pos[:] = mouse_pos else: self._rotation += (np.array(mouse_pos, dtype=np.int32) - self._mouse_old_pos) * self.pan_speed self._rotation[1] = min(max(self._rotation[1], -math.pi/2 + 1e-6), math.pi/2 - 1e-6) self.direction[0] = math.cos(self._rotation[0]) * math.cos(self._rotation[1]) self.direction[1] = math.sin(self._rotation[1]) self.direction[2] = math.sin(self._rotation[0]) * math.cos(self._rotation[1]) self._mouse_old_pos[:] = mouse_pos else: self._mouse_was_pressed = False
# noinspection DuplicatedCode
[docs] def move(self, direction: MoveDir, distance: float = 1.0): """ Updates the camera position with a movement event. :param direction: the direction to move in. :param distance: the distance to move. """ if self.inhibit: return if direction == MoveDir.UP: self.position[1] += self.move_speed * distance elif direction == MoveDir.DOWN: self.position[1] -= self.move_speed * distance elif direction == MoveDir.RIGHT: self.position[0] += self.move_speed * distance elif direction == MoveDir.LEFT: self.position[0] -= self.move_speed * distance elif direction == MoveDir.FORWARD: self.position[2] += self.move_speed * distance elif direction == MoveDir.BACKWARD: self.position[2] -= self.move_speed * distance
# log(f"Moved position: {self.position}", severity=logging.INFO)
[docs] class SSVOrbitCameraController(SSVCameraController): """ A camera controller which supports orbiting around a given target. """ _target_pos: npt.NDArray[np.float32] _orbit_dist: float def __init__(self): super().__init__() self._target_pos = np.array([0, 0, 0], dtype=np.float32) self._orbit_dist = 2 @property def target_pos(self): """Gets or sets the point around which to orbit.""" return self._target_pos @target_pos.setter def target_pos(self, value): self._target_pos = value self._update_direction_position() @property def orbit_dist(self): """Gets or sets the distance from the target position to orbit at.""" return self._orbit_dist @orbit_dist.setter def orbit_dist(self, value): self._orbit_dist = value self._update_direction_position() def _update_direction_position(self): self.direction[0] = math.cos(self._rotation[0]) * math.cos(self._rotation[1]) self.direction[1] = math.sin(self._rotation[1]) self.direction[2] = math.sin(self._rotation[0]) * math.cos(self._rotation[1]) self.position[:] = self._target_pos + self.direction * self._orbit_dist
[docs] def mouse_change(self, mouse_pos: Tuple[int, int], mouse_down: Tuple[bool, bool, bool]): """ Updates the camera with a mouse event. :param mouse_pos: the new mouse position. :param mouse_down: whether the mouse button is pressed. """ if self.inhibit: return if mouse_down[0] or mouse_down[1] or mouse_down[2]: if not self._mouse_was_pressed: self._mouse_was_pressed = True self._mouse_old_pos[:] = mouse_pos else: self._mouse_was_pressed = False if mouse_down[0]: # Orbit if self._mouse_was_pressed: self._rotation -= (np.array(mouse_pos, dtype=np.int32) - self._mouse_old_pos) * self.pan_speed self._rotation[1] = min(max(self._rotation[1], -math.pi/2 + 1e-6), math.pi/2 - 1e-6) self._update_direction_position() # log(f"view_mat=\n{np.transpose(self.view_matrix)}", severity=logging.INFO) elif mouse_down[2]: # Pan if self._mouse_was_pressed: mouse_delta = np.append((np.array(mouse_pos, dtype=np.float32) - self._mouse_old_pos) * self.pan_speed, (0, 0)) self._target_pos += (self.rotation_matrix @ mouse_delta)[:3] self._update_direction_position() # log(f"view_mat=\n{np.transpose(self.view_matrix)}", severity=logging.INFO) elif mouse_down[1]: # Zoom pass self._mouse_old_pos[:] = mouse_pos
[docs] def zoom(self, distance: float): """ Updates the orbit distance with a zoom event. :param distance: how far to zoom in. """ if self.inhibit: return self.orbit_dist += self.orbit_dist * distance * self.zoom_speed self._update_direction_position()
# noinspection DuplicatedCode
[docs] def move(self, direction: MoveDir, distance: float = 1.0): """ Updates the camera position with a movement event. :param direction: the direction to move in. :param distance: the distance to move. """ if self.inhibit: return dir_vec: npt.NDArray[np.float32] = np.zeros(4, dtype=np.float32) if direction == MoveDir.UP: dir_vec[1] = self.move_speed * distance elif direction == MoveDir.DOWN: dir_vec[1] = -self.move_speed * distance elif direction == MoveDir.RIGHT: dir_vec[0] = self.move_speed * distance elif direction == MoveDir.LEFT: dir_vec[0] = -self.move_speed * distance elif direction == MoveDir.FORWARD: dir_vec[2] = -self.move_speed * distance elif direction == MoveDir.BACKWARD: dir_vec[2] = self.move_speed * distance self._target_pos += (self.rotation_matrix @ dir_vec)[:3] self._update_direction_position()
# log(f"Moved position: {self.position}", severity=logging.INFO)