Source code for arcade.camera.camera_2d

from __future__ import annotations

from collections.abc import Generator
from contextlib import contextmanager
from math import atan2, cos, degrees, radians, sin
from typing import TYPE_CHECKING

from pyglet.math import Vec2, Vec3
from typing_extensions import Self

from arcade.camera.data_types import (
    DEFAULT_FAR,
    DEFAULT_NEAR_ORTHO,
    CameraData,
    OrthographicProjectionData,
    ZeroProjectionDimension,
)
from arcade.camera.projection_functions import (
    generate_orthographic_matrix,
    generate_view_matrix,
    project_orthographic,
    unproject_orthographic,
)
from arcade.types import LBWH, LRBT, XYWH, Point, Rect
from arcade.types.vector_like import Point2
from arcade.window_commands import get_window

if TYPE_CHECKING:
    from arcade.application import Window
    from arcade.gl import Framebuffer

__all__ = ["Camera2D"]


[docs] class Camera2D: """ A simple orthographic camera. It provides properties to access every important variable for controlling the camera. 3D properties such as pos, and up are constrained to a 2D plane. There is no access to the forward vector (as a property). There are also ease of use methods for matching the viewport and projector to the window size. Provides many helpful values: * The position and rotation or the camera * 8 positions along the edge of the camera's viewable area * the bounding box of the area the camera sees * Viewport, and Scissor box for controlling where to draw to .. warning:: Do not replace the ``camera_data`` and ``projection_data`` instances after initialization! Replacing the camera data and projection data may break controllers. Their contents are exposed via properties rather than directly to prevent this. Args: viewport: A ``Rect`` which defines the pixel bounds which the camera fits its image to. If the viewport is not 1:1 with the projection then positions in world space won't match pixels on screen. position: The 2D position of the camera in the XY plane. up: A 2D vector which describes which direction is up (defines the +Y-axis of the camera space). zoom: A scalar value which is inversely proportional to the size of the camera projection. i.e. a zoom of 2.0 halves the size of the projection, doubling the perceived size of objects. projection: A ``Rect`` which defines the world space bounds which the camera projects to the viewport. near: The near clipping plane of the camera. far: The far clipping plane of the camera. scissor: A ``Rect`` which will crop the camera's output to this area on screen. Unlike the viewport this has no influence on the visuals rendered with the camera only the area shown. render_target: The FrameBuffer that the camera may use. Warning if the target isn't the screen it won't automatically show up on screen. window: The Arcade Window to bind the camera to. Defaults to the currently active window. """ def __init__( self, viewport: Rect | None = None, position: Point2 | None = None, up: tuple[float, float] = (0.0, 1.0), zoom: float = 1.0, projection: Rect | None = None, near: float = DEFAULT_NEAR_ORTHO, far: float = DEFAULT_FAR, *, scissor: Rect | None = None, render_target: Framebuffer | None = None, window: Window | None = None, ): self._window: Window = window or get_window() self.render_target: Framebuffer | None = render_target """ An optional framebuffer to activate at the same time as the projection data, could be the screen, or an offscreen texture """ # We don't want to force people to use a render target, # but we need to have some form of default size. render_target = render_target or self._window.ctx.screen viewport = viewport or LBWH(*render_target.viewport) width, height = viewport.size half_width = width / 2 half_height = height / 2 # Unpack projection, but only validate when it's given directly left, right, bottom, top = ( (-half_width, half_width, -half_height, half_height) if projection is None else projection.lrbt ) if projection is not None: if left == right: raise ZeroProjectionDimension( f"projection width is 0 due to equal {left=} and {right=} values" ) if bottom == top: raise ZeroProjectionDimension( f"projection height is 0 due to equal {bottom=} and {top=}" ) if near == far: raise ZeroProjectionDimension( f"projection depth is 0 due to equal {near=} and {far=} values" ) pos_x = position[0] if position is not None else half_width pos_y = position[1] if position is not None else half_height self._camera_data = CameraData( position=(pos_x, pos_y, 0.0), up=(up[0], up[1], 0.0), forward=(0.0, 0.0, -1.0), zoom=zoom, ) self._projection_data: OrthographicProjectionData = OrthographicProjectionData( left=left, right=right, top=top, bottom=bottom, near=near, far=far ) self.viewport: Rect = viewport or LRBT(0, 0, width, height) """ A rect which describes how the final projection should be mapped from unit-space. defaults to the size of the render_target or window """ self.scissor: Rect | None = scissor """ An optional rect which describes what pixels of the active render target should be drawn to when undefined the viewport rect is used. """
[docs] @classmethod def from_camera_data( cls, *, camera_data: CameraData | None = None, projection_data: OrthographicProjectionData | None = None, render_target: Framebuffer | None = None, viewport: Rect | None = None, scissor: Rect | None = None, window: Window | None = None, ) -> Self: """ Make a ``Camera2D`` directly from data objects. This :py:class:`classmethod` allows advanced users to: #. skip or replace the default validation #. share ``camera_data`` or ``projection_data`` between cameras .. warning:: Be careful when sharing data objects! **Any** action on a camera which changes a shared object changes it for **every** camera which uses the same object. .. list-table:: :header-rows: 1 * - Shared Value - Example Use(s) * - ``camera_data`` - Mini-maps, reflection, and ghosting effects. * - ``projection_data`` - Simplified rendering configuration * - ``render_target`` - Complex rendering setups Args: camera_data: A :py:class:`~arcade.camera.CameraData` describing the position, up, forward and zoom. projection_data: A :py:class:`~arcade.camera.OrthographicProjectionData` which describes the left, right, top, bottom, far, near planes and the viewport for an orthographic projection. render_target: A non-screen :py:class:`~arcade.gl.framebuffer.Framebuffer` for this camera to draw into. When specified, * nothing will draw directly to the screen * the buffer's internal viewport will be ignored viewport: A viewport as a :py:class:`~arcade.types.rect.Rect`. This overrides any viewport the ``render_target`` may have. scissor: The OpenGL scissor box to use when drawing. window: The Arcade Window to bind the camera to. Defaults to the currently active window. """ if projection_data: left, right = projection_data.left, projection_data.right if projection_data.left == projection_data.right: raise ZeroProjectionDimension( f"projection width is 0 due to equal {left=}and {right=} values" ) bottom, top = projection_data.bottom, projection_data.top if bottom == top: raise ZeroProjectionDimension( f"projection height is 0 due to equal {bottom=}and {top=}" ) near, far = projection_data.near, projection_data.far if near == far: raise ZeroProjectionDimension( f"projection depth is 0 due to equal {near=}and {far=} values" ) # build a new camera with defaults and then apply the provided camera objects. new_camera = cls( render_target=render_target, window=window, viewport=viewport, scissor=scissor ) if camera_data is not None: new_camera._camera_data = camera_data if projection_data is not None: new_camera._projection_data = projection_data return new_camera
[docs] def use(self) -> None: """ Set internal projector as window projector, and set the projection and view matrix. call every time you want to 'look through' this camera. If you want to use a 'with' block use activate() instead. """ if self.render_target is not None: self.render_target.use() self._window.current_camera = self _projection = generate_orthographic_matrix(self.projection_data, self.zoom) _view = generate_view_matrix(self.view_data) self._window.ctx.viewport = self.viewport.lbwh_int self._window.ctx.scissor = None if not self.scissor else self.scissor.lbwh_int self._window.projection = _projection self._window.view = _view
[docs] @contextmanager def activate(self) -> Generator[Self, None, None]: """ Set internal projector as window projector, and set the projection and view matrix. This method works with 'with' blocks. After using this method it automatically resets the projector to the one previously in use. """ previous_projection = self._window.current_camera previous_framebuffer = self._window.ctx.active_framebuffer try: self.use() yield self finally: previous_framebuffer.use() previous_projection.use()
[docs] def project(self, world_coordinate: Point) -> Vec2: """ Take a Vec2 or Vec3 of coordinates and return the related screen coordinate """ _projection = generate_orthographic_matrix(self.projection_data, self.zoom) _view = generate_view_matrix(self.view_data) return project_orthographic( world_coordinate, self.viewport.lbwh_int, _view, _projection, )
[docs] def unproject(self, screen_coordinate: Point) -> Vec3: """ Take in a pixel coordinate from within the range of the window size and returns the world space coordinates. Essentially reverses the effects of the projector. Args: screen_coordinate: A 2D or 3D position in pixels from the bottom left of the screen. Returns: A 3D vector in world space (same as sprites). perfect for finding if the mouse overlaps with a sprite or ui element irrespective of the camera. """ _projection = generate_orthographic_matrix(self.projection_data, self.zoom) _view = generate_view_matrix(self.view_data) return unproject_orthographic(screen_coordinate, self.viewport.lbwh_int, _view, _projection)
[docs] def equalise(self) -> None: """ Forces the projection to match the size of the viewport. When matching the projection to the viewport the method keeps the projections center in the same relative place. """ x, y = self._projection_data.rect.x, self._projection_data.rect.y self._projection_data.rect = XYWH(x, y, self.viewport_width, self.viewport_height)
[docs] def match_window( self, viewport: bool = True, projection: bool = True, scissor: bool = True, position: bool = False, aspect: float | None = None, ) -> None: """ Sets the viewport to the size of the window. Should be called when the window is resized. Args: viewport: Flag whether to equalise the viewport to the value. projection: Flag whether to also equalize the projection to the viewport. On by default scissor: Flag whether to also equalize the scissor box to the viewport. On by default position: Flag whether to also center the camera to the viewport. Off by default aspect: The ratio between width and height that the viewport should be constrained to. If unset then the viewport just matches the window size. The aspect ratio describes how much larger the width should be compared to the height. i.e. for an aspect ratio of ``4:3`` you should input ``4.0/3.0`` or ``1.33333...``. Cannot be equal to zero. """ self.update_values( self._window.rect, viewport=viewport, projection=projection, scissor=scissor, position=position, aspect=aspect, )
[docs] def match_target( self, viewport: bool = True, projection: bool = True, scissor: bool = True, position: bool = False, aspect: float | None = None, ) -> None: """ Sets the viewport to the size of the Camera2D's render target. Args: viewport: Flag whether to equalize the viewport to the area of the render target projection: Flag whether to equalize the size of the projection to match the render target. The projection center stays fixed, and the new projection matches only in size. scissor: Flag whether to update the scissor value. position: Flag whether to also center the camera to the value. Off by default aspect: The ratio between width and height that the value should be constrained to. i.e. for an aspect ratio of ``4:3`` you should input ``4.0/3.0`` or ``1.33333...``. Cannot be equal to zero. If unset then the value will not be updated. Raises: ValueError: Will be raised if the Camera2D was has no render target. """ if self.render_target is None: raise ValueError( "Tried to match a non-exsistant render target. Please use `match_window` instead" ) self.update_values( LRBT(*self.render_target.viewport), viewport, projection, scissor, position, aspect=aspect, )
[docs] def update_values( self, value: Rect, viewport: bool = True, projection: bool = True, scissor: bool = True, position: bool = False, aspect: float | None = None, ): """ Convenience method for updating the viewport, projection, position and a few others with the same value. Args: value: The rect that the values will be derived from. viewport: Flag whether to equalize the viewport to the value. projection: Flag whether to equalize the size of the projection to match the value. The projection center stays fixed, and the new projection matches only in size. scissor: Flag whether to update the scissor value. position: Flag whether to also center the camera to the value. Off by default aspect: The ratio between width and height that the value should be constrained to. i.e. for an aspect ratio of ``4:3`` you should input ``4.0/3.0`` or ``1.33333...``. Cannot be equal to zero. If unset then the value will not be updated. """ if aspect is not None: if value.height * aspect < value.width: w = value.height * aspect h = value.height else: w = value.width h = value.width / aspect value = XYWH(value.x, value.y, w, h) if viewport: self.viewport = value if projection: x, y = self._projection_data.rect.x, self._projection_data.rect.y self._projection_data.rect = XYWH(x, y, value.width, value.height) if scissor and self.scissor: self.scissor = value if position: self.position = value.center
[docs] def aabb(self) -> Rect: """ Retrieve the axis-aligned bounds box of the camera's view area. If the camera isn't rotated , this will be precisely the view area, but it will cover a larger area when it is rotated. Useful for CPU culling """ up = self._camera_data.up ux, uy, *_ = up rx, ry = uy, -ux # up x Z' l, r, b, t = self.viewport.lrbt x, y = self.position x_points = ( x + ux * t + rx * l, # top left x + ux * t + rx * r, # top right x + ux * b + rx * l, # bottom left x + ux * b + rx * r, # bottom right ) y_points = ( y + uy * t + ry * l, # top left y + uy * t + ry * r, # top right y + uy * b + ry * l, # bottom left y + uy * b + ry * r, # bottom right ) left = min(x_points) right = max(x_points) bottom = min(y_points) top = max(y_points) return LRBT(left=left, right=right, bottom=bottom, top=top)
[docs] def point_in_view(self, point: Point2) -> bool: # TODO test """ Take a 2D point in the world, and return whether the point is inside the visible area of the camera. """ # This is unwrapped from standard Vec2 operations, # The construction and garbage collection of the vectors would # increase this method's cost by ~4x pos = self.position diff = point[0] - pos[0], point[1] - pos[1] up = self._camera_data.up h_width = self.width / 2.0 h_height = self.height / 2.0 dot_x = up[1] * diff[0] - up[0] * diff[1] dot_y = up[0] * diff[0] + up[1] * diff[1] return abs(dot_x) <= h_width and abs(dot_y) <= h_height
@property def view_data(self) -> CameraData: """The view data for the camera. This includes: * the position * forward vector * up direction * zoom. Camera controllers use this property. """ return self._camera_data @property def projection_data(self) -> OrthographicProjectionData: """The projection data for the camera. This is an Orthographic projection. with a right, left, top, bottom, near, and far value. An easy way to understand the use of the projection is that the right value of the projection tells the camera what value will be at the right most pixel in the viewport. Due to the view data having a zoom component most use cases will only change the projection on screen resize. """ return self._projection_data @property def position(self) -> Vec2: """The 2D world position of the camera along the X and Y axes.""" return Vec2(self._camera_data.position[0], self._camera_data.position[1]) # Setter with different signature will cause mypy issues # https://github.com/python/mypy/issues/3004 @position.setter def position(self, _pos: Point) -> None: x, y, *_z = _pos z = self._camera_data.position[2] if not _z else _z[0] self._camera_data.position = (x, y, z) @property def projection(self) -> Rect: """Get/set the left, right, bottom, and top projection values. These are world space values which control how the camera projects the world onto the pixel space of the current viewport area. .. note:: this IS scaled by zoom. If this isn't what you want, you have to calculate the value manually from projection_data .. warning:: The axis values cannot be equal! * ``left`` cannot equal ``right`` * ``bottom`` cannot equal ``top`` This property raises a :py:class:`~arcade.camera.data_types.ZeroProjectionDimension` exception if any axis pairs are equal. You can handle this exception as a :py:class:`ValueError`. """ return self._projection_data.rect / self._camera_data.zoom @projection.setter def projection(self, value: Rect) -> None: # Unpack and validate if not value: raise ZeroProjectionDimension(f"Projection area is 0, {value.lrbt}") _z = self._camera_data.zoom # Modify the projection data itself. self._projection_data.rect = value * _z @property def width(self) -> float: """ The width of the projection from left to right. This is in world space coordinates not pixel coordinates. .. note:: this IS scaled by zoom. If this isn't what you want, you have to calculate the value manually from projection_data """ return (self._projection_data.right - self._projection_data.left) / self._camera_data.zoom @width.setter def width(self, new_width: float) -> None: w = self.width l = self.left / w # Normalised Projection left r = self.right / w # Normalised Projection Right self.left = l * new_width self.right = r * new_width @property def height(self) -> float: """ The height of the projection from bottom to top. This is in world space coordinates not pixel coordinates. .. note:: this IS scaled by zoom. If this isn't what you want, you have to calculate the value manually from projection_data """ return (self._projection_data.top - self._projection_data.bottom) / self._camera_data.zoom @height.setter def height(self, new_height: float) -> None: h = self.height b = self.bottom / h # Normalised Projection Bottom t = self.top / h # Normalised Projection Top self.bottom = b * new_height self.top = t * new_height @property def left(self) -> float: """ The left edge of the projection in world space. This is not adjusted with the camera position. .. note:: this IS scaled by zoom. If this isn't what you want, you have to calculate the value manually from projection_data """ return self._projection_data.left / self._camera_data.zoom @left.setter def left(self, new_left: float) -> None: self._projection_data.left = new_left * self._camera_data.zoom @property def right(self) -> float: """ The right edge of the projection in world space. This is not adjusted with the camera position. .. note:: this IS scaled by zoom. If this isn't what you want, you have to calculate the value manually from projection_data """ return self._projection_data.right / self._camera_data.zoom @right.setter def right(self, new_right: float) -> None: self._projection_data.right = new_right * self._camera_data.zoom @property def bottom(self) -> float: """ The bottom edge of the projection in world space. This is not adjusted with the camera position. .. note:: this IS scaled by zoom. If this isn't what you want, you have to calculate the value manually from projection_data """ return self._projection_data.bottom / self._camera_data.zoom @bottom.setter def bottom(self, new_bottom: float) -> None: self._projection_data.bottom = new_bottom * self._camera_data.zoom @property def top(self) -> float: """ The top edge of the projection in world space. This is not adjusted with the camera position. .. note:: this IS scaled by zoom. If this isn't what you want, you have to calculate the value manually from projection_data """ return self._projection_data.top / self._camera_data.zoom @top.setter def top(self, new_top: float) -> None: self._projection_data.top = new_top * self._camera_data.zoom @property def projection_near(self) -> float: """ The near plane of the projection in world space. This is not adjusted with the camera position. .. note:: this IS NOT scaled by zoom. """ return self._projection_data.near @projection_near.setter def projection_near(self, new_near: float) -> None: self._projection_data.near = new_near @property def projection_far(self) -> float: """ The far plane of the projection in world space. This is not adjusted with the camera position. .. note:: this IS NOT scaled by zoom. """ return self._projection_data.far @projection_far.setter def projection_far(self, new_far: float) -> None: self._projection_data.far = new_far @property def viewport_width(self) -> int: """ The width of the viewport. Defines the number of pixels drawn too horizontally. """ return int(self.viewport.width) @viewport_width.setter def viewport_width(self, new_width: int) -> None: self.viewport = self.viewport.resize(new_width, anchor=Vec2(0.0, 0.0)) @property def viewport_height(self) -> int: """ The height of the viewport. Defines the number of pixels drawn too vertically. """ return int(self.viewport.height) @viewport_height.setter def viewport_height(self, new_height: int) -> None: self.viewport = self.viewport.resize(height=new_height, anchor=Vec2(0.0, 0.0)) @property def viewport_left(self) -> int: """ The left most pixel drawn to on the X axis. """ return int(self.viewport.left) @viewport_left.setter def viewport_left(self, new_left: int) -> None: """ Set the left most pixel drawn to. This moves the position of the viewport, and does not change the size. """ self.viewport = self.viewport.align_left(new_left) @property def viewport_right(self) -> int: """ The right most pixel drawn to on the X axis. """ return int(self.viewport.right) @viewport_right.setter def viewport_right(self, new_right: int) -> None: """ Set the right most pixel drawn to. This moves the position of the viewport, and does not change the size. """ self.viewport = self.viewport.align_right(new_right) @property def viewport_bottom(self) -> int: """ The bottom most pixel drawn to on the Y axis. """ return int(self.viewport.bottom) @viewport_bottom.setter def viewport_bottom(self, new_bottom: int) -> None: """ Set the bottom most pixel drawn to. This moves the position of the viewport, and does not change the size. """ self.viewport = self.viewport.align_bottom(new_bottom) @property def viewport_top(self) -> int: """ The top most pixel drawn to on the Y axis. """ return int(self.viewport.top) @viewport_top.setter def viewport_top(self, new_top: int) -> None: """ Set the top most pixel drawn to. This moves the position of the viewport, and does not change the size. """ self.viewport = self.viewport.align_top(new_top) @property def up(self) -> Vec2: """ A 2D vector which describes what is mapped to the +Y direction on screen. This is equivalent to rotating the screen. The base vector is 3D, but this camera only provides a 2D view. """ return Vec2(self._camera_data.up[0], self._camera_data.up[1]) @up.setter def up(self, _up: Point2) -> None: """ Set the 2D vector which describes what is mapped to the +Y direction on screen. This is equivalent to rotating the screen. The base vector is 3D, but this camera only provides a 2D view. .. warning:: This is assumed to be normalized (length 1.0) """ x, y = _up self._camera_data.up = (x, y, 0.0) @property def angle(self) -> float: """ An angle representation of the 2D UP vector. This starts with 0 degrees as [0, 1] rotating clock-wise. """ # We rotate counter clockwise by 90 degrees because we want 0 deg to be directly up angle = degrees(atan2(self._camera_data.up[1], self._camera_data.up[0])) - 90.0 if angle <= 0.0: angle += 360.0 return 360 - angle @angle.setter def angle(self, value: float) -> None: """ Set the 2D UP vector using an angle. This starts with 0 degrees as [0, 1] rotating clock-wise. """ _r = radians(90.0 - value) # Note that this is flipped as we want 0 degrees to be vert. self._camera_data.up = (cos(_r), sin(_r), 0.0) @property def zoom(self) -> float: """ A scalar value which describes how much the projection should be scaled towards from its center. A value of 2.0 causes the projection to be half its original size. This causes sprites to appear 2.0x larger. """ return self._camera_data.zoom @zoom.setter def zoom(self, _zoom: float) -> None: """ Set the scalar value which describes how much the projection should be scaled towards from its center. A value of 2.0 causes the projection to be half its original size. This causes sprites to appear 2.0x larger. """ self._camera_data.zoom = _zoom # top_left @property def top_left(self) -> Vec2: """Get the top left most corner the camera can see""" pos = self.position ux, uy, *_ = self._camera_data.up rx, ry = uy, -ux top = self.top left = self.left return Vec2(pos.x + ux * top + rx * left, pos.y + uy * top + ry * left) @top_left.setter def top_left(self, new_corner: Point2): ux, uy, *_ = self._camera_data.up rx, ry = uy, -ux top = self.top left = self.left x, y = new_corner self.position = (x - ux * top - rx * left, y - uy * top - ry * left) # type: ignore # top_center @property def top_center(self) -> Vec2: """Get the top most position the camera can see""" pos = self.position ux, uy, *_ = self._camera_data.up top = self.top return Vec2(pos.x + ux * top, pos.y + uy * top) @top_center.setter def top_center(self, new_top: Point2): ux, uy, *_ = self._camera_data.up top = self.top x, y = new_top self.position = x - ux * top, y - uy * top # type: ignore # top_right @property def top_right(self) -> Vec2: """Get the top right most corner the camera can see""" pos = self.position ux, uy, *_ = self._camera_data.up rx, ry = uy, -ux top = self.top right = self.right return Vec2(pos.x + ux * top + rx * right, pos.y + uy * top + ry * right) @top_right.setter def top_right(self, new_corner: Point2): ux, uy, *_ = self._camera_data.up rx, ry = uy, -ux top = self.top right = self.right x, y = new_corner self.position = (x - ux * top - rx * right, y - uy * top - ry * right) # type: ignore # center_right @property def center_right(self) -> Vec2: """Get the right most point the camera can see""" pos = self.position ux, uy, *_ = self._camera_data.up right = self.right return Vec2(pos.x + uy * right, pos.y - ux * right) @center_right.setter def center_right(self, new_right: Point2): ux, uy, *_ = self._camera_data.up right = self.right x, y = new_right self.position = x - uy * right, y + ux * right # type: ignore # bottom_right @property def bottom_right(self) -> Vec2: """Get the bottom right most corner the camera can see""" pos = self.position ux, uy, *_ = self._camera_data.up rx, ry = uy, -ux bottom = self.bottom right = self.right return Vec2(pos.x + ux * bottom + rx * right, pos.y + uy * bottom + ry * right) @bottom_right.setter def bottom_right(self, new_corner: Point2): ux, uy, *_ = self._camera_data.up rx, ry = uy, -ux bottom = self.bottom right = self.right x, y = new_corner self.position = ( x - ux * bottom - rx * right, y - uy * bottom - ry * right, ) # type: ignore # bottom_center @property def bottom_center(self) -> Vec2: """Get the bottom most position the camera can see""" pos = self.position ux, uy, *_ = self._camera_data.up bottom = self.bottom return Vec2(pos.x + ux * bottom, pos.y + uy * bottom) @bottom_center.setter def bottom_center(self, new_bottom: Point2): ux, uy, *_ = self._camera_data.up bottom = self.bottom x, y = new_bottom self.position = x - ux * bottom, y - uy * bottom # type: ignore # bottom_left @property def bottom_left(self) -> Vec2: """Get the bottom left most corner the camera can see""" pos = self.position ux, uy, *_ = self._camera_data.up rx, ry = uy, -ux bottom = self.bottom left = self.left return Vec2(pos.x + ux * bottom + rx * left, pos.y + uy * bottom + ry * left) @bottom_left.setter def bottom_left(self, new_corner: Point2): ux, uy, *_ = self._camera_data.up rx, ry = uy, -ux bottom = self.bottom left = self.left x, y = new_corner self.position = x - ux * bottom - rx * left, y - uy * bottom - ry * left # type: ignore # center_left @property def center_left(self) -> Vec2: """Get the left most point the camera can see""" pos = self.position ux, uy, *_ = self._camera_data.up left = self.left return Vec2(pos.x + uy * left, pos.y - ux * left) @center_left.setter def center_left(self, new_left: Point2): ux, uy, *_ = self._camera_data.up left = self.left x, y = new_left self.position = Vec2(x - uy * left, y + ux * left)