Source code for svg3d.view

"""Define OpenGL-style views and viewports for scene rendering."""

import math
from collections.abc import Iterable
from typing import NamedTuple

import numpy as np

from .svg3d import Mesh


[docs] def get_lookat_matrix( pos_object: np.ndarray, pos_camera: np.ndarray, vec_up: np.ndarray | tuple = (0.0, 1.0, 0.0), ): """Get the "look at" or view matrix for our system. This matrix moves the world such that the camera is at the origin and rotates the world such that the z-axis of the camera is the mathematical z axis. Parameters ---------- pos_object : :math:`(3,)` :class:`numpy.ndarray` Position of the object we are looking at. "at" in openGL vernacular. pos_camera : :math:`(3,)` :class:`numpy.ndarray` Position of the camera. "eye" in openGL vernacular. vec_up : :math:`(3,)` :class:`numpy.ndarray`: | tuple, optional Vector describing the height of the camera. "up" in openGL vernacular. Default value: (0.0, 1.0, 0.0) .. seealso:: Calculating a Lookat Matrix: https://stackoverflow.com/questions/349050/calculating-a-lookat-matrix/6802424#6802424 .. seealso:: Understanding Lookat Matrices: https://medium.com/@carmencincotti/lets-look-at-magic-lookat-matrices-c77e53ebdf78 """ # First, shift the world such that the camera is at the origin m_camera_translate = np.eye(4) m_camera_translate[-1, :3] -= pos_camera # Now, rotate the vector from the camera position to the object position such that # it lines up with the z axis. # Compute the x axis of our original coordinates along the vector [camera - pos] axis_z = np.asarray(pos_camera, dtype=np.float64) - pos_object axis_z /= np.linalg.norm(axis_z) # "forward" axis in openGL terms # Compute the y ("forward") axis of our original coordinate system. This is # perpendicular to axis_z and any arbitrary vector in the plane formed by z and y axis_x = np.cross(vec_up, axis_z) axis_x /= np.linalg.norm(axis_x) # "right" axis in openGL terms axis_y = np.cross(axis_z, axis_x) # "up" axis in openGL terms m_camera_rotate = np.eye(4) m_camera_rotate[:3, :3] = [axis_x, axis_y, axis_z] return m_camera_translate @ (m_camera_rotate.T)
[docs] def get_projection_matrix( z_near: float, z_far: float, fov_y: float, aspect: float = 1.0 ): """Get a projection matrix from parameters of the provided view frustum. z_near and z_far are the distances to the tip and base of the frustum, respectively. fov_y describes the opening angle of the base, and aspect describes the relationship between the y opening angle and the x. Objects that lie outside the view frustum are culled and wil not be rendered into the scene. .. # TODO: include image of frustum view Parameters ---------- z_near : float Distance to the near clipping plane. Must be greater than zero. z_far : float Distance to the far clipping plane. Must be greater than z_near. fov_y : float Field of view angle along the y direction, in degrees. aspect : float, optional Ratio of field of view angle in the y direction to field of view angle in x. Default value: 1.0 .. seealso:: OpenGL Reference: https://registry.khronos.org/OpenGL-Refpages/gl2.1/xhtml/gluPerspective.xml .. seealso:: Understanding Projection Matrices: http://www.songho.ca/opengl/gl_projectionmatrix.html """ f = 1 / math.tan(math.radians(fov_y) / 2) m_projection = np.zeros([4, 4]) m_projection[[0, 1, -1], [0, 1, 2]] = f / aspect, f, -1 m_projection[2, [2, 3]] = ( (z_near + z_far) / (z_near - z_far), (2 * z_near * z_far) / (z_near - z_far), ) return m_projection.T
[docs] class Viewport(NamedTuple): """A :obj:`~.Viewport` controls the visible area in a rendered SVG. This is a convience wrapper around the svgwrite :obj:`~svgwrite.mixins.Viewbox` classes with a simplified interface. """ minx: float = -0.5 """Left border of the viewport.""" miny: float = -0.5 """Right border of the viewport.""" width: float = 1.0 """Width of the viewport.""" height: float = 1.0 """Height of the viewport."""
[docs] @classmethod def from_aspect(cls, aspect_ratio: float): """Create a :obj:`~.Viewport` with the given aspect ratio.""" return cls(-aspect_ratio / 2.0, -0.5, aspect_ratio, 1.0)
[docs] @classmethod def from_string(cls, string_to_parse: str): """Create a :obj:`~.Viewport` from a space-delimited string of floats.""" args = [float(f) for f in string_to_parse.split()] return cls(*args)
[docs] class View: def __init__( self, look_at: np.ndarray, projection: np.ndarray, scene: tuple[Mesh] | list[Mesh], viewport=None, ): self._look_at = look_at self._projection = projection self._scene = scene self._viewport = viewport if viewport is not None else Viewport() DEFAULT_OBJECT_POSITION = np.zeros(3) """Classmethods for this object center their view on the origin by default.""" ISOMETRIC_VIEW_MATRIX = [ [np.sqrt(3), -1, np.sqrt(2), 0], [0, 2, np.sqrt(2), 0], [-np.sqrt(3), -1, np.sqrt(2), 0], [0, 0, -100 * np.sqrt(6), np.sqrt(6)], ] / np.sqrt(6) # TODO: no-undoc-members, don't want to expose this @property def look_at(self): """:math:`(4,4)` :class:`numpy.ndarray`: The openGL-style lookAt matrix. .. TODO: add links to openGL docs, explain transpose if required. """ return self._look_at @look_at.setter def look_at(self, look_at: np.ndarray): self._look_at = look_at @property def projection(self): """:math:`(4,4)` :class:`numpy.ndarray`: The openGL-style projection matrix. .. TODO: add links to openGL docs, explain transpose if required. """ return self._projection @projection.setter def projection(self, projection: np.ndarray): self._projection = projection @property def scene(self): """Iterable[Mesh] : Get or set the list of :obj:`~.Mesh` objects to render.""" return self._scene @scene.setter def scene(self, scene: tuple[Mesh] | list[Mesh]): self._scene = scene @property def viewport(self): """Viewport: Get or set the system's :obj:`~.Viewport`.""" return self._viewport @viewport.setter def viewport(self, viewport: Viewport): self._viewport = viewport
[docs] @classmethod def from_look_at_and_projection( cls, look_at: np.ndarray, projection: np.ndarray, scene: Iterable[Mesh], ): """Create a new :obj:`~.View` from a lookAt and projection matrix. .. TODO: Describe how these are composed, give matrix equations """ msg = "Both look_at and projection must have size (4,4)." assert look_at.shape == (4, 4) and projection.shape == (4, 4), msg return cls( look_at, projection, scene, )
[docs] @classmethod def isometric(cls, scene, fov: float = 1.0, distance: float = 100.0): """Create a :obj:`~.View` based on an isometric projection. In an isometric projection, the scale along each coordinate axis is identical. This is a parallel projection method, meaning that objects remain the same size regardless of their position from the camera. This is useful in diagrams and technical renderings but may be undesirable for realistic scenes. .. # TODO: Give example image or diagram showing an isometric projection Parameters ---------- scene : list[Mesh] An iterable of mesh objects to view. fov: float Field of view, in degrees. Should be in the open range (0.0, 180.0). Default value: 1.0 distance: float Distance of the viewer from the origin. Default value: 100.0 """ # Equivalent to a 45 degree rotation about the X axis and an atan(1/sqrt(2)) # degree rotation about the z axis isometric_view = cls.ISOMETRIC_VIEW_MATRIX isometric_view[-1, 2] = -distance return cls( look_at=isometric_view, projection=get_projection_matrix(z_near=1.0, z_far=200.0, fov_y=fov), scene=scene, )
[docs] @classmethod def dimetric(cls, scene, fov: float = 1.0, distance: float = 100.0): """Create a :obj:`~.View` based on a dimetric projection. In a dimetric projection, the scale along two out of three axes is identical. This strikes a balance between the simplicity and interpretability of isometric projections and the improved sense of realism afforded by trimetric projections. This is a parallel projection method, meaning that objects remain the same size regardless of their position from the camera. This is useful in diagrams and technical renderings but may be undesirable for realistic scenes. .. # TODO: Give example image or diagram showing an dimetric projection Parameters ---------- scene : list[Mesh] An iterable of mesh objects to view. fov: float Field of view, in degrees. Should be in the open range (0.0, 180.0). Default value: 1.0 distance: float Distance of the viewer from the origin. Default value: 100.0 """ # TODO: reimplement as https://faculty.sites.iastate.edu/jia/files/inline-files/projection-classify.pdf camera_position = np.array([8, 8, 21]) / math.sqrt(569) * distance return cls( look_at=get_lookat_matrix( pos_object=cls.DEFAULT_OBJECT_POSITION, pos_camera=camera_position ), projection=get_projection_matrix(z_near=1.0, z_far=200.0, fov_y=fov), scene=scene, )
[docs] @classmethod def trimetric(cls, scene, fov: float = 1.0, distance: float = 100.0): """Create a :obj:`~.View` based on a trimetric projection. In a trimetric projection, each axis is scaled independently. This results in a more "natural" scene than isometric and trimetric views, as the foreshortening of each axis provides a sense of depth to the scene. This is a parallel projection method, meaning that objects remain the same size regardless of their position from the camera. This is useful in diagrams and technical renderings but may be undesirable for realistic scenes. .. # TODO: Give example image or diagram showing a trimetric projection Parameters ---------- scene : list[Mesh] An iterable of mesh objects to view. fov: float Field of view, in degrees. Should be in the open range (0.0, 180.0). Default value: 1.0 distance: float Distance of the viewer from the origin. Default value: 100.0 """ camera_position = np.array([1 / 7, 1 / 14, 3 / 14]) * math.sqrt(14) * distance return cls( look_at=get_lookat_matrix( pos_object=cls.DEFAULT_OBJECT_POSITION, pos_camera=camera_position ), projection=get_projection_matrix(z_near=1.0, z_far=200.0, fov_y=fov), scene=scene, )
# Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE.