summaryrefslogtreecommitdiff
path: root/src/silx/gui/plot3d/scene/transform.py
diff options
context:
space:
mode:
Diffstat (limited to 'src/silx/gui/plot3d/scene/transform.py')
-rw-r--r--src/silx/gui/plot3d/scene/transform.py1027
1 files changed, 1027 insertions, 0 deletions
diff --git a/src/silx/gui/plot3d/scene/transform.py b/src/silx/gui/plot3d/scene/transform.py
new file mode 100644
index 0000000..43b739b
--- /dev/null
+++ b/src/silx/gui/plot3d/scene/transform.py
@@ -0,0 +1,1027 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# 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."""
+
+from __future__ import absolute_import, division, unicode_literals
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "25/07/2016"
+
+
+import itertools
+import numpy
+
+from . import event
+
+
+# Functions ###################################################################
+
+# Projections
+
+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.
+ direction /= dirnorm
+
+ side = numpy.cross(direction,
+ numpy.array(up, copy=False, dtype=numpy.float32))
+ sidenorm = numpy.linalg.norm(side)
+ assert sidenorm != 0.
+ up = numpy.cross(side / sidenorm, direction)
+ upnorm = numpy.linalg.norm(up)
+ assert upnorm != 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]))
+
+
+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.array(position, copy=False, dtype=numpy.float32)
+ center = numpy.array(center, copy=False, dtype=numpy.float32)
+ direction = center - position
+ return mat4LookAtDir(position, direction, up)
+
+
+def mat4Frustum(left, right, bottom, top, near, far):
+ """Creates a frustum projection matrix.
+
+ See glFrustum.
+ """
+ return numpy.array((
+ (2.*near / (right-left), 0., (right+left) / (right-left), 0.),
+ (0., 2.*near / (top-bottom), (top+bottom) / (top-bottom), 0.),
+ (0., 0., -(far+near) / (far-near), -2.*far*near / (far-near)),
+ (0., 0., -1., 0.)), dtype=numpy.float32)
+
+
+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.
+ assert far > near
+ aspectratio = width / height
+ f = 1. / numpy.tan(numpy.radians(fovy) / 2.)
+ return numpy.array((
+ (f / aspectratio, 0., 0., 0.),
+ (0., f, 0., 0.),
+ (0., 0., (far + near) / (near - far), 2. * far * near / (near - far)),
+ (0., 0., -1., 0.)), dtype=numpy.float32)
+
+
+def mat4Orthographic(left, right, bottom, top, near, far):
+ """Creates an orthographic (i.e., parallel) projection matrix.
+
+ See glOrtho.
+ """
+ return numpy.array((
+ (2. / (right - left), 0., 0., - (right + left) / (right - left)),
+ (0., 2. / (top - bottom), 0., - (top + bottom) / (top - bottom)),
+ (0., 0., -2. / (far - near), - (far + near) / (far - near)),
+ (0., 0., 0., 1.)), dtype=numpy.float32)
+
+
+# Affine
+
+def mat4Translate(tx, ty, tz):
+ """4x4 translation matrix."""
+ return numpy.array((
+ (1., 0., 0., tx),
+ (0., 1., 0., ty),
+ (0., 0., 1., tz),
+ (0., 0., 0., 1.)), dtype=numpy.float32)
+
+
+def mat4Scale(sx, sy, sz):
+ """4x4 scale matrix."""
+ return numpy.array((
+ (sx, 0., 0., 0.),
+ (0., sy, 0., 0.),
+ (0., 0., sz, 0.),
+ (0., 0., 0., 1.)), dtype=numpy.float32)
+
+
+def mat4RotateFromAngleAxis(angle, x=0., y=0., z=1.):
+ """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.-ca) * x*x + ca, (1.-ca) * x*y - sa*z, (1.-ca) * x*z + sa*y, 0.),
+ ((1.-ca) * x*y + sa*z, (1.-ca) * y*y + ca, (1.-ca) * y*z - sa*x, 0.),
+ ((1.-ca) * x*z - sa*y, (1.-ca) * y*z + sa*x, (1.-ca) * z*z + ca, 0.),
+ (0., 0., 0., 1.)), dtype=numpy.float32)
+
+
+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. - 2.*(qy**2 + qz**2), 2.*(qx*qy - qw*qz), 2.*(qx*qz + qw*qy), 0.),
+ (2.*(qx*qy + qw*qz), 1. - 2.*(qx**2 + qz**2), 2.*(qy*qz - qw*qx), 0.),
+ (2.*(qx*qz - qw*qy), 2.*(qy*qz + qw*qx), 1. - 2.*(qx**2 + qy**2), 0.),
+ (0., 0., 0., 1.)), dtype=numpy.float32)
+
+
+def mat4Shear(axis, sx=0., sy=0., sz=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.), dtype=numpy.float32)
+ shearcolumn[index] = 1.
+ matrix[:, index] = shearcolumn
+ return matrix
+
+
+# Transforms ##################################################################
+
+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)))
+
+ 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))
+
+ 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.")
+
+ 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
+
+ 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.array(points, copy=False)
+ 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.
+ 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.array(vector, copy=False, dtype=numpy.float32)
+ if len(vector) == 3:
+ vector = numpy.append(vector, w)
+ return vector
+
+ 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.))
+
+ if perspectiveDivide and result[3] != 0.:
+ result /= result[3]
+
+ if len(point) == 3:
+ return result[:3]
+ else:
+ return result
+
+ 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])
+
+ 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., 1.), repeat=3)),
+ dtype=numpy.float32)
+ """Unit cube corners used by :meth:`transformBounds`"""
+
+ 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
+
+
+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)
+
+
+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
+
+
+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 ######################################################################
+
+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)
+
+ 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.")
+
+
+class Translate(Transform):
+ """4x4 translation matrix."""
+
+ def __init__(self, tx=0., ty=0., tz=0.):
+ super(Translate, self).__init__()
+ self._tx, self._ty, self._tz = 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()
+
+
+class Scale(Transform):
+ """4x4 scale matrix."""
+
+ def __init__(self, sx=1., sy=1., sz=1.):
+ super(Scale, self).__init__()
+ self._sx, self._sy, self._sz = 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. / self.sx, 1. / self.sy, 1. / 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.
+ self._sx = sx
+ if sy is not None:
+ assert sy != 0.
+ self._sy = sy
+ if sz is not None:
+ assert sz != 0.
+ self._sz = sz
+ self.notify()
+
+
+class Rotate(Transform):
+
+ def __init__(self, angle=0., ax=0., ay=0., az=1.):
+ """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.
+ 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)
+
+ 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.: # No axis, set rotation angle to 0.
+ self._angle = 0.
+ self._axis = numpy.array((0., 0., 1.), 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.:
+ return numpy.array((0., 0., 0., 1.), 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. * 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)
+
+
+class Shear(Transform):
+
+ def __init__(self, axis, sx=0., sy=0., sz=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., 1.)):
+ super(_Projection, self).__init__()
+ self._checkDepthExtent = checkDepthExtent
+ self._depthExtent = 1, 10
+ self.setDepthExtent(near, far) # set _depthExtent
+ self._size = 1., 1.
+ 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.
+ 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()
+
+
+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., right=1., bottom=1., top=0., near=-1., far=1.,
+ size=(1., 1.), 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
+
+ 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()
+
+
+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., far=1., size=(1., 1.)):
+
+ super(Ortho2DWidget, self).__init__(near, far, size)
+
+ def _makeMatrix(self):
+ width, height = self.size
+ return mat4Orthographic(0., width, height, 0., self.near, self.far)
+
+
+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., near=0.1, far=1., size=(1., 1.)):
+
+ super(Perspective, self).__init__(near, far, checkDepthExtent=True)
+ self._fovy = 90.
+ 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()