Source code for silx.gui.plot3d.scene.transform

# /*##########################################################################
#
# Copyright (c) 2015-2020 European Synchrotron Radiation Facility
#
# 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.
#
# ###########################################################################*/
"""This module provides 4x4 matrix operation and classes to handle them."""

__authors__ = ["T. Vincent"]
__license__ = "MIT"
__date__ = "25/07/2016"


import itertools
import numpy

from . import event


# Functions ###################################################################

# Projections


[docs] def mat4LookAtDir(position, direction, up): """Creates matrix to look in direction from position. :param position: Array-like 3 coordinates of the point of view position. :param direction: Array-like 3 coordinates of the sight direction vector. :param up: Array-like 3 coordinates of the upward direction in the image plane. :returns: Corresponding matrix. :rtype: numpy.ndarray of shape (4, 4) """ assert len(position) == 3 assert len(direction) == 3 assert len(up) == 3 direction = numpy.array(direction, copy=True, dtype=numpy.float32) dirnorm = numpy.linalg.norm(direction) assert dirnorm != 0.0 direction /= dirnorm side = numpy.cross(direction, numpy.asarray(up, dtype=numpy.float32)) sidenorm = numpy.linalg.norm(side) assert sidenorm != 0.0 up = numpy.cross(side / sidenorm, direction) upnorm = numpy.linalg.norm(up) assert upnorm != 0.0 up /= upnorm matrix = numpy.identity(4, dtype=numpy.float32) matrix[0, :3] = side matrix[1, :3] = up matrix[2, :3] = -direction return numpy.dot(matrix, mat4Translate(-position[0], -position[1], -position[2]))
[docs] def mat4LookAt(position, center, up): """Creates matrix to look at center from position. See gluLookAt. :param position: Array-like 3 coordinates of the point of view position. :param center: Array-like 3 coordinates of the center of the scene. :param up: Array-like 3 coordinates of the upward direction in the image plane. :returns: Corresponding matrix. :rtype: numpy.ndarray of shape (4, 4) """ position = numpy.asarray(position, dtype=numpy.float32) center = numpy.asarray(center, dtype=numpy.float32) direction = center - position return mat4LookAtDir(position, direction, up)
[docs] def mat4Frustum(left, right, bottom, top, near, far): """Creates a frustum projection matrix. See glFrustum. """ return numpy.array( ( (2.0 * near / (right - left), 0.0, (right + left) / (right - left), 0.0), (0.0, 2.0 * near / (top - bottom), (top + bottom) / (top - bottom), 0.0), (0.0, 0.0, -(far + near) / (far - near), -2.0 * far * near / (far - near)), (0.0, 0.0, -1.0, 0.0), ), dtype=numpy.float32, )
[docs] def mat4Perspective(fovy, width, height, near, far): """Creates a perspective projection matrix. Similar to gluPerspective. :param float fovy: Field of view angle in degrees in the y direction. :param float width: Width of the viewport. :param float height: Height of the viewport. :param float near: Distance to the near plane (strictly positive). :param float far: Distance to the far plane (strictly positive). :return: Corresponding matrix. :rtype: numpy.ndarray of shape (4, 4) """ assert fovy != 0 assert height != 0 assert width != 0 assert near > 0.0 assert far > near aspectratio = width / height f = 1.0 / numpy.tan(numpy.radians(fovy) / 2.0) return numpy.array( ( (f / aspectratio, 0.0, 0.0, 0.0), (0.0, f, 0.0, 0.0), (0.0, 0.0, (far + near) / (near - far), 2.0 * far * near / (near - far)), (0.0, 0.0, -1.0, 0.0), ), dtype=numpy.float32, )
[docs] def mat4Orthographic(left, right, bottom, top, near, far): """Creates an orthographic (i.e., parallel) projection matrix. See glOrtho. """ return numpy.array( ( (2.0 / (right - left), 0.0, 0.0, -(right + left) / (right - left)), (0.0, 2.0 / (top - bottom), 0.0, -(top + bottom) / (top - bottom)), (0.0, 0.0, -2.0 / (far - near), -(far + near) / (far - near)), (0.0, 0.0, 0.0, 1.0), ), dtype=numpy.float32, )
# Affine
[docs] def mat4Translate(tx, ty, tz): """4x4 translation matrix.""" return numpy.array( ( (1.0, 0.0, 0.0, tx), (0.0, 1.0, 0.0, ty), (0.0, 0.0, 1.0, tz), (0.0, 0.0, 0.0, 1.0), ), dtype=numpy.float32, )
[docs] def mat4Scale(sx, sy, sz): """4x4 scale matrix.""" return numpy.array( ( (sx, 0.0, 0.0, 0.0), (0.0, sy, 0.0, 0.0), (0.0, 0.0, sz, 0.0), (0.0, 0.0, 0.0, 1.0), ), dtype=numpy.float32, )
[docs] def mat4RotateFromAngleAxis(angle, x=0.0, y=0.0, z=1.0): """4x4 rotation matrix from angle and axis. :param float angle: The rotation angle in radians. :param float x: The rotation vector x coordinate. :param float y: The rotation vector y coordinate. :param float z: The rotation vector z coordinate. """ ca = numpy.cos(angle) sa = numpy.sin(angle) return numpy.array( ( ( (1.0 - ca) * x * x + ca, (1.0 - ca) * x * y - sa * z, (1.0 - ca) * x * z + sa * y, 0.0, ), ( (1.0 - ca) * x * y + sa * z, (1.0 - ca) * y * y + ca, (1.0 - ca) * y * z - sa * x, 0.0, ), ( (1.0 - ca) * x * z - sa * y, (1.0 - ca) * y * z + sa * x, (1.0 - ca) * z * z + ca, 0.0, ), (0.0, 0.0, 0.0, 1.0), ), dtype=numpy.float32, )
[docs] def mat4RotateFromQuaternion(quaternion): """4x4 rotation matrix from quaternion. :param quaternion: Array-like unit quaternion stored as (x, y, z, w) """ quaternion = numpy.array(quaternion, copy=True) quaternion /= numpy.linalg.norm(quaternion) qx, qy, qz, qw = quaternion return numpy.array( ( ( 1.0 - 2.0 * (qy**2 + qz**2), 2.0 * (qx * qy - qw * qz), 2.0 * (qx * qz + qw * qy), 0.0, ), ( 2.0 * (qx * qy + qw * qz), 1.0 - 2.0 * (qx**2 + qz**2), 2.0 * (qy * qz - qw * qx), 0.0, ), ( 2.0 * (qx * qz - qw * qy), 2.0 * (qy * qz + qw * qx), 1.0 - 2.0 * (qx**2 + qy**2), 0.0, ), (0.0, 0.0, 0.0, 1.0), ), dtype=numpy.float32, )
[docs] def mat4Shear(axis, sx=0.0, sy=0.0, sz=0.0): """4x4 shear matrix: Skew two axes relative to a third fixed one. shearFactor = tan(shearAngle) :param str axis: The axis to keep constant and shear against. In 'x', 'y', 'z'. :param float sx: The shear factor for the X axis relative to axis. :param float sy: The shear factor for the Y axis relative to axis. :param float sz: The shear factor for the Z axis relative to axis. """ assert axis in ("x", "y", "z") matrix = numpy.identity(4, dtype=numpy.float32) # Make the shear column index = "xyz".find(axis) shearcolumn = numpy.array((sx, sy, sz, 0.0), dtype=numpy.float32) shearcolumn[index] = 1.0 matrix[:, index] = shearcolumn return matrix
# Transforms ##################################################################
[docs] class Transform(event.Notifier): def __init__(self, static=False): """Base class for (row-major) 4x4 matrix transforms. :param bool static: False (default) to reset cache when changed, True for static matrices. """ super(Transform, self).__init__() self._matrix = None self._inverse = None if not static: self.addListener(self._changed) # Listening self for changes def __repr__(self): return "%s(%s)" % (self.__class__.__init__, repr(self.getMatrix(copy=False)))
[docs] def inverse(self): """Return the Transform of the inverse. The returned Transform is static, it is not updated when this Transform is modified. :return: A Transform which is the inverse of this Transform. """ return Inverse(self)
# Matrix def _makeMatrix(self): """Override to build matrix""" return numpy.identity(4, dtype=numpy.float32) def _makeInverse(self): """Override to build inverse matrix.""" return numpy.linalg.inv(self.getMatrix(copy=False))
[docs] def getMatrix(self, copy=True): """The 4x4 matrix of this transform. :param bool copy: True (the default) to get a copy of the matrix, False to get the internal matrix, do not modify! :return: 4x4 matrix of this transform. """ if self._matrix is None: self._matrix = self._makeMatrix() if copy: return self._matrix.copy() else: return self._matrix
matrix = property(getMatrix, doc="The 4x4 matrix of this transform.")
[docs] def getInverseMatrix(self, copy=False): """The 4x4 matrix of the inverse of this transform. :param bool copy: True (the default) to get a copy of the matrix, False to get the internal matrix, do not modify! :return: 4x4 matrix of the inverse of this transform. """ if self._inverse is None: self._inverse = self._makeInverse() if copy: return self._inverse.copy() else: return self._inverse
inverseMatrix = property( getInverseMatrix, doc="The 4x4 matrix of the inverse of this transform." ) # Listener def _changed(self, source): """Default self listener reseting matrix cache.""" self._matrix = None self._inverse = None # Multiplication with vectors
[docs] def transformPoints(self, points, direct=True, perspectiveDivide=False): """Apply the transform to an array of points. :param points: 2D array of N vectors of 3 or 4 coordinates :param bool direct: Whether to apply the direct (True, the default) or inverse (False) transform. :param bool perspectiveDivide: Whether to apply the perspective divide (True) or not (False, the default). :return: The transformed points. :rtype: numpy.ndarray of same shape as points. """ if direct: matrix = self.getMatrix(copy=False) else: matrix = self.getInverseMatrix(copy=False) points = numpy.asarray(points) assert points.ndim == 2 points = numpy.transpose(points) dimension = points.shape[0] assert dimension in (3, 4) if dimension == 3: # Add 4th coordinate points = numpy.append( points, numpy.ones((1, points.shape[1]), dtype=points.dtype), axis=0 ) result = numpy.transpose(numpy.dot(matrix, points)) if perspectiveDivide: mask = result[:, 3] != 0.0 result[mask] /= result[mask, 3][:, numpy.newaxis] return result[:, :3] if dimension == 3 else result
@staticmethod def _prepareVector(vector, w): """Add 4th coordinate (w) to vector if missing.""" assert len(vector) in (3, 4) vector = numpy.asarray(vector, dtype=numpy.float32) if len(vector) == 3: vector = numpy.append(vector, w) return vector
[docs] def transformPoint(self, point, direct=True, perspectiveDivide=False): """Apply the transform to a point. :param point: Array-like vector of 3 or 4 coordinates. :param bool direct: Whether to apply the direct (True, the default) or inverse (False) transform. :param bool perspectiveDivide: Whether to apply the perspective divide (True) or not (False, the default). :return: The transformed point. :rtype: numpy.ndarray of same length as point. """ if direct: matrix = self.getMatrix(copy=False) else: matrix = self.getInverseMatrix(copy=False) result = numpy.dot(matrix, self._prepareVector(point, 1.0)) if perspectiveDivide and result[3] != 0.0: result /= result[3] if len(point) == 3: return result[:3] else: return result
[docs] def transformDir(self, direction, direct=True): """Apply the transform to a direction. :param direction: Array-like vector of 3 coordinates. :param bool direct: Whether to apply the direct (True, the default) or inverse (False) transform. :return: The transformed direction. :rtype: numpy.ndarray of length 3. """ if direct: matrix = self.getMatrix(copy=False) else: matrix = self.getInverseMatrix(copy=False) return numpy.dot(matrix[:3, :3], direction[:3])
[docs] def transformNormal(self, normal, direct=True): """Apply the transform to a normal: R = (M-1)t * V. :param normal: Array-like vector of 3 coordinates. :param bool direct: Whether to apply the direct (True, the default) or inverse (False) transform. :return: The transformed normal. :rtype: numpy.ndarray of length 3. """ if direct: matrix = self.getInverseMatrix(copy=False).T else: matrix = self.getMatrix(copy=False).T return numpy.dot(matrix[:3, :3], normal[:3])
_CUBE_CORNERS = numpy.array( list(itertools.product((0.0, 1.0), repeat=3)), dtype=numpy.float32 ) """Unit cube corners used by :meth:`transformBounds`"""
[docs] def transformBounds(self, bounds, direct=True): """Apply the transform to an axes-aligned rectangular box. :param bounds: Min and max coords of the box for each axes. :type bounds: 2x3 numpy.ndarray :param bool direct: Whether to apply the direct (True, the default) or inverse (False) transform. :return: Axes-aligned rectangular box including the transformed box. :rtype: 2x3 numpy.ndarray of float32 """ corners = numpy.ones((8, 4), dtype=numpy.float32) corners[:, :3] = bounds[0] + self._CUBE_CORNERS * (bounds[1] - bounds[0]) if direct: matrix = self.getMatrix(copy=False) else: matrix = self.getInverseMatrix(copy=False) # Transform corners cornerstransposed = numpy.dot(matrix, corners.T) cornerstransposed = cornerstransposed / cornerstransposed[3] # Get min/max for each axis transformedbounds = numpy.empty((2, 3), dtype=numpy.float32) transformedbounds[0] = cornerstransposed.T[:, :3].min(axis=0) transformedbounds[1] = cornerstransposed.T[:, :3].max(axis=0) return transformedbounds
[docs] class Inverse(Transform): """Transform which is the inverse of another one. Static: It never gets updated. """ def __init__(self, transform): """Initializer. :param Transform transform: The transform to invert. """ super(Inverse, self).__init__(static=True) self._matrix = transform.getInverseMatrix(copy=True) self._inverse = transform.getMatrix(copy=True)
[docs] class TransformList(Transform, event.HookList): """List of transforms.""" def __init__(self, iterable=()): Transform.__init__(self) event.HookList.__init__(self, iterable) def _listWillChangeHook(self, methodName, *args, **kwargs): for item in self: item.removeListener(self._transformChanged) def _listWasChangedHook(self, methodName, *args, **kwargs): for item in self: item.addListener(self._transformChanged) self.notify() def _transformChanged(self, source): """Listen to transform changes of the list and its items.""" if source is not self: # Avoid infinite recursion self.notify() def _makeMatrix(self): matrix = numpy.identity(4, dtype=numpy.float32) for transform in self: matrix = numpy.dot(matrix, transform.getMatrix(copy=False)) return matrix
[docs] class StaticTransformList(Transform): """Transform that is a snapshot of a list of Transforms It does not keep reference to the list of Transforms. :param iterable: Iterable of Transform used for initialization """ def __init__(self, iterable=()): super(StaticTransformList, self).__init__(static=True) matrix = numpy.identity(4, dtype=numpy.float32) for transform in iterable: matrix = numpy.dot(matrix, transform.getMatrix(copy=False)) self._matrix = matrix # Init matrix once
# Affine ######################################################################
[docs] class Matrix(Transform): def __init__(self, matrix=None): """4x4 Matrix. :param matrix: 4x4 array-like matrix or None for identity matrix. """ super(Matrix, self).__init__(static=True) self.setMatrix(matrix)
[docs] def setMatrix(self, matrix=None): """Update the 4x4 Matrix. :param matrix: 4x4 array-like matrix or None for identity matrix. """ if matrix is None: self._matrix = numpy.identity(4, dtype=numpy.float32) else: matrix = numpy.array(matrix, copy=True, dtype=numpy.float32) assert matrix.shape == (4, 4) self._matrix = matrix # Reset cached inverse as Transform is declared static self._inverse = None self.notify()
# Redefined here to add a setter matrix = property( Transform.getMatrix, setMatrix, doc="The 4x4 matrix of this transform." )
[docs] class Translate(Transform): """4x4 translation matrix.""" def __init__(self, tx=0.0, ty=0.0, tz=0.0): super(Translate, self).__init__() self._tx, self._ty, self._tz = 0.0, 0.0, 0.0 self.setTranslate(tx, ty, tz) def _makeMatrix(self): return mat4Translate(self.tx, self.ty, self.tz) def _makeInverse(self): return mat4Translate(-self.tx, -self.ty, -self.tz) @property def tx(self): return self._tx @tx.setter def tx(self, tx): self.setTranslate(tx=tx) @property def ty(self): return self._ty @ty.setter def ty(self, ty): self.setTranslate(ty=ty) @property def tz(self): return self._tz @tz.setter def tz(self, tz): self.setTranslate(tz=tz) @property def translation(self): return numpy.array((self.tx, self.ty, self.tz), dtype=numpy.float32) @translation.setter def translation(self, translations): tx, ty, tz = translations self.setTranslate(tx, ty, tz) def setTranslate(self, tx=None, ty=None, tz=None): if tx is not None: self._tx = tx if ty is not None: self._ty = ty if tz is not None: self._tz = tz self.notify()
[docs] class Scale(Transform): """4x4 scale matrix.""" def __init__(self, sx=1.0, sy=1.0, sz=1.0): super(Scale, self).__init__() self._sx, self._sy, self._sz = 0.0, 0.0, 0.0 self.setScale(sx, sy, sz) def _makeMatrix(self): return mat4Scale(self.sx, self.sy, self.sz) def _makeInverse(self): return mat4Scale(1.0 / self.sx, 1.0 / self.sy, 1.0 / self.sz) @property def sx(self): return self._sx @sx.setter def sx(self, sx): self.setScale(sx=sx) @property def sy(self): return self._sy @sy.setter def sy(self, sy): self.setScale(sy=sy) @property def sz(self): return self._sz @sz.setter def sz(self, sz): self.setScale(sz=sz) @property def scale(self): return numpy.array((self._sx, self._sy, self._sz), dtype=numpy.float32) @scale.setter def scale(self, scales): sx, sy, sz = scales self.setScale(sx, sy, sz) def setScale(self, sx=None, sy=None, sz=None): if sx is not None: assert sx != 0.0 self._sx = sx if sy is not None: assert sy != 0.0 self._sy = sy if sz is not None: assert sz != 0.0 self._sz = sz self.notify()
[docs] class Rotate(Transform): def __init__(self, angle=0.0, ax=0.0, ay=0.0, az=1.0): """4x4 rotation matrix. :param float angle: The rotation angle in degrees. :param float ax: The x coordinate of the rotation axis. :param float ay: The y coordinate of the rotation axis. :param float az: The z coordinate of the rotation axis. """ super(Rotate, self).__init__() self._angle = 0.0 self._axis = None self.setAngleAxis(angle, (ax, ay, az)) @property def angle(self): """The rotation angle in degrees.""" return self._angle @angle.setter def angle(self, angle): self.setAngleAxis(angle=angle) @property def axis(self): """The normalized rotation axis as a numpy.ndarray.""" return self._axis.copy() @axis.setter def axis(self, axis): self.setAngleAxis(axis=axis)
[docs] def setAngleAxis(self, angle=None, axis=None): """Update the angle and/or axis of the rotation. :param float angle: The rotation angle in degrees. :param axis: Array-like axis vector (3 coordinates). """ if angle is not None: self._angle = angle if axis is not None: assert len(axis) == 3 axis = numpy.array(axis, copy=True, dtype=numpy.float32) assert axis.size == 3 norm = numpy.linalg.norm(axis) if norm == 0.0: # No axis, set rotation angle to 0. self._angle = 0.0 self._axis = numpy.array((0.0, 0.0, 1.0), dtype=numpy.float32) else: self._axis = axis / norm if angle is not None or axis is not None: self.notify()
@property def quaternion(self): """Rotation unit quaternion as (x, y, z, w). Where: ||(x, y, z)|| = sin(angle/2), w = cos(angle/2). """ if numpy.linalg.norm(self._axis) == 0.0: return numpy.array((0.0, 0.0, 0.0, 1.0), dtype=numpy.float32) else: quaternion = numpy.empty((4,), dtype=numpy.float32) halfangle = 0.5 * numpy.radians(self.angle) quaternion[0:3] = numpy.sin(halfangle) * self._axis quaternion[3] = numpy.cos(halfangle) return quaternion @quaternion.setter def quaternion(self, quaternion): assert len(quaternion) == 4 # Normalize quaternion quaternion = numpy.array(quaternion, copy=True) quaternion /= numpy.linalg.norm(quaternion) # Get angle sinhalfangle = numpy.linalg.norm(quaternion[0:3]) coshalfangle = quaternion[3] angle = 2.0 * numpy.arctan2(sinhalfangle, coshalfangle) # Axis will be normalized in setAngleAxis self.setAngleAxis(numpy.degrees(angle), quaternion[0:3]) def _makeMatrix(self): angle = numpy.radians(self.angle, dtype=numpy.float32) return mat4RotateFromAngleAxis(angle, *self.axis) def _makeInverse(self): return numpy.array( self.getMatrix(copy=False).transpose(), copy=True, order="C", dtype=numpy.float32, )
[docs] class Shear(Transform): def __init__(self, axis, sx=0.0, sy=0.0, sz=0.0): """4x4 shear/skew matrix of 2 axes relative to the third one. :param str axis: The axis to keep fixed, in 'x', 'y', 'z' :param float sx: The shear factor for the x axis. :param float sy: The shear factor for the y axis. :param float sz: The shear factor for the z axis. """ assert axis in ("x", "y", "z") super(Shear, self).__init__() self._axis = axis self._factors = sx, sy, sz @property def axis(self): """The axis against which other axes are skewed.""" return self._axis @property def factors(self): """The shear factors: shearFactor = tan(shearAngle)""" return self._factors def _makeMatrix(self): return mat4Shear(self.axis, *self.factors) def _makeInverse(self): sx, sy, sz = self.factors return mat4Shear(self.axis, -sx, -sy, -sz)
# Projection ################################################################## class _Projection(Transform): """Base class for projection matrix. Handles near and far clipping plane values. Subclasses must implement :meth:`_makeMatrix`. :param float near: Distance to the near plane. :param float far: Distance to the far plane. :param bool checkDepthExtent: Toggle checks near > 0 and far > near. :param size: Viewport's size used to compute the aspect ratio (width, height). :type size: 2-tuple of float """ def __init__(self, near, far, checkDepthExtent=False, size=(1.0, 1.0)): super(_Projection, self).__init__() self._checkDepthExtent = checkDepthExtent self._depthExtent = 1, 10 self.setDepthExtent(near, far) # set _depthExtent self._size = 1.0, 1.0 self.size = size # set _size def setDepthExtent(self, near=None, far=None): """Set the extent of the visible area along the viewing direction. :param float near: The near clipping plane Z coord. :param float far: The far clipping plane Z coord. """ near = float(near) if near is not None else self._depthExtent[0] far = float(far) if far is not None else self._depthExtent[1] if self._checkDepthExtent: assert near > 0.0 assert far > near self._depthExtent = near, far self.notify() @property def near(self): """Distance to the near plane.""" return self._depthExtent[0] @near.setter def near(self, near): if near != self.near: self.setDepthExtent(near=near) @property def far(self): """Distance to the far plane.""" return self._depthExtent[1] @far.setter def far(self, far): if far != self.far: self.setDepthExtent(far=far) @property def size(self): """Viewport size as a 2-tuple of float (width, height).""" return self._size @size.setter def size(self, size): assert len(size) == 2 self._size = tuple(size) self.notify()
[docs] class Orthographic(_Projection): """Orthographic (i.e., parallel) projection which can keep aspect ratio. Clipping planes are adjusted to match the aspect ratio of the :attr:`size` attribute if :attr:`keepaspect` is True. In this case, the left, right, bottom and top parameters defines the area which must always remain visible. Effective clipping planes are adjusted to keep the aspect ratio. :param float left: Coord of the left clipping plane. :param float right: Coord of the right clipping plane. :param float bottom: Coord of the bottom clipping plane. :param float top: Coord of the top clipping plane. :param float near: Distance to the near plane. :param float far: Distance to the far plane. :param size: Viewport's size used to compute the aspect ratio (width, height). :type size: 2-tuple of float :param bool keepaspect: True (default) to keep aspect ratio, False otherwise. """ def __init__( self, left=0.0, right=1.0, bottom=1.0, top=0.0, near=-1.0, far=1.0, size=(1.0, 1.0), keepaspect=True, ): self._left, self._right = left, right self._bottom, self._top = bottom, top self._keepaspect = bool(keepaspect) super(Orthographic, self).__init__(near, far, checkDepthExtent=False, size=size) # _update called when setting size def _makeMatrix(self): return mat4Orthographic( self.left, self.right, self.bottom, self.top, self.near, self.far ) def _update(self, left, right, bottom, top): if self.keepaspect: width, height = self.size aspect = width / height orthoaspect = abs(left - right) / abs(bottom - top) if orthoaspect >= aspect: # Keep width, enlarge height newheight = numpy.sign(top - bottom) * abs(left - right) / aspect bottom = 0.5 * (bottom + top) - 0.5 * newheight top = bottom + newheight else: # Keep height, enlarge width newwidth = numpy.sign(right - left) * abs(bottom - top) * aspect left = 0.5 * (left + right) - 0.5 * newwidth right = left + newwidth # Store values self._left, self._right = left, right self._bottom, self._top = bottom, top
[docs] def setClipping(self, left=None, right=None, bottom=None, top=None): """Set the clipping planes of the projection. Parameters are adjusted to keep aspect ratio. If a clipping plane coord is not provided, it uses its current value :param float left: Coord of the left clipping plane. :param float right: Coord of the right clipping plane. :param float bottom: Coord of the bottom clipping plane. :param float top: Coord of the top clipping plane. """ left = float(left) if left is not None else self.left right = float(right) if right is not None else self.right bottom = float(bottom) if bottom is not None else self.bottom top = float(top) if top is not None else self.top self._update(left, right, bottom, top) self.notify()
left = property(lambda self: self._left, doc="Coord of the left clipping plane.") right = property(lambda self: self._right, doc="Coord of the right clipping plane.") bottom = property( lambda self: self._bottom, doc="Coord of the bottom clipping plane." ) top = property(lambda self: self._top, doc="Coord of the top clipping plane.") @property def size(self): """Viewport size as a 2-tuple of float (width, height)""" return self._size @size.setter def size(self, size): assert len(size) == 2 size = float(size[0]), float(size[1]) if size != self._size: self._size = size self._update(self.left, self.right, self.bottom, self.top) self.notify() @property def keepaspect(self): """True to keep aspect ratio, False otherwise.""" return self._keepaspect @keepaspect.setter def keepaspect(self, aspect): aspect = bool(aspect) if aspect != self._keepaspect: self._keepaspect = aspect self._update(self.left, self.right, self.bottom, self.top) self.notify()
[docs] class Ortho2DWidget(_Projection): """Orthographic projection with pixel as unit. Provides same coordinates as widgets: origin: top left, X axis goes left, Y axis goes down. :param float near: Z coordinate of the near clipping plane. :param float far: Z coordinante of the far clipping plane. :param size: Viewport's size used to compute the aspect ratio (width, height). :type size: 2-tuple of float """ def __init__(self, near=-1.0, far=1.0, size=(1.0, 1.0)): super(Ortho2DWidget, self).__init__(near, far, size) def _makeMatrix(self): width, height = self.size return mat4Orthographic(0.0, width, height, 0.0, self.near, self.far)
[docs] class Perspective(_Projection): """Perspective projection matrix defined by FOV and aspect ratio. :param float fovy: Vertical field-of-view in degrees. :param float near: The near clipping plane Z coord (stricly positive). :param float far: The far clipping plane Z coord (> near). :param size: Viewport's size used to compute the aspect ratio (width, height). :type size: 2-tuple of float """ def __init__(self, fovy=90.0, near=0.1, far=1.0, size=(1.0, 1.0)): super(Perspective, self).__init__(near, far, checkDepthExtent=True) self._fovy = 90.0 self.fovy = fovy # Set _fovy self.size = size # Set _ size def _makeMatrix(self): width, height = self.size return mat4Perspective(self.fovy, width, height, self.near, self.far) @property def fovy(self): """Vertical field-of-view in degrees.""" return self._fovy @fovy.setter def fovy(self, fovy): self._fovy = float(fovy) self.notify()