* Fixed errors due to the camera being too far from the origin When the camera was too far from the origin there were a number of overflow errors. This clamps the camera to +/- 1B Fixes #557 * Reformatted
341 lines
12 KiB
Python
341 lines
12 KiB
Python
import numpy
|
|
from typing import Optional, Tuple, List
|
|
from enum import Enum
|
|
import wx
|
|
from wx import glcanvas
|
|
import math
|
|
from ..canvas_container import CanvasContainer
|
|
from ..data_types import CameraLocationType, CameraRotationType
|
|
|
|
from ..matrix import (
|
|
rotation_matrix_yx,
|
|
perspective_matrix,
|
|
displacement_matrix,
|
|
orthographic_matrix,
|
|
TransformationMatrixType,
|
|
)
|
|
|
|
|
|
class Projection(Enum):
|
|
TOP_DOWN = 0
|
|
PERSPECTIVE = 1
|
|
|
|
|
|
_CameraMoveChangeEventType = wx.NewEventType()
|
|
EVT_CAMERA_MOVED = wx.PyEventBinder(_CameraMoveChangeEventType)
|
|
|
|
|
|
class CameraMovedEvent(wx.PyEvent):
|
|
"""Run when the camera has moved or rotated."""
|
|
|
|
def __init__(
|
|
self, camera_location: CameraLocationType, camera_rotation: CameraRotationType
|
|
):
|
|
wx.PyEvent.__init__(self, eventType=_CameraMoveChangeEventType)
|
|
self._location = camera_location
|
|
self._rotation = camera_rotation
|
|
|
|
@property
|
|
def camera_location(self) -> CameraLocationType:
|
|
"""The location of the camera. (x, y, z)"""
|
|
return self._location
|
|
|
|
@property
|
|
def camera_rotation(self) -> CameraRotationType:
|
|
"""The rotation of the camera. (yaw, pitch).
|
|
This should behave the same as how Minecraft handles it.
|
|
"""
|
|
return self._rotation
|
|
|
|
|
|
_ProjectionChangeEventType = wx.NewEventType()
|
|
EVT_PROJECTION_CHANGED = wx.PyEventBinder(_ProjectionChangeEventType)
|
|
|
|
|
|
class ProjectionChangedEvent(wx.PyEvent):
|
|
"""Run when the projection of the camera has changed."""
|
|
|
|
def __init__(self, projection: Projection):
|
|
wx.PyEvent.__init__(self, eventType=_ProjectionChangeEventType)
|
|
self._projection = projection
|
|
|
|
@property
|
|
def projection(self) -> Projection:
|
|
"""The location of the camera. (x, y, z)"""
|
|
return self._projection
|
|
|
|
|
|
class Camera(CanvasContainer):
|
|
"""A class to hold the state information of the camera."""
|
|
|
|
_bounds: Tuple[Tuple[float, float, float], Tuple[float, float, float]]
|
|
_location: CameraLocationType
|
|
_rotation: CameraRotationType
|
|
_projection_mode: Projection
|
|
_fov: List[float]
|
|
_clipping: List[Tuple[float, float]]
|
|
_aspect_ratio: float
|
|
_projection_matrix: Optional[TransformationMatrixType]
|
|
_transformation_matrix: Optional[TransformationMatrixType]
|
|
|
|
__slots__ = (
|
|
"_bounds",
|
|
"_location",
|
|
"_rotation",
|
|
"_projection_mode",
|
|
"_fov",
|
|
"_clipping",
|
|
"_aspect_ratio",
|
|
"_projection_matrix",
|
|
"_transformation_matrix",
|
|
)
|
|
|
|
def __init__(self, canvas: glcanvas.GLCanvas):
|
|
super().__init__(canvas)
|
|
self._bounds = (
|
|
(-1_000_000_000, -1_000_000_000, -1_000_000_000),
|
|
(1_000_000_000, 1_000_000_000, 1_000_000_000),
|
|
)
|
|
self._location = (0.0, 0.0, 0.0)
|
|
self._rotation = (0.0, 0.0)
|
|
self._projection_mode = Projection.PERSPECTIVE
|
|
self._fov = [100.0, 70.0]
|
|
self._clipping = [
|
|
(-(10**5), 10**5),
|
|
(0.1, 10000.0),
|
|
]
|
|
self._aspect_ratio = 4 / 3
|
|
self._projection_matrix = None
|
|
self._transformation_matrix = None
|
|
|
|
def _reset_matrix(self):
|
|
self._projection_matrix = None
|
|
self._transformation_matrix = None
|
|
|
|
def _notify_moved(self):
|
|
"""Post a CameraMovedEvent"""
|
|
wx.PostEvent(self.canvas, CameraMovedEvent(self.location, self.rotation))
|
|
|
|
@property
|
|
def projection_mode(self) -> Projection:
|
|
return self._projection_mode
|
|
|
|
@projection_mode.setter
|
|
def projection_mode(self, projection_mode: Projection):
|
|
assert (
|
|
projection_mode in Projection
|
|
), f"{projection_mode} is not a valid projection."
|
|
if self._projection_mode != projection_mode:
|
|
self._projection_mode = projection_mode
|
|
self._reset_matrix()
|
|
wx.PostEvent(self.canvas, ProjectionChangedEvent(self.projection_mode))
|
|
|
|
@property
|
|
def location(self) -> CameraLocationType:
|
|
"""The location of the camera. (x, y, z)"""
|
|
return self._location
|
|
|
|
@location.setter
|
|
def location(self, camera_location: CameraLocationType):
|
|
"""Set the location of the camera. (x, y, z).
|
|
Generates EVT_CAMERA_MOVED on the parent canvas."""
|
|
if self.set_location(camera_location):
|
|
self._notify_moved()
|
|
|
|
def set_location(self, camera_location: CameraLocationType) -> bool:
|
|
"""Set the location of the camera. (x, y, z)."""
|
|
assert (
|
|
len(camera_location) == 3
|
|
), "camera_location must be an iterable of three floats."
|
|
camera_location = tuple(
|
|
min(max(float(c), c_min), c_max)
|
|
for c, c_min, c_max in zip(camera_location, *self._bounds)
|
|
)
|
|
if camera_location != self._location:
|
|
self._reset_matrix()
|
|
self._location = camera_location
|
|
return True
|
|
return False
|
|
|
|
@property
|
|
def rotation(self) -> CameraRotationType:
|
|
"""The rotation of the camera. (yaw, pitch).
|
|
This should behave the same as how Minecraft handles it.
|
|
"""
|
|
return self._rotation
|
|
|
|
@rotation.setter
|
|
def rotation(self, camera_rotation: CameraRotationType):
|
|
"""Set the rotation of the camera. (yaw, pitch).
|
|
yaw (-180 to 180), pitch (-90 to 90)
|
|
This should behave the same as how Minecraft handles it.
|
|
Generates EVT_CAMERA_MOVED on the parent canvas."""
|
|
if self.set_rotation(camera_rotation):
|
|
self._notify_moved()
|
|
|
|
def set_rotation(self, camera_rotation: CameraRotationType) -> bool:
|
|
"""Set the rotation of the camera. (yaw, pitch).
|
|
yaw (-180 to 180), pitch (-90 to 90)
|
|
This should behave the same as how Minecraft handles it."""
|
|
assert (
|
|
len(camera_rotation) == 2
|
|
), "camera_rotation must be an iterable of two floats."
|
|
ry, rx = map(float, camera_rotation)
|
|
if not -180 <= ry < 180:
|
|
ry %= 360
|
|
if ry >= 180:
|
|
ry -= 360
|
|
if not -90 <= rx <= 90:
|
|
rx = max(min(rx, 90), -90)
|
|
camera_rotation = (ry, rx)
|
|
if camera_rotation != self._rotation:
|
|
self._reset_matrix()
|
|
self._rotation = camera_rotation
|
|
return True
|
|
return False
|
|
|
|
@property
|
|
def location_rotation(self) -> Tuple[CameraLocationType, CameraRotationType]:
|
|
"""Get the camera location and rotation in one property."""
|
|
return self.location, self.rotation
|
|
|
|
@location_rotation.setter
|
|
def location_rotation(
|
|
self, location_rotation: Tuple[CameraLocationType, CameraRotationType]
|
|
):
|
|
"""Set the camera location and rotation in one property.
|
|
Generates EVT_CAMERA_MOVED on the parent canvas."""
|
|
location, rotation = location_rotation
|
|
moved = self.set_location(location)
|
|
rotated = self.set_rotation(rotation)
|
|
if moved or rotated:
|
|
self._notify_moved()
|
|
|
|
def _set_fov(self, mode: Projection, fov: float):
|
|
self._fov[mode.value] = float(fov)
|
|
self._reset_matrix()
|
|
|
|
@property
|
|
def fov(self) -> float:
|
|
"""The field of view of the camera.
|
|
The value will vary based on the projection mode."""
|
|
return self._fov[self.projection_mode.value]
|
|
|
|
@fov.setter
|
|
def fov(self, fov: float):
|
|
"""Set the field of view of the camera.
|
|
The value will vary based on the projection mode."""
|
|
self._set_fov(self.projection_mode, fov)
|
|
|
|
@property
|
|
def perspective_fov(self) -> float:
|
|
"""The field of view of the camera in degrees when in perspective mode."""
|
|
return self._fov[Projection.PERSPECTIVE.value]
|
|
|
|
@perspective_fov.setter
|
|
def perspective_fov(self, fov: float):
|
|
"""Set the field of view of the camera in degrees when in perspective mode."""
|
|
self._set_fov(Projection.PERSPECTIVE, fov)
|
|
|
|
@property
|
|
def orthographic_fov(self) -> float:
|
|
"""The field of view of the camera when in orthographic mode."""
|
|
return self._fov[Projection.TOP_DOWN.value]
|
|
|
|
@orthographic_fov.setter
|
|
def orthographic_fov(self, fov: float):
|
|
"""Set the field of view of the camera when in orthographic mode."""
|
|
self._set_fov(Projection.TOP_DOWN, fov)
|
|
|
|
def _set_clipping(self, mode: Projection, clipping: Tuple[float, float]):
|
|
assert len(clipping) == 2, "camera_rotation must be an iterable of two floats."
|
|
self._clipping[mode.value] = tuple(map(float, clipping))
|
|
self._reset_matrix()
|
|
|
|
@property
|
|
def perspective_clipping(self) -> Tuple[float, float]:
|
|
"""The near and far clipping distance when in perspective mode."""
|
|
return self._clipping[Projection.PERSPECTIVE.value]
|
|
|
|
@perspective_clipping.setter
|
|
def perspective_clipping(self, clipping: Tuple[float, float]):
|
|
"""Set the near and far clipping distance when in perspective mode."""
|
|
self._set_clipping(Projection.PERSPECTIVE, clipping)
|
|
|
|
@property
|
|
def orthographic_clipping(self) -> Tuple[float, float]:
|
|
"""The near and far clipping distance when in orthographic mode."""
|
|
return self._clipping[Projection.TOP_DOWN.value]
|
|
|
|
@orthographic_clipping.setter
|
|
def orthographic_clipping(self, clipping: Tuple[float, float]):
|
|
"""Set the near and far clipping distance when in orthographic mode."""
|
|
self._set_clipping(Projection.TOP_DOWN, clipping)
|
|
|
|
@property
|
|
def aspect_ratio(self) -> float:
|
|
"""The aspect ratio of the camera (width/weight)"""
|
|
return self._aspect_ratio
|
|
|
|
@aspect_ratio.setter
|
|
def aspect_ratio(self, aspect_ratio: float):
|
|
"""Set the aspect ratio of the camera (width/weight)"""
|
|
self._aspect_ratio = float(aspect_ratio)
|
|
self._reset_matrix()
|
|
|
|
@staticmethod
|
|
def rotation_matrix(yaw, pitch) -> TransformationMatrixType:
|
|
"""Helper function to get a rotation matrix for yaw and pitch.
|
|
|
|
:param yaw: Yaw in degrees.
|
|
:param pitch: Pitch in degrees.
|
|
:return: A 4x4 rotation matrix.
|
|
"""
|
|
return rotation_matrix_yx(math.radians(yaw + 180), math.radians(pitch))
|
|
|
|
@property
|
|
def camera_matrix(self) -> TransformationMatrixType:
|
|
"""The matrix to convert world space to camera space."""
|
|
return numpy.matmul(
|
|
self.rotation_matrix(*self.rotation),
|
|
displacement_matrix(*-numpy.array(self.location)),
|
|
)
|
|
|
|
@property
|
|
def projection_matrix(self) -> TransformationMatrixType:
|
|
"""The matrix to convert camera space to screen space."""
|
|
if self._projection_matrix is None:
|
|
if self.projection_mode == Projection.TOP_DOWN:
|
|
self._projection_matrix = self.orthographic_matrix
|
|
else:
|
|
self._projection_matrix = self.perspective_matrix
|
|
self._projection_matrix.flags.writeable = False
|
|
|
|
return self._projection_matrix
|
|
|
|
@property
|
|
def orthographic_matrix(self) -> TransformationMatrixType:
|
|
"""The orthographic matrix to convert camera space to screen space."""
|
|
near, far = self._clipping[self.projection_mode.value]
|
|
return orthographic_matrix(self.fov, self.aspect_ratio, near, far)
|
|
|
|
@property
|
|
def perspective_matrix(self) -> TransformationMatrixType:
|
|
"""The perspective matrix to convert camera space to screen space."""
|
|
z_near, z_far = self._clipping[self.projection_mode.value]
|
|
return perspective_matrix(
|
|
math.radians(self.fov), self.aspect_ratio, z_near, z_far
|
|
)
|
|
|
|
@property
|
|
def transformation_matrix(self) -> TransformationMatrixType:
|
|
"""The world to projection matrix."""
|
|
# camera translation
|
|
if self._transformation_matrix is None:
|
|
self._transformation_matrix = numpy.matmul(
|
|
self.projection_matrix, self.camera_matrix
|
|
)
|
|
self._transformation_matrix.flags.writeable = False
|
|
|
|
return self._transformation_matrix
|