# /*##########################################################################
#
# 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


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]))


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)


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,
    )


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,
    )


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


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,
    )


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,
    )


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,
    )


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,
    )


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 ##################################################################


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.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

    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

    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.0, 1.0), 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.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()


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()


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)

    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,
        )


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()


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

    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.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)


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()
