# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright 2024-2025 Max-Planck-Institut für Nachhaltige Materialien GmbH
# Copyright 2011-2024 Max-Planck-Institut für Eisenforschung GmbH
#
# DAMASK is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.
import builtins
import copy
import re
import sys
from typing import Literal, NamedTuple, Optional, Sequence, TypeVar, Union

import numpy as np
import numpy.typing as npt
import scipy
from scipy.spatial.transform import Rotation as ScipyRotation

from . import grid_filters, tensor, util
from ._typehints import FloatSequence, IntSequence, NumpyRngSeed


class AxisAngleTuple(NamedTuple):
    axis: np.ndarray
    angle: np.ndarray


_P = -1

# parameters for conversion from/to cubochoric
_sc   = np.pi**(1./6.)/6.**(1./6.)
_beta = np.pi**(5./6.)/6.**(1./6.)/2.
_R1   = (3.*np.pi/4.)**(1./3.)

MyType = TypeVar('MyType', bound='Rotation')

class Rotation:
    u"""
    Rotation with functionality for conversion between different representations.

    The following conventions apply:

    - Coordinate frames are right-handed.
    - A rotation angle ω is taken to be positive for a counterclockwise rotation
      when viewing from the end point of the rotation axis towards the origin.
    - Rotations will be interpreted in the passive sense, i.e. as rotation of
      the coordinate frame.
    - P = -1 (as default).

    References
    ----------
    D. Rowenhorst et al., Modelling and Simulation in Materials Science and Engineering 23:083501, 2015
    https://doi.org/10.1088/0965-0393/23/8/083501

    Examples
    --------
    Rotate vector 'a' (defined in coordinate system 'A') to
    coordinates 'b' expressed in system 'B', where 'Q' represents the conversion from 'A' to 'B':

    >>> import numpy as np
    >>> import damask
    >>> Q = damask.Rotation.from_random()
    >>> a = np.random.rand(3)
    >>> b = Q @ a
    >>> np.allclose(np.dot(Q.as_matrix(),a),b)
    True

    Compound rotations R1 (first) and R2 (second):

    >>> import numpy as np
    >>> import damask
    >>> R1 = damask.Rotation.from_random()
    >>> R2 = damask.Rotation.from_random()
    >>> R = R2 * R1
    >>> np.allclose(R.as_matrix(), np.dot(R2.as_matrix(),R1.as_matrix()))
    True

    Initialize from scipy.spatial.transform.Rotation:

    >>> import numpy as np
    >>> import damask
    >>> from scipy.spatial.transform import Rotation as ScipyRotation
    >>> R_SciPy = ScipyRotation.random()
    >>> R_DAMASK = damask.Rotation(rotation=R_SciPy)
    >>> v = np.random.rand(3)
    >>> np.allclose(R_DAMASK@v, R_SciPy.apply(v,inverse=True))
    True
    >>> np.allclose(R_DAMASK.as_quaternion(),R_SciPy.as_quat(canonical=True,scalar_first=True))
    True
    """

    __slots__ = ['quaternion']

    def __init__(self,
                 rotation: Union[FloatSequence, ScipyRotation, 'Rotation'] = np.array([1.,0.,0.,0.])):
        """
        New rotation.

        Parameters
        ----------
        rotation : list, numpy.ndarray, Rotation, or scipy.spatial.transform.Rotation, optional
            Unit quaternion in positive real hemisphere or Rotation.
            Use .from_quaternion to perform a sanity check.
            Defaults to no rotation.

        Note
        ----
        When passing in a Rotation from SciPy, it is interpreted
        passively even though SciPy uses an active convention.
        """
        self.quaternion: np.ndarray

        if (isinstance(rotation,ScipyRotation)):
            if util.version(scipy.__version__) >= '1.14.0':
                self.quaternion = rotation.as_quat(canonical=True,scalar_first=True)
            else:
                quat = rotation.as_quat(canonical=True)
                self.quaternion = np.block([quat[...,3:4],quat[...,0:3]])
        elif len(s:=np.asarray(rotation).shape) > 0 and s[-1] == 4:
            self.quaternion = np.array(rotation,dtype=float)
        else:
            raise TypeError('"rotation" cannot be interpreted as quaternion')


    def __str__(self) -> str:
        """
        Return str(self).

        Give short, human-readable summary.
        """
        return re.sub(r'\[([ +-]*[0-9.eE+-]+)(\s*)([ +-]*[0-9.eE+-]+)(\s*)([ +-]*[0-9.eE+-]+)(\s*)([ +-]*[0-9.eE+-]+)(\s*)\]',
                      r'\1\2    \3\4\5\6\7\8',self.quaternion.__str__())


    def __repr__(self) -> str:
        """
        Return repr(self).

        Give unambiguous representation.
        """
        return re.sub(r'\[(\+|-| )*(?=\d)([^,]+,)\s*?(\+|-| )*(?=\d)([^,]+,)\s*?(\+|-| )*(?=\d)([^,]+,)\s*?(\+|-| )*(?=\d)(.+?)\]',
                      r'(\1\2    \3\4 \5\6 \7\8)',self.quaternion.__repr__())


    def __copy__(self: MyType,
                 rotation: Optional[Union[FloatSequence, 'Rotation']] = None) -> MyType:
        """
        Return deepcopy(self).

        Create deep copy.
        """
        dup = copy.deepcopy(self)
        if rotation is not None:
            dup.quaternion = Rotation(rotation).quaternion
        return dup

    copy = __copy__


    def __getitem__(self: MyType,
                    item: Union[tuple[Union[None, int, slice, "builtins.ellipsis"], ...],
                                int, bool, np.bool_, np.ndarray]) -> MyType:
        """
        Return self[item].

        Return slice according to item.
        """
        return self.copy(np.stack([c[item] for c in np.rollaxis(self.quaternion,-1)],axis=-1))


    def __eq__(self,                                                                                # type: ignore[override]
               other: object) -> npt.NDArray[np.bool_]:
        """
        Return self==other.

        Test equality of other.

        Parameters
        ----------
        other : Rotation
            Rotation to check for equality.

        Returns
        -------
        equal : numpy.ndarray
            Whether both arguments are equal.
        """
        return NotImplemented if not isinstance(other, Rotation) else \
               np.logical_or(np.all(self.quaternion ==     other.quaternion,axis=-1),
                             np.all(self.quaternion == -1.*other.quaternion,axis=-1))

    def __ne__(self,                                                                                # type: ignore[override]
               other: object) -> npt.NDArray[np.bool_]:
        """
        Return self!=other.

        Test inequality of other.

        Parameters
        ----------
        other : Rotation
            Rotation to check for inequality.
        """
        return np.logical_not(self==other) if isinstance(other, Rotation) else NotImplemented


    def isclose(self: MyType,
                other: MyType,
                rtol: float = 1.e-5,
                atol: float = 1.e-8,
                equal_nan: bool = True) -> npt.NDArray[np.bool_]:
        """
        Report where values are approximately equal to corresponding ones of other Rotation.

        Parameters
        ----------
        other : Rotation
            Rotation to compare against.
        rtol : float, optional
            Relative tolerance of equality.
        atol : float, optional
            Absolute tolerance of equality.
        equal_nan : bool, optional
            Consider matching NaN values as equal. Defaults to True.

        Returns
        -------
        mask : numpy.ndarray of bool, shape (self.shape)
            Mask indicating where corresponding rotations are close.
        """
        s = self.quaternion
        o = other.quaternion
        return np.logical_or(np.all(np.isclose(s,    o,rtol,atol,equal_nan),axis=-1),
                             np.all(np.isclose(s,-1.*o,rtol,atol,equal_nan),axis=-1))

    def allclose(self: MyType,
                 other: MyType,
                 rtol: float = 1.e-5,
                 atol: float = 1.e-8,
                 equal_nan: bool = True) -> np.bool_:
        """
        Test whether all values are approximately equal to corresponding ones of other Rotation.

        Parameters
        ----------
        other : Rotation
            Rotation to compare against.
        rtol : float, optional
            Relative tolerance of equality.
        atol : float, optional
            Absolute tolerance of equality.
        equal_nan : bool, optional
            Consider matching NaN values as equal. Defaults to True.

        Returns
        -------
        allclose : bool
            Test whether all values are approximately equal to corresponding
            ones of other rotation.
        """
        return np.all(self.isclose(other,rtol,atol,equal_nan))


    @property
    def ndim(self) -> int:
        return self.quaternion[...,0].ndim

    @property
    def shape(self) -> tuple[int, ...]:
        return self.quaternion[...,0].shape

    @property
    def size(self) -> int:
        return self.quaternion[...,0].size


    def __array__(self: MyType,
                  dtype: Optional[npt.DTypeLike] = None,
                  *,
                  copy: Optional[bool] = None) -> np.ndarray:
        """Initializer for numpy."""
        return np.asarray(self.quaternion,dtype) if util.version(np.__version__) < '2.0.0' else \
               np.asarray(self.quaternion,dtype,copy=copy)


    def __len__(self: MyType) -> int:
        """
        Return len(self).

        Length of leading/leftmost dimension of array.
        """
        return 0 if self.shape == () else self.shape[0]


    def __invert__(self: MyType) -> MyType:
        """
        Return ~self.

        Inverse rotation (backward rotation).
        """
        dup = self.copy()
        dup.quaternion[...,1:] *= -1.
        return dup


    def __pow__(self: MyType,
                exp: Union[float, int]) -> MyType:
        """
        Return self**exp.

        Perform the rotation 'exp' times.

        Parameters
        ----------
        exp : float
            Exponent.
        """
        phi = np.arccos(self.quaternion[...,0:1])
        p = self.quaternion[...,1:]/np.linalg.norm(self.quaternion[...,1:],axis=-1,keepdims=True)
        return self.copy(Rotation(np.block([np.cos(exp*phi),np.sin(exp*phi)*p]))._standardize())

    def __ipow__(self: MyType,
                 exp: Union[float, int]) -> MyType:
        """
        Return self**=exp.

        Perform the rotation 'exp' times (in-place).

        Parameters
        ----------
        exp : float
            Exponent.
        """
        return self**exp


    def __mul__(self: MyType,
                other: MyType) -> MyType:
        """
        Return self*other.

        Compose with other.

        Parameters
        ----------
        other : Rotation, shape (self.shape)
            Rotation for composition.
            Compatible innermost dimensions will blend.

        Returns
        -------
        composition : Rotation
            Compound rotation self*other, i.e. first other then self rotation.
        """
        if isinstance(other,Rotation):
            blend = util.shapeblender( self.shape,other.shape)
            s_m   = util.shapeshifter( self.shape,blend,mode='right')
            s_o   = util.shapeshifter(other.shape,blend,mode='left')

            q_m = self.quaternion[...,0].reshape(s_m + (1,))
            p_m = self.quaternion[...,1:].reshape(s_m + (3,))
            q_o = other.quaternion[...,0].reshape(s_o + (1,))
            p_o = other.quaternion[...,1:].reshape(s_o + (3,))

            qmo = q_m*q_o
            q = (qmo - np.einsum('...i,...i',p_m,p_o).reshape(qmo.shape))
            p = q_m*p_o + q_o*p_m + _P * np.cross(p_m,p_o)
            return self.copy(Rotation(np.block([q,p]))._standardize())
        else:
            raise TypeError('use "R@b", i.e. matmul, to apply rotation "R" to object "b"')

    def __imul__(self: MyType,
                 other: MyType) -> MyType:
        """
        Return self*=other.

        Compose with other (in-place).

        Parameters
        ----------
        other : Rotation, shape (self.shape)
            Rotation for composition.
            Compatible innermost dimensions will blend.
        """
        return self*other


    def __truediv__(self: MyType,
                    other: MyType) -> MyType:
        """
        Return self/other.

        Compose with inverse of other.

        Parameters
        ----------
        other : damask.Rotation, shape (self.shape)
            Rotation to invert for composition.
            Compatible innermost dimensions will blend.

        Returns
        -------
        composition : Rotation
            Compound rotation self*(~other), i.e. first inverse of other then self rotation.
        """
        if isinstance(other,Rotation):
            return self*~other
        else:
            raise TypeError('use "R@b", i.e. matmul, to apply rotation "R" to object "b"')

    def __itruediv__(self: MyType,
                     other: MyType) -> MyType:
        """
        Return self/=other.

        Compose with inverse of other (in-place).

        Parameters
        ----------
        other : Rotation, shape (self.shape)
            Rotation to invert for composition.
        """
        return self/other


    def __matmul__(self,
                   other: np.ndarray) -> np.ndarray:
        """
        Return self@other.

        Rotate vector, second-order tensor, or fourth-order tensor.
        `other` is interpreted as an array of tensor quantities with the highest-possible order
        considering the shape of `self`. Compatible innermost dimensions will blend.
        For instance, shapes of (2,) and (3,3) for `self` and `other` prompt interpretation of
        `other` as a second-rank tensor and result in (2,) rotated tensors, whereas
        shapes of (2,1) and (3,3) for `self` and `other` result in (2,3) rotated vectors.

        Parameters
        ----------
        other : numpy.ndarray, shape (...,3), (...,3,3), or (...,3,3,3,3)
            Vector or tensor on which to apply the rotation.

        Returns
        -------
        rotated : numpy.ndarray, shape (...,3), (...,3,3), or (...,3,3,3,3)
            Rotated vector or tensor, i.e. transformed to frame defined by rotation.

        Examples
        --------
        Application of twelve (random) rotations to a set of five vectors.

        >>> import numpy as np
        >>> import damask
        >>> r = damask.Rotation.from_random(shape=12)
        >>> o = np.ones((5,3))
        >>> (r@o).shape                                                                             # (12) @ (5, 3)
        (12, 5, 3)

        Application of a (random) rotation to all twelve second-rank tensors.

        >>> import numpy as np
        >>> import damask
        >>> r = damask.Rotation.from_random()
        >>> o = np.ones((12,3,3))
        >>> (r@o).shape                                                                             # (1) @ (12, 3,3)
        (12, 3, 3)

        Application of twelve (random) rotations to the corresponding twelve second-rank tensors.

        >>> import numpy as np
        >>> import damask
        >>> r = damask.Rotation.from_random(shape=12)
        >>> o = np.ones((12,3,3))
        >>> (r@o).shape                                                                             # (12) @ (3,3)
        (12, 3, 3)

        Application of each of three (random) rotations to all three vectors.

        >>> import numpy as np
        >>> import damask
        >>> r = damask.Rotation.from_random(shape=3)
        >>> o = np.ones((3,3))
        >>> (r[...,np.newaxis]@o[np.newaxis,...]).shape                                             # (3,1) @ (1,3, 3)
        (3, 3, 3)

        Application of twelve (random) rotations to all twelve second-rank tensors.

        >>> import numpy as np
        >>> import damask
        >>> r = damask.Rotation.from_random(shape=12)
        >>> o = np.ones((12,3,3))
        >>> (r@o[np.newaxis,...]).shape                                                             # (12) @ (1,12, 3,3)
        (12, 12, 3, 3)
        """
        def rotate_vector(q,vector):
            p_m = q[...,1:]
            q_m = q[...,0]
            return  np.expand_dims(q_m**2 - np.einsum('...i,...i',p_m,p_m), -1) * vector \
                  + np.expand_dims(2. * np.einsum('...i,...i',p_m,vector), -1) * p_m \
                  + np.expand_dims(2. * _P * q_m, -1) * np.cross(p_m,vector)

        if isinstance(other, np.ndarray):
            obs = (util.shapeblender(self.shape,other.shape[:-1])+other.shape[-1:])[self.ndim:]
            for order in [4,2,1]:
                if obs[-order:] == order*(3,):
                    target_batch = util.shapeblender(self.shape,other.shape[:-order],False)
                    q = self.quaternion.reshape(self.shape + (1,)*(len(target_batch)-len(self.shape)) + (1,)*max(0,order-1) + (4,))
                    result = other
                    for ax in range(-order,0):
                        result = np.moveaxis(rotate_vector(q,np.moveaxis(result,ax,-1)),-1,ax)
                    return result
            raise ValueError('can only rotate vectors, second-order tensors, and fourth-order tensors')
        elif isinstance(other, Rotation):                                                           # type: ignore[unreachable]
            raise TypeError('use "R2*R1", i.e. multiplication, to compose rotations "R1" and "R2"')
        else:
            raise TypeError(f'cannot rotate "{type(other)}"')

    apply = __matmul__


    def _standardize(self: MyType) -> MyType:
        """Standardize quaternion (ensure positive real hemisphere)."""
        self.quaternion[self.quaternion[...,0] < 0.] *= -1.
        return self


    def append(self: MyType,
               other: Union[MyType, list[MyType]]) -> MyType:
        """
        Extend array along first dimension with other array(s).

        Parameters
        ----------
        other : (list of) damask.Rotation
            Rotation(s) to append.
        """
        return self.copy(np.vstack(tuple(map(lambda x:x.quaternion,
                                             [self]+other if isinstance(other,list) else [self,other]))))


    def flatten(self: MyType,
                order: Literal['C','F','A'] = 'C') -> MyType:
        """
        Flatten array.

        Parameters
        ----------
        order : {'C', 'F', 'A'}, optional
            'C' flattens in row-major (C-style) order.
            'F' flattens in column-major (Fortran-style) order.
            'A' flattens in column-major order if object is Fortran contiguous in memory,
            row-major order otherwise.
            Defaults to 'C'.

        Returns
        -------
        flattened : damask.Rotation
            Rotation flattened to single dimension.
        """
        return self.copy() if self.shape == () else \
               self.copy(self.quaternion.reshape((-1,4),order=order))


    def reshape(self: MyType,
                newshape: Union[int, IntSequence],
                order: Literal['C','F','A'] = 'C') -> MyType:
        """
        Reshape array.

        Parameters
        ----------
        newshape : (sequence of) int
            The new shape should be compatible with the original shape.
            If an integer, then the result will be a 1-D array of that length.
        order : {'C', 'F', 'A'}, optional
            'C' flattens in row-major (C-style) order.
            'F' flattens in column-major (Fortran-style) order.
            'A' flattens in column-major order if object is Fortran contiguous in memory,
            row-major order otherwise.
            Defaults to 'C'.

        Returns
        -------
        reshaped : damask.Rotation
            Rotation of given shape.
        """
        shape = (newshape,) if isinstance(newshape,(int,np.integer)) else newshape
        return self.copy(self.quaternion.reshape(tuple(shape)+(4,),order=order))


    def broadcast_to(self: MyType,
                     shape: Union[int, IntSequence],
                     mode: Literal['left', 'right'] = 'right') -> MyType:
        """
        Broadcast array.

        Parameters
        ----------
        shape : (sequence of) int
            Shape of broadcasted array, needs to be compatible with the original shape.
        mode : str, optional
            Where to preferentially locate missing dimensions.
            Either 'left' or 'right' (default).

        Returns
        -------
        broadcasted : damask.Rotation
            Rotation broadcasted to given shape.
        """
        shape_ = (shape,) if isinstance(shape,(int,np.integer)) else tuple(shape)
        return self.copy(np.broadcast_to(self.quaternion.reshape(util.shapeshifter(self.shape,shape_,mode)+(4,)),
                                         shape_+(4,)))


    def average(self: MyType,
                weights: Optional[FloatSequence] = None) -> MyType:
        """
        Average along last array dimension.

        Parameters
        ----------
        weights : numpy.ndarray, shape (self.shape), optional
            Relative weight of each rotation.

        Returns
        -------
        average : damask.Rotation
            Weighted average of original Rotation field.

        References
        ----------
        F. Landis Markley et al., Journal of Guidance, Control, and Dynamics 30(4):1193-1197, 2007
        https://doi.org/10.2514/1.28949
        """
        def M(quat):
            """Intermediate representation supporting quaternion averaging."""
            return np.einsum('...i,...j',quat,quat)

        weights_ = np.ones(self.shape,dtype=float) if weights is None else np.array(weights,float)

        eig, vec = np.linalg.eig(np.sum(M(self.quaternion) * weights_[...,np.newaxis,np.newaxis],axis=-3)
                                /np.sum(                     weights_[...,np.newaxis,np.newaxis],axis=-3))

        return self.copy(Rotation.from_quaternion(np.real(
                                                  np.squeeze(
                                                  np.take_along_axis(vec,
                                                                     eig.argmax(axis=-1)[...,np.newaxis,np.newaxis],
                                                                     axis=-1),
                                                  axis=-1)),
                                                  accept_homomorph = True))


    def misorientation(self: MyType,
                       other: MyType) -> MyType:
        """
        Calculate misorientation to other Rotation.

        Parameters
        ----------
        other : damask.Rotation
            Rotation to which the misorientation is computed.
            Compatible innermost dimensions will blend.

        Returns
        -------
        g : damask.Rotation
            Misorientation.
        """
        return ~(self*~other)


    def misorientation_angle(self: MyType,
                             other: MyType) -> np.ndarray:
        """
        Calculate misorientation angle to other Rotation.

        Parameters
        ----------
        other : damask.Rotation
            Rotation to which the misorientation angle is computed.
            Compatible innermost dimensions will blend.

        Returns
        -------
        omega : np.ndarray
            Misorientation angle.
        """
        blend = util.shapeblender( self.shape,other.shape)
        s_m   = util.shapeshifter( self.shape,blend,mode='right')
        s_o   = util.shapeshifter(other.shape,blend,mode='left')

        q_m = self.quaternion[...,0].reshape(s_m)
        p_m = self.quaternion[...,1:].reshape(s_m + (3,))
        q_o = other.quaternion[...,0].reshape(s_o)
        p_o = other.quaternion[...,1:].reshape(s_o + (3,))

        q_0 = np.abs(q_m*q_o - np.einsum('...i,...i',p_m,-p_o))
        return 2.*np.arccos(np.clip(np.round(q_0,15),None,1.))


    ################################################################################################
    # convert to different orientation representations (numpy arrays)

    def as_quaternion(self) -> npt.NDArray[np.floating]:
        """
        Represent as unit quaternion.

        Returns
        -------
        q : numpy.ndarray, shape (...,4)
            Unit quaternion (q_0, q_1, q_2, q_3) in positive real hemisphere, i.e. ǀqǀ = 1, q_0 ≥ 0.
        """
        return self.quaternion.copy()

    def as_Euler_angles(self,
                        degrees: bool = False) -> npt.NDArray[np.floating]:
        """
        Represent as Bunge Euler angles.

        Parameters
        ----------
        degrees : bool, optional
            Return angles in degrees. Defaults to False.

        Returns
        -------
        phi : numpy.ndarray, shape (...,3)
            Bunge Euler angles (φ_1 ∈ [0,2π], ϕ ∈ [0,π], φ_2 ∈ [0,2π])
            or (φ_1 ∈ [0,360], ϕ ∈ [0,180], φ_2 ∈ [0,360]) if degrees == True.

        Notes
        -----
        Bunge Euler angles correspond to a rotation axis sequence of z–x'–z''.

        Examples
        --------
        Cube orientation (quaternion = [1,0,0,0]) as Bunge Euler angles.

        >>> import damask
        >>> damask.Rotation(rotation=[1,0,0,0]).as_Euler_angles()
        array([0., 0., 0.])
        """
        eu = Rotation._qu2eu(self.quaternion)
        return np.degrees(eu) if degrees else eu

    def as_axis_angle(self,
                      degrees: bool = False,
                      pair: bool = False) -> Union[AxisAngleTuple, np.ndarray]:
        """
        Represent as axis–angle pair.

        Parameters
        ----------
        degrees : bool, optional
            Return rotation angle in degrees. Defaults to False.
        pair : bool, optional
            Return tuple of axis and angle. Defaults to False.

        Returns
        -------
        n_omega : numpy.ndarray, shape (...,4) or tuple ((...,3), (...)) if pair == True
            Axis and angle [n_1, n_2, n_3, ω] with ǀnǀ = 1 and ω ∈ [0,π]
            or ω ∈ [0,180] if degrees == True.

        Examples
        --------
        Cube orientation (quaternion = [1,0,0,0]) as axis–angle pair.

        >>> import damask
        >>> damask.Rotation(rotation=[1,0,0,0]).as_axis_angle(pair=True)
        AxisAngleTuple(axis=array([0., 0., 1.]), angle=array(0.))
        """
        ax: np.ndarray = Rotation._qu2ax(self.quaternion)
        if degrees: ax[...,3] = np.degrees(ax[...,3])
        if pair:
            return AxisAngleTuple(ax[...,:3],ax[...,3])
        else:
            return ax

    def as_matrix(self) -> npt.NDArray[np.floating]:
        """
        Represent as rotation matrix.

        Returns
        -------
        R : numpy.ndarray, shape (...,3,3)
            Rotation matrix R with det(R) = 1, R.T ∙ R = I.

        Examples
        --------
        Cube orientation (quaternion = [1,0,0,0]) as rotation matrix.

        >>> import damask
        >>> damask.Rotation(rotation=[1,0,0,0]).as_matrix()
        array([[1., 0., 0.],
               [0., 1., 0.],
               [0., 0., 1.]])
        """
        return Rotation._qu2om(self.quaternion)

    def as_Rodrigues_vector(self,
                            compact: bool = False) -> npt.NDArray[np.floating]:
        """
        Represent as Rodrigues–Frank vector with separate axis and angle argument.

        Parameters
        ----------
        compact : bool, optional
            Return three-component Rodrigues–Frank vector,
            i.e. axis and angle argument are not separated.

        Returns
        -------
        rho : numpy.ndarray, shape (...,4) or (...,3) if compact == True
            Rodrigues–Frank vector [n_1, n_2, n_3, tan(ω/2)] with ǀnǀ = 1 and ω ∈ [0,π]
            or [n_1, n_2, n_3] with ǀnǀ = tan(ω/2) and ω ∈ [0,π] if compact == True.

        Examples
        --------
        Cube orientation (quaternion = [1,0,0,0] as three-component
        Rodrigues–Frank vector.

        >>> import damask
        >>> damask.Rotation(rotation=[1,0,0,0]).as_Rodrigues_vector(compact=True)
        array([ 0.,  0., 0.])
        """
        ro = Rotation._qu2ro(self.quaternion)
        if compact:
            with np.errstate(invalid='ignore'):
                return ro[...,:3]*ro[...,3:4]
        else:
            return ro

    def as_homochoric(self) -> npt.NDArray[np.floating]:
        """
        Represent as homochoric vector.

        Returns
        -------
        h : numpy.ndarray, shape (...,3)
            Homochoric vector (h_1, h_2, h_3) with ǀhǀ < (3π/4)^(1/3).

        Examples
        --------
        Cube orientation (quaternion = [1,0,0,0]) as homochoric vector.

        >>> import damask
        >>> damask.Rotation(rotation=[1,0,0,0]).as_homochoric()
        array([0., 0., 0.])
        """
        return Rotation._qu2ho(self.quaternion)

    def as_cubochoric(self) -> npt.NDArray[np.floating]:
        """
        Represent as cubochoric vector.

        Returns
        -------
        x : numpy.ndarray, shape (...,3)
            Cubochoric vector (x_1, x_2, x_3) with max(x_i) < 1/2 π^(2/3).

        Examples
        --------
        Cube orientation (quaternion = [1,0,0,0]) as cubochoric vector.

        >>> import damask
        >>> damask.Rotation(rotation=[1,0,0,0]).as_cubochoric()
        array([0., 0., 0.])
        """
        return Rotation._qu2cu(self.quaternion)

    ################################################################################################
    # Static constructors. The input data needs to follow the conventions, options allow to
    # relax the conventions.
    @staticmethod
    def from_quaternion(q: Union[Sequence[FloatSequence], np.ndarray],
                        accept_homomorph: bool = False,
                        normalize: bool = False,
                        P: Literal[1, -1] = -1) -> 'Rotation':
        """
        Initialize from quaternion.

        Parameters
        ----------
        q : numpy.ndarray, shape (...,4)
            Unit quaternion (q_0, q_1, q_2, q_3) in positive real hemisphere, i.e. ǀqǀ = 1 and q_0 ≥ 0.
        accept_homomorph : bool, optional
            Allow homomorphic variants, i.e. q_0 < 0 (negative real hemisphere).
            Defaults to False.
        normalize : bool, optional
            Allow ǀqǀ ≠ 1. Defaults to False.
        P : int ∈ {-1,1}, optional
            Sign convention. Defaults to -1.

        Returns
        -------
        new : damask.Rotation
            Rotation representing the given quaternion.

        Examples
        --------
        >>> import damask
        >>> damask.Rotation.from_quaternion(q=[[1,0,0,0],[0,1,0,0]])
        array([(1.,     0.,  0.,  0.),
               (0.,     1.,  0.,  0.)])
        """
        qu = np.array(q,dtype=float)
        if qu.shape[:-2:-1] != (4,): raise ValueError(f'invalid shape: {qu.shape}')
        if abs(P) != 1: raise ValueError('P ∉ {-1,1}')

        if P == 1: qu[...,1:4] *= -1

        if accept_homomorph:
            qu[qu[...,0]<0.] *= -1.
        elif np.any(qu[...,0] < 0.):
            with np.printoptions(threshold=sys.maxsize,precision=16,floatmode='fixed'):
                raise ValueError(f'quaternion with negative first (real) component\n{qu}')
        if normalize:
            qu /= np.linalg.norm(qu,axis=-1,keepdims=True)
        elif not np.allclose(np.linalg.norm(qu,axis=-1),1.,rtol=1.e-8):
            with np.printoptions(threshold=sys.maxsize,precision=16,floatmode='fixed'):
                raise ValueError(f'quaternion is not of unit length\n{qu}')

        return Rotation(qu)

    @staticmethod
    def from_Euler_angles(phi: np.ndarray,
                          degrees: bool = False) -> 'Rotation':
        """
        Initialize from Bunge Euler angles.

        Parameters
        ----------
        phi : numpy.ndarray, shape (...,3)
            Euler angles (φ_1 ∈ [0,2π], ϕ ∈ [0,π], φ_2 ∈ [0,2π])
            or (φ_1 ∈ [0,360], ϕ ∈ [0,180], φ_2 ∈ [0,360]) if degrees == True.
        degrees : bool, optional
            Euler angles are given in degrees. Defaults to False.

        Returns
        -------
        new : damask.Rotation
            Rotation representing the given Bunge Euler angles.

        Notes
        -----
        Bunge Euler angles correspond to a rotation axis sequence of z–x'–z''.

        Examples
        --------
        >>> import damask
        >>> damask.Rotation.from_Euler_angles(phi=[180,0,0],degrees=True)
        array((0.,     0.,  0.,  1.))
        """
        eu = np.array(phi,dtype=float)
        if eu.shape[:-2:-1] != (3,): raise ValueError(f'invalid shape: {eu.shape}')

        eu = np.radians(eu) if degrees else eu
        if np.any(eu < 0.) or np.any(eu > np.pi*np.array([2.,1.,2.])):
            with np.printoptions(threshold=sys.maxsize,precision=16,floatmode='fixed'):
                raise ValueError(f'Euler angles outside of [0..2π],[0..π],[0..2π]\n{eu}')

        return Rotation(Rotation._eu2qu(eu))

    @staticmethod
    def from_axis_angle(n_omega: np.ndarray,
                        degrees: bool = False,
                        normalize: bool = False,
                        P: Literal[1, -1] = -1) -> 'Rotation':
        """
        Initialize from axis–angle pair.

        Parameters
        ----------
        n_omega : numpy.ndarray, shape (...,4)
            Axis and angle (n_1, n_2, n_3, ω) with ǀnǀ = 1 and ω ∈ [0,π]
            or ω ∈ [0,180] if degrees == True.
        degrees : bool, optional
            Angle ω is given in degrees. Defaults to False.
        normalize : bool, optional
            Allow ǀnǀ ≠ 1. Defaults to False.
        P : int ∈ {-1,1}, optional
            Sign convention. Defaults to -1.

        Returns
        -------
        new : damask.Rotation
            Rotation representing the given axis-angle pair.

        Examples
        --------
        >>> import damask
        >>> damask.Rotation.from_axis_angle(n_omega=[[0,0,1,90],[1,0,0,90]],degrees=True)
        array([(0.707,   0.   ,  0. , 0.707),
               (0.707,   0.707,  0. , 0.   )])
        """
        ax = np.array(n_omega,dtype=float)
        if ax.shape[:-2:-1] != (4,): raise ValueError(f'invalid shape: {ax.shape}')
        if abs(P) != 1: raise ValueError('P ∉ {-1,1}')

        if P == 1: ax[...,0:3] *= -1
        if degrees: ax[...,  3] = np.radians(ax[...,3])
        if np.any(ax[...,3] < 0.) or np.any(ax[...,3] > np.pi):
            with np.printoptions(threshold=sys.maxsize,precision=16,floatmode='fixed'):
                raise ValueError(f'axis–angle rotation angle outside of [0..π]\n{ax}')

        if normalize:
            ax[...,0:3] /= np.linalg.norm(ax[...,0:3],axis=-1,keepdims=True)
        elif not np.allclose(np.linalg.norm(ax[...,0:3],axis=-1),1.):
            with np.printoptions(threshold=sys.maxsize,precision=16,floatmode='fixed'):
                raise ValueError(f'axis–angle rotation axis is not of unit length\n{ax}')

        return Rotation(Rotation._ax2qu(ax))

    @staticmethod
    def from_basis(basis: np.ndarray,
                   orthonormal: bool = True,
                   reciprocal: bool = False) -> 'Rotation':
        """
        Initialize from basis vector triplet.

        Parameters
        ----------
        basis : numpy.ndarray, shape (...,3,3)
            Three three-dimensional basis vectors.
        orthonormal : bool, optional
            Basis is strictly orthonormal, i.e. is free of stretch components. Defaults to True.
        reciprocal : bool, optional
            Basis vectors are given in reciprocal (instead of real) space. Defaults to False.

        Returns
        -------
        new : damask.Rotation
            Rotation representing the given basis.
        """
        om = np.array(basis,dtype=float)
        if om.shape[-2:] != (3,3): raise ValueError(f'invalid shape: {om.shape}')

        if reciprocal:
            om = np.linalg.inv(tensor.transpose(om)/np.pi)                                          # transform reciprocal basis set
            orthonormal = False                                                                     # contains stretch

        if not orthonormal:
            U, _, Vh = np.linalg.svd(om)                                                            # singular value decomposition
            om = np.matmul(U,Vh)
        elif  (np.abs(np.einsum('...i,...i',om[...,0],om[...,1])) > 5.e-8).any() \
           or (np.abs(np.einsum('...i,...i',om[...,1],om[...,2])) > 5.e-8).any() \
           or (np.abs(np.einsum('...i,...i',om[...,2],om[...,0])) > 5.e-8).any():
            with np.printoptions(threshold=sys.maxsize,precision=16,floatmode='fixed'):
                raise ValueError(f'orientation matrix is not orthogonal\n{om}')

        if not np.allclose(np.linalg.det(om),1.):
            with np.printoptions(threshold=sys.maxsize,precision=16,floatmode='fixed'):
                raise ValueError(f'orientation matrix has determinant ≠ 1\n{om}')

        return Rotation(Rotation._om2qu(om))

    @staticmethod
    def from_matrix(R: np.ndarray,
                    normalize: bool = False) -> 'Rotation':
        """
        Initialize from rotation matrix.

        Parameters
        ----------
        R : numpy.ndarray, shape (...,3,3)
            Rotation matrix with det(R) = 1 and R.T ∙ R = I.
        normalize : bool, optional
            Rescales rotation matrix to unit determinant. Defaults to False.

        Returns
        -------
        new : damask.Rotation
            Rotation representing the given rotation matrix.

        Examples
        --------
        >>> import damask
        >>> damask.Rotation.from_matrix(R=[[1,0,0],[0,0,-1],[0,1,0]])
        array(( 0.707,    -0.707, -0. , -0. ))
        """
        return Rotation.from_basis(np.array(R,dtype=float) * (np.linalg.det(R)**(-1./3.))[...,np.newaxis,np.newaxis]
                                   if normalize else
                                   R)

    @staticmethod
    def from_parallel(source: np.ndarray,
                      target: np.ndarray,
                      active: bool = False ) -> 'Rotation':
        """
        Initialize from pairs of two orthogonal basis vectors.

        Basis vectors are expressed in a common global frame (active == False)
        or constitute directions that are each expressed in their respective
        frame (active == True).

        Parameters
        ----------
        source : numpy.ndarray, shape (...,2,3)
            First and second three-dimensional vector of orthogonal source basis.
        target : numpy.ndarray, shape (...,2,3)
            Corresponding three-dimensional vectors of target basis.
        active : bool, optional
            Consider rotations as active, i.e. return (B^-1⋅A) instead of (B⋅A^-1).
            Defaults to False.

        Returns
        -------
        new : damask.Rotation
            Rotation representing the given basis vectors.

        Notes
        -----
        If rotations $A = [s_1,s_2,s_1 × s_2]^T$ and B = $[t_1,t_2,t_1 × t_2]^T$
        are considered "active", the resulting rotation will be $B^{-1}⋅A$ instead
        of the default result $B⋅A^{-1}$.
        Use of "active" enables the definition of a rotation based on
        two specific directions in each coordinate frame being parallel to each other.

        Examples
        --------
        >>> import damask
        >>> damask.Rotation.from_parallel(source=[[2,0,0],[0,1,0]],
        ...                               target=[[1,0,0],[0,2,0]])
        array(( 1.,     0.,  0.,  0.))

        Direction x and y of the specimen frame are parallel to
        direction [ 1 1 1 ] and [ 1 -1 0 ] of the crystal frame, respectively.
        >>> import damask
        >>> damask.Rotation.from_parallel(source=[[1,0,0],[0,1,0]],
        ...                               target=[[1,1,1],[1,-1,0]],active=True)
        array((0.1159169 ,     0.88047624,  0.3647052 ,  0.27984814))
        """
        s_ = np.array(source,dtype=float)
        t_ = np.array(target,dtype=float)

        if s_.shape[-2:] != (2,3) or t_.shape[-2:] != (2,3):
            raise ValueError(f'invalid shape: {s_.shape}/{t_.shape}')

        s_ /= np.linalg.norm(s_,axis=-1,keepdims=True)
        t_ /= np.linalg.norm(t_,axis=-1,keepdims=True)

        sm = np.stack([          s_[...,0,:],
                                             s_[...,1,:],
                        np.cross(s_[...,0,:],s_[...,1,:]) ],axis=-1 if active else -2)
        tm = np.stack([          t_[...,0,:],
                                             t_[...,1,:],
                        np.cross(t_[...,0,:],t_[...,1,:]) ],axis=-1 if active else -2)

        return Rotation.from_basis(sm).misorientation(Rotation.from_basis(tm))


    @staticmethod
    def from_Rodrigues_vector(rho: np.ndarray,
                              normalize: bool = False,
                              P: Literal[1, -1] = -1) -> 'Rotation':
        """
        Initialize from Rodrigues–Frank vector (with angle separated from axis).

        Parameters
        ----------
        rho : numpy.ndarray, shape (...,4)
            Rodrigues–Frank vector (n_1, n_2, n_3, tan(ω/2)) with ǀnǀ = 1  and ω ∈ [0,π].
        normalize : bool, optional
            Allow ǀnǀ ≠ 1. Defaults to False.
        P : int ∈ {-1,1}, optional
            Sign convention. Defaults to -1.

        Returns
        -------
        new : damask.Rotation
            Rotation representing the given Rodrigues–Frank vector.

        Examples
        --------
        >>> import damask
        >>> damask.Rotation.from_Rodrigues_vector(rho=[0,0,1,1])
        array((0.707,     0. ,  0. ,  0.707))
        """
        ro = np.array(rho,dtype=float)
        if ro.shape[:-2:-1] != (4,): raise ValueError(f'invalid shape: {ro}')
        if abs(P) != 1: raise ValueError('P ∉ {-1,1}')

        if P == 1: ro[...,0:3] *= -1
        if np.any(ro[...,3] < 0.):
            with np.printoptions(threshold=sys.maxsize,precision=16,floatmode='fixed'):
                raise ValueError(f'Rodrigues vector rotation angle is negative\n{ro}')

        if normalize:
            ro[...,0:3] /= np.linalg.norm(ro[...,0:3],axis=-1,keepdims=True)
        elif not np.allclose(np.linalg.norm(ro[...,0:3],axis=-1),1.):
            with np.printoptions(threshold=sys.maxsize,precision=16,floatmode='fixed'):
                raise ValueError(f'Rodrigues vector rotation axis is not of unit length\n{ro}')

        return Rotation(Rotation._ro2qu(ro))

    @staticmethod
    def from_homochoric(h: np.ndarray,
                        P: Literal[1, -1] = -1) -> 'Rotation':
        """
        Initialize from homochoric vector.

        Parameters
        ----------
        h : numpy.ndarray, shape (...,3)
            Homochoric vector (h_1, h_2, h_3) with ǀhǀ < (3π/4)^(1/3).
        P : int ∈ {-1,1}, optional
            Sign convention. Defaults to -1.

        Returns
        -------
        new : damask.Rotation
            Rotation representing the given homochoric vector.
        """
        ho = np.array(h,dtype=float)
        if ho.shape[:-2:-1] != (3,): raise ValueError(f'invalid shape: {ho.shape}')
        if abs(P) != 1: raise ValueError('P ∉ {-1,1}')

        if P == 1: ho *= -1

        if np.any(np.linalg.norm(ho,axis=-1) > _R1+1.e-9):
            with np.printoptions(threshold=sys.maxsize,precision=16,floatmode='fixed'):
                raise ValueError(f'homochoric coordinate outside of the sphere\n{ho}')

        return Rotation(Rotation._ho2qu(ho))

    @staticmethod
    def from_cubochoric(x: np.ndarray,
                        P: Literal[1, -1] = -1) -> 'Rotation':
        """
        Initialize from cubochoric vector.

        Parameters
        ----------
        x : numpy.ndarray, shape (...,3)
            Cubochoric vector (x_1, x_2, x_3) with max(x_i) < 1/2 π^(2/3).
        P : int ∈ {-1,1}, optional
            Sign convention. Defaults to -1.

        Returns
        -------
        new : damask.Rotation
            Rotation representing the given cubochoric vector.
        """
        cu = np.array(x,dtype=float)
        if cu.shape[:-2:-1] != (3,): raise ValueError(f'invalid shape: {cu.shape}')
        if abs(P) != 1: raise ValueError('P ∉ {-1,1}')
        if np.abs(np.max(cu)) > np.pi**(2./3.) * 0.5+1.e-9:
            with np.printoptions(threshold=sys.maxsize,precision=16,floatmode='fixed'):
                raise ValueError(f'cubochoric coordinate outside of the cube\n{cu}')

        ho = Rotation._cu2ho(cu) if P == -1 else Rotation._cu2ho(cu) * -1

        return Rotation(Rotation._ho2qu(ho))


    @staticmethod
    def from_random(shape: Union[None, int, IntSequence] = None,
                    rng_seed: Optional[NumpyRngSeed] = None) -> 'Rotation':
        """
        Initialize with samples from a uniform distribution.

        Parameters
        ----------
        shape : (sequence of) int, optional
            Output shape. Defaults to None, which gives a scalar.
        rng_seed : {None, int, array_like[ints], SeedSequence, BitGenerator, Generator}, optional
            A seed to initialize the BitGenerator.
            Defaults to None, i.e. unpredictable entropy will be pulled from the OS.

        Returns
        -------
        new : damask.Rotation
            Random rotation of given shape.
        """
        rng = np.random.default_rng(rng_seed)
        r = rng.random(3 if shape is None else tuple(shape)+(3,) if hasattr(shape, '__iter__') else (shape,3))

        A = np.sqrt(r[...,2])
        B = np.sqrt(1.-r[...,2])
        q = np.stack([np.cos(2.*np.pi*r[...,0])*A,
                      np.sin(2.*np.pi*r[...,1])*B,
                      np.cos(2.*np.pi*r[...,1])*B,
                      np.sin(2.*np.pi*r[...,0])*A],axis=-1)

        return Rotation(q[:] if shape is None else q.reshape(r.shape[:-1]+(4,)))._standardize()


    @staticmethod
    def from_ODF(weights: np.ndarray,
                 phi: np.ndarray,
                 shape: Union[None, int, IntSequence] = None,
                 degrees: bool = False,
                 fractions: bool = True,
                 rng_seed: Optional[NumpyRngSeed] = None) -> 'Rotation':
        """
        Initialize with samples from a binned orientation distribution function (ODF).

        Parameters
        ----------
        weights : numpy.ndarray, shape (n)
            Texture intensity values (probability density or volume fraction) at Euler space grid points.
        phi : numpy.ndarray, shape (n,3)
            Grid coordinates in Euler space at which weights are defined.
        shape : (sequence of) int, optional
            Output shape. Defaults to None, which gives a scalar.
        degrees : bool, optional
            Euler space grid coordinates are in degrees. Defaults to False.
        fractions : bool, optional
            ODF values correspond to volume fractions, not probability densities.
            Defaults to True.
        rng_seed : {None, int, array_like[ints], SeedSequence, BitGenerator, Generator}, optional
            A seed to initialize the BitGenerator.
            Defaults to None, i.e. unpredictable entropy will be pulled from the OS.

        Returns
        -------
        new : damask.Rotation
            Rotation sampled from given ODF.

        Notes
        -----
        Due to the distortion of Euler space in the vicinity of ϕ = 0,
        probability densities, p, defined on grid points with ϕ = 0 will never
        result in reconstructed orientations as their dV/V = p dγ = p × 0.
        Hence, it is recommended to transform any such dataset to a
        cell-centered variant, which avoids grid points at ϕ = 0.

        References
        ----------
        P. Eisenlohr and F. Roters, Computational Materials Science 42(4):670-678, 2008
        https://doi.org/10.1016/j.commatsci.2007.09.015
        """
        def _dg(eu,deg):
            """Return infinitesimal Euler space volume of bin(s)."""
            phi_sorted = eu[np.lexsort((eu[:,0],eu[:,1],eu[:,2]))]
            steps,size,_ = grid_filters.cellsSizeOrigin_coordinates0_point(phi_sorted)
            delta = np.radians(size/steps) if deg else size/steps
            return delta[0]*2.*np.sin(delta[1]/2.)*delta[2] / 8. / np.pi**2 * np.sin(np.radians(eu[:,1]) if deg else eu[:,1])

        dg = 1. if fractions else _dg(phi,degrees)
        dV_V = dg * np.maximum(0.,weights.squeeze())

        N = 1 if shape is None else np.prod(shape).astype(int)
        return Rotation.from_Euler_angles(phi[util.hybrid_IA(dV_V,N,rng_seed)],degrees).reshape(() if shape is None else shape)


    @staticmethod
    def from_spherical_component(center: 'Rotation',
                                 sigma: float,
                                 shape: Union[None, int, IntSequence] = None,
                                 degrees: bool = False,
                                 rng_seed: Optional[NumpyRngSeed] = None) -> 'Rotation':
        """
        Initialize with samples from a Gaussian distribution around a given center.

        Parameters
        ----------
        center : Rotation or Orientation
            Central rotation.
        sigma : float
            Standard deviation of (Gaussian) misorientation distribution.
        shape : (sequence of) int, optional
            Output shape. Defaults to None, which gives a scalar.
        degrees : bool, optional
            Standard deviation is given in degrees. Defaults to False.
        rng_seed : {None, int, array_like[ints], SeedSequence, BitGenerator, Generator}, optional
            A seed to initialize the BitGenerator.
            Defaults to None, i.e. unpredictable entropy will be pulled from the OS.

        Returns
        -------
        new : damask.Orientation
            Rotation sampled from normal distribution around a center.

        Examples
        --------
        Create a brass texture consisting of
        200 orientations:

        >>> import damask
        >>> center = damask.Rotation.from_Euler_angles(phi=[35.,45.,0.],degrees=True)
        >>> brass = damask.Rotation.from_spherical_component(center=center,sigma=3.,shape=200,degrees=True)

        Create a Goss texture consisting of
        100 orientations:

        >>> import damask
        >>> center = damask.Rotation.from_Euler_angles(phi=[0.,45.,0.],degrees=True)
        >>> goss = damask.Rotation.from_spherical_component(center=center,sigma=3.,shape=100,degrees=True)
        """
        rng = np.random.default_rng(rng_seed)
        sigma = np.radians(sigma) if degrees else sigma
        N = 1 if shape is None else np.prod(shape)
        u,Theta  = (rng.random((N,2)) * 2. * np.array([1.,np.pi]) - np.array([1.,0.])).T
        omega = abs(rng.normal(scale=sigma,size=N))
        p = np.column_stack([np.sqrt(1.-u**2)*np.cos(Theta),
                             np.sqrt(1.-u**2)*np.sin(Theta),
                             u, omega])

        return Rotation.from_axis_angle(p).reshape(() if shape is None else shape) * center


    @staticmethod
    def from_fiber_component(crystal: IntSequence,
                             sample: IntSequence,
                             sigma: float = 0.,
                             shape: Union[None, int, IntSequence] = None,
                             degrees: bool = False,
                             rng_seed: Optional[NumpyRngSeed] = None) -> 'Rotation':
        """
        Initialize with samples from a Gaussian distribution around a given direction.

        Parameters
        ----------
        crystal : numpy.ndarray, shape (2)
            Polar coordinates (polar angle θ from [0 0 1], azimuthal angle φ from [1 0 0])
            of fiber direction in crystal frame.
        sample : numpy.ndarray, shape (2)
            Polar coordinates (polar angle θ from z, azimuthal angle φ from x)
            of fiber direction in sample frame.
        sigma : float, optional
            Standard deviation of (Gaussian) misorientation distribution.
            Defaults to 0.
        shape : (sequence of) int, optional
            Output shape. Defaults to None, which gives a scalar.
        degrees : bool, optional
            Standard deviation and polar coordinates are given in degrees.
            Defaults to False.
        rng_seed : {None, int, array_like[ints], SeedSequence, BitGenerator, Generator}, optional
            A seed to initialize the BitGenerator.
            Defaults to None, i.e. unpredictable entropy will be pulled from the OS.

        Returns
        -------
        new : damask.Rotation
            Rotation sampled from normal distribution around a direction.

        Notes
        -----
        The crystal direction for (θ=0,φ=0) is [0 0 1];
        the sample direction for (θ=0,φ=0) is z.

        Polar coordinates follow the ISO 80000-2:2019 convention
        typically used in physics.
        See https://en.wikipedia.org/wiki/Spherical_coordinate_system.

        Ranges 0≤θ≤π and 0≤φ≤2π give a unique set of coordinates.

        The value for sigma should be small enough to avoid values
        larger than π when sampling from a normal distribution.
        A typically safe value is σ = π/5 ≈ 36°.

        Examples
        --------
        Create an ideal bcc α-fiber texture ([1 0 1] ǀǀ x=RD) consisting of 600 orientations:

        >>> import damask
        >>> import numpy as np
        >>> alpha = damask.Rotation.from_fiber_component(crystal=[np.pi/4.,0.],sample=[np.pi/2.,0.],
        ...                                              shape=600)

        Create an ideal γ-fiber texture ([1 1 1] ǀǀ z=ND) consisting of 250 orientations:

        >>> import damask
        >>> gamma = damask.Rotation.from_fiber_component(crystal=[54.736,45.],sample=[0.,0.],
        ...                                              shape=250,degrees=True)

        Create a relatively strong basal texture ([0 0 0 1] ǀǀ z=ND) consisting of 320 orientations:

        >>> import damask
        >>> basal = damask.Rotation.from_fiber_component(crystal=[0.,0.],sample=[0.,0.],
        ...                                              shape=320,sigma=.15)
        """
        rng = np.random.default_rng(rng_seed)
        sigma_,alpha,beta = (np.radians(c) for c in (sigma,crystal,sample)) if degrees else \
                             map(np.asarray, (sigma,crystal,sample))

        d_cr  = np.array([np.sin(alpha[0])*np.cos(alpha[1]), np.sin(alpha[0])*np.sin(alpha[1]), np.cos(alpha[0])])
        d_lab = np.array([np.sin( beta[0])*np.cos( beta[1]), np.sin( beta[0])*np.sin( beta[1]), np.cos( beta[0])])
        ax_align = np.append(np.cross(d_cr,d_lab), np.arccos(np.dot(d_cr,d_lab)))                   # align crystal frame direction to sample frame direction
        if np.isclose(ax_align[3],0.): ax_align[:3] = np.array([1.,0.,0.])
        R_align = Rotation.from_axis_angle(ax_align if ax_align[3] > 0. else -ax_align,normalize=True)

        N = 1 if shape is None else np.prod(shape).astype(int)
        u,Theta = (rng.random((N,2)) * 2. * np.array([1.,np.pi]) - np.array([1.,0.])).T
        omega = abs(rng.normal(scale=sigma_,size=N))
        p = np.column_stack([np.sqrt(1.-u**2)*np.cos(Theta),
                             np.sqrt(1.-u**2)*np.sin(Theta),
                             u, omega])
        p[:,:3] = np.einsum('ij,...j',np.eye(3)-np.outer(d_lab,d_lab),p[:,:3])                      # remove component along fiber axis
        f = np.column_stack((np.broadcast_to(d_lab,(N,3)),rng.random(N)*np.pi))
        f[::2,:3] *= -1.                                                                            # flip half the rotation axes to negative sense

        return (R_align.broadcast_to(N)
                * Rotation.from_axis_angle(p,normalize=True)
                * Rotation.from_axis_angle(f)).reshape(() if shape is None else shape)


####################################################################################################
# Code below available according to the following conditions on https://github.com/MarDiehl/3Drotations
####################################################################################################
# Copyright (c) 2017-2020, Martin Diehl/Max-Planck-Institut für Eisenforschung GmbH
# Copyright (c) 2013-2014, Marc De Graef/Carnegie Mellon University
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without modification, are
# permitted provided that the following conditions are met:
#
#     - Redistributions of source code must retain the above copyright notice, this list
#        of conditions and the following disclaimer.
#     - Redistributions in binary form must reproduce the above copyright notice, this
#        list of conditions and the following disclaimer in the documentation and/or
#        other materials provided with the distribution.
#     - Neither the names of Marc De Graef, Carnegie Mellon University nor the names
#        of its contributors may be used to endorse or promote products derived from
#        this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
# USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
####################################################################################################
    #---------- Quaternion ----------
    @staticmethod
    def _qu2om(qu: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
        """
        Quaternion to rotation matrix.

        Parameters
        ----------
        qu : numpy.ndarray, shape (...,4)
            Unit quaternion (q_0, q_1, q_2, q_3) in positive real hemisphere, i.e. ǀqǀ = 1 and q_0 ≥ 0.

        Returns
        -------
        om : numpy.ndarray, shape (...,3,3)
            Rotation matrix with det(R) = 1 and R.T ∙ R = I.

        References
        ----------
        E. Bernardes and S. Viollet, PLoS ONE 17(11):e0276302, 2022
        https://doi.org/10.1371/journal.pone.0276302
        """
        qq = qu[...,0]**2-(qu[...,1]**2 + qu[...,2]**2 + qu[...,3]**2)
        om = np.stack([qq + 2.*qu[...,1]**2,
                       2.*(qu[...,2]*qu[...,1]-_P*qu[...,0]*qu[...,3]),
                       2.*(qu[...,3]*qu[...,1]+_P*qu[...,0]*qu[...,2]),
                       2.*(qu[...,1]*qu[...,2]+_P*qu[...,0]*qu[...,3]),
                       qq + 2.*qu[...,2]**2,
                       2.*(qu[...,3]*qu[...,2]-_P*qu[...,0]*qu[...,1]),
                       2.*(qu[...,1]*qu[...,3]-_P*qu[...,0]*qu[...,2]),
                       2.*(qu[...,2]*qu[...,3]+_P*qu[...,0]*qu[...,1]),
                       qq + 2.*qu[...,3]**2,
                      ],axis=-1).reshape(qu.shape[:-1]+(3,3))
        return om

    @staticmethod
    def _qu2eu(qu: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
        """
        Quaternion to Bunge Euler angles.

        Parameters
        ----------
        qu : numpy.ndarray, shape (...,4)
            Unit quaternion (q_0, q_1, q_2, q_3) in positive real hemisphere, i.e. ǀqǀ = 1 and q_0 ≥ 0.

        Returns
        -------
        eu : numpy.ndarray, shape (...,3)
            Euler angles (φ_1 ∈ [0,2π], ϕ ∈ [0,π], φ_2 ∈ [0,2π]).

        References
        ----------
        E. Bernardes and S. Viollet, PLoS ONE 17(11):e0276302, 2022
        https://doi.org/10.1371/journal.pone.0276302
        """
        a =     qu[...,0]
        b = -_P*qu[...,3]
        c = -_P*qu[...,1]
        d = -_P*qu[...,2]

        eu = np.stack([np.arctan2(b,a),
                       np.arccos(2*(a**2+b**2)/(a**2+b**2+c**2+d**2)-1),
                       np.arctan2(-d,c),
                      ],axis=-1)

        eu_sum  = eu[...,0] + eu[...,2]
        eu_diff = eu[...,0] - eu[...,2]

        is_zero = np.isclose(eu[...,1],0.0)
        is_pi   = np.isclose(eu[...,1],np.pi)
        is_ok   = ~np.logical_or(is_zero,is_pi)

        eu[...,0][is_zero] =  2*eu[...,0][is_zero]
        eu[...,0][is_pi]   = -2*eu[...,2][is_pi]
        eu[...,2][~is_ok]  = 0.0
        eu[...,0][is_ok]   = eu_diff[is_ok]
        eu[...,2][is_ok]   = eu_sum [is_ok]

        eu[np.logical_or(np.abs(eu)         < 1.e-6,
                         np.abs(eu-2*np.pi) < 1.e-6)] = 0.
        return np.where(eu < 0., eu%(np.pi*np.array([2.,1.,2.])),eu)

    @staticmethod
    def _qu2ax(qu: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
        """
        Quaternion to axis–angle pair.

        Modified version of the original formulation, should be numerically more stable.

        Parameters
        ----------
        qu : numpy.ndarray, shape (...,4)
            Unit quaternion (q_0, q_1, q_2, q_3) in positive real hemisphere, i.e. ǀqǀ = 1 and q_0 ≥ 0.

        Returns
        -------
        ax : numpy.ndarray, shape (...,4)
            Axis and angle (n_1, n_2, n_3, ω) with ǀnǀ = 1 and ω ∈ [0,π].
        """
        with np.errstate(invalid='ignore',divide='ignore'):
            s = np.sign(qu[...,0:1])/np.sqrt(qu[...,1:2]**2+qu[...,2:3]**2+qu[...,3:4]**2)
            omega = 2. * np.arccos(np.clip(qu[...,0:1],-1.,1.))
            ax = np.where(qu[...,0:1] < 1.e-8,
                          np.block([qu[...,1:4],np.broadcast_to(np.pi,qu[...,0:1].shape)]),
                          np.block([qu[...,1:4]*s,omega]))
        ax[np.isclose(qu[...,0],1.,rtol=0.)] = np.array([0.,0.,1.,0.])
        return ax

    @staticmethod
    def _qu2ro(qu: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
        """
        Quaternion to Rodrigues–Frank vector.

        Parameters
        ----------
        qu : numpy.ndarray, shape (...,4)
            Unit quaternion (q_0, q_1, q_2, q_3) in positive real hemisphere, i.e. ǀqǀ = 1 and q_0 ≥ 0.

        Returns
        -------
        ro : numpy.ndarray, shape (...,4)
            Rodrigues–Frank vector [n_1, n_2, n_3, tan(ω/2)] with ǀnǀ = 1 and ω ∈ [0,π].
        """
        with np.errstate(invalid='ignore',divide='ignore'):
            s  = np.linalg.norm(qu[...,1:4],axis=-1,keepdims=True)
            ro = np.where(np.abs(qu[...,0:1]) < 1.e-12,
                          np.block([qu[...,1:4],  np.broadcast_to(np.inf,qu[...,0:1].shape)]),
                          np.block([qu[...,1:4]/s,np.tan(np.arccos(np.clip(qu[...,0:1],-1.,1.)))]))
        ro[np.abs(s).squeeze(-1) < 1.e-12] = np.array([0.,0.,_P,0.])
        return ro

    @staticmethod
    def _qu2ho(qu: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
        """
        Quaternion to homochoric vector.

        Parameters
        ----------
        qu : numpy.ndarray, shape (...,4)
            Unit quaternion (q_0, q_1, q_2, q_3) in positive real hemisphere, i.e. ǀqǀ = 1 and q_0 ≥ 0.

        Returns
        -------
        ho : numpy.ndarray, shape (...,3)
            Homochoric vector (h_1, h_2, h_3) with ǀhǀ < (3π/4)^(1/3).
        """
        with np.errstate(invalid='ignore'):
            omega = 2. * np.arccos(np.clip(qu[...,0:1],-1.,1.))
            ho = np.where(np.abs(omega) < 1.e-12,
                          0.0,
                          qu[...,1:4]/np.linalg.norm(qu[...,1:4],axis=-1,keepdims=True)
                          * (0.75*(omega - np.sin(omega)))**(1./3.))
        return ho

    @staticmethod
    def _qu2cu(qu: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
        """
        Quaternion to cubochoric vector.

        Parameters
        ----------
        qu : numpy.ndarray, shape (...,4)
            Unit quaternion (q_0, q_1, q_2, q_3) in positive real hemisphere, i.e. ǀqǀ = 1 and q_0 ≥ 0.

        Returns
        -------
        cu : numpy.ndarray, shape (...,3)
            Cubochoric vector (x_1, x_2, x_3) with max(x_i) < 1/2 π^(2/3).
        """
        return Rotation._ho2cu(Rotation._qu2ho(qu))


    #---------- Rotation matrix ----------
    @staticmethod
    def _om2qu(om: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
        """
        Rotation matrix to quaternion.

        This formulation is from  www.euclideanspace.com/maths/geometry/rotations/conversions/matrixToQuaternion.
        The original formulation had issues.

        Parameters
        ----------
        om : numpy.ndarray, shape (...,3,3)
            Rotation matrix with det(R) = 1 and R.T ∙ R = I.

        Returns
        -------
        qu : numpy.ndarray, shape (...,4)
            Unit quaternion (q_0, q_1, q_2, q_3) in positive real hemisphere, i.e. ǀqǀ = 1 and q_0 ≥ 0.
        """
        trace = om[...,0,0] + om[...,1,1] + om[...,2,2]

        with np.errstate(invalid='ignore',divide='ignore'):
            s = np.array([
                 0.5 / np.sqrt( 1. + trace),
                 2.  * np.sqrt( 1. + om[...,0,0] - om[...,1,1] - om[...,2,2]),
                 2.  * np.sqrt( 1. + om[...,1,1] - om[...,2,2] - om[...,0,0]),
                 2.  * np.sqrt( 1. + om[...,2,2] - om[...,0,0] - om[...,1,1] )
                ])

            qu = np.select(
                [(trace > 0)[...,np.newaxis],
                 (om[...,0,0] > np.maximum(om[...,1,1], om[...,2,2]))[...,np.newaxis],
                 (om[...,1,1] > om[...,2,2])[...,np.newaxis]],
                [np.stack([0.25 / s[0],
                           (om[...,2,1] - om[...,1,2]) * s[0],
                           (om[...,0,2] - om[...,2,0]) * s[0],
                           (om[...,1,0] - om[...,0,1]) * s[0]], axis=-1),
                 np.stack([(om[...,2,1] - om[...,1,2]) / s[1],
                           0.25 * s[1],
                           (om[...,0,1] + om[...,1,0]) / s[1],
                           (om[...,0,2] + om[...,2,0]) / s[1]], axis=-1),
                 np.stack([(om[...,0,2] - om[...,2,0]) / s[2],
                           (om[...,0,1] + om[...,1,0]) / s[2],
                           0.25 * s[2],
                           (om[...,1,2] + om[...,2,1]) / s[2]], axis=-1)],
                default=np.stack([(om[...,1,0] - om[...,0,1]) / s[3],
                                  (om[...,0,2] + om[...,2,0]) / s[3],
                                  (om[...,1,2] + om[...,2,1]) / s[3],
                                  0.25 * s[3]], axis=-1)
                          )*np.array([1.,_P,_P,_P])
            qu[qu[...,0] < 0.] *= -1.
        return qu

    @staticmethod
    def _om2eu(om: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
        """
        Rotation matrix to Bunge Euler angles.

        Parameters
        ----------
        om : numpy.ndarray, shape (...,3,3)
            Rotation matrix with det(R) = 1 and R.T ∙ R = I.

        Returns
        -------
        eu : numpy.ndarray, shape (...,3)
            Euler angles (φ_1 ∈ [0,2π], ϕ ∈ [0,π], φ_2 ∈ [0,2π]).
        """
        with np.errstate(invalid='ignore',divide='ignore'):
            zeta = 1./np.sqrt(1.-om[...,2,2]**2)
            eu = np.where(np.isclose(np.abs(om[...,2,2]),1.,0.)[...,np.newaxis],
                          np.stack([np.arctan2(om[...,0,1],om[...,0,0]),
                                    np.pi*0.5*(1.-om[...,2,2]),
                                    np.zeros(om.shape[:-2]),
                                   ], axis=-1),
                          np.stack([np.arctan2(om[...,2,0]*zeta,-om[...,2,1]*zeta),
                                    np.arccos( om[...,2,2]),
                                    np.arctan2(om[...,0,2]*zeta,+om[...,1,2]*zeta)
                                   ], axis=-1)
                          )
        eu[np.abs(eu) < 1.e-8] = 0.0
        return np.where(eu < 0., eu%(np.pi*np.array([2.,1.,2.])),eu)

    @staticmethod
    def _om2ax(om: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
        """
        Rotation matrix to axis–angle pair.

        Parameters
        ----------
        om : numpy.ndarray, shape (...,3,3)
            Rotation matrix with det(R) = 1 and R.T ∙ R = I.

        Returns
        -------
        ax : numpy.ndarray, shape (...,4)
            Axis and angle (n_1, n_2, n_3, ω) with ǀnǀ = 1 and ω ∈ [0,π].
        """
        diag_delta = -_P*np.stack([om[...,1,2]-om[...,2,1],
                                   om[...,2,0]-om[...,0,2],
                                   om[...,0,1]-om[...,1,0]
                                  ], axis=-1)
        t = 0.5*(om.trace(axis2=-2,axis1=-1) -1.).reshape(om.shape[:-2]+(1,))
        w,vr = np.linalg.eig(om)
        # mask duplicated real eigenvalues
        w[np.isclose(w[...,0],1.+0.j),1:] = 0.
        w[np.isclose(w[...,1],1.+0.j),2:] = 0.
        vr = np.swapaxes(vr,-1,-2)
        ax = np.where(np.abs(diag_delta)<1.e-13,
                             np.real(vr[np.isclose(w,1.+0.j)]).reshape(om.shape[:-2]+(3,)),
                      np.abs(np.real(vr[np.isclose(w,1.+0.j)]).reshape(om.shape[:-2]+(3,)))
                      *np.sign(diag_delta))
        ax = np.block([ax,np.arccos(np.clip(t,-1.,1.))])
        ax[np.abs(ax[...,3]) < 1.e-8] = np.array([0.,0.,1.,0.])
        return ax

    @staticmethod
    def _om2ro(om: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
        """
        Rotation matrix to Rodrigues–Frank vector.

        Parameters
        ----------
        om : numpy.ndarray, shape (...,3,3)
            Rotation matrix with det(R) = 1 and R.T ∙ R = I.

        Returns
        -------
        ro : numpy.ndarray, shape (...,4)
            Rodrigues–Frank vector [n_1, n_2, n_3, tan(ω/2)] with ǀnǀ = 1 and ω ∈ [0,π].
        """
        return Rotation._eu2ro(Rotation._om2eu(om))

    @staticmethod
    def _om2ho(om: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
        """
        Rotation matrix to homochoric vector.

        Parameters
        ----------
        om : numpy.ndarray, shape (...,3,3)
            Rotation matrix with det(R) = 1 and R.T ∙ R = I.

        Returns
        -------
        ho : numpy.ndarray, shape (...,3)
            Homochoric vector (h_1, h_2, h_3) with ǀhǀ < (3π/4)^(1/3).
        """
        return Rotation._ax2ho(Rotation._om2ax(om))

    @staticmethod
    def _om2cu(om: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
        """
        Rotation matrix to cubochoric vector.

        Parameters
        ----------
        om : numpy.ndarray, shape (...,3,3)
            Rotation matrix with det(R) = 1 and R.T ∙ R = I.

        Returns
        -------
        cu : numpy.ndarray, shape (...,3)
            Cubochoric vector (x_1, x_2, x_3) with max(x_i) < 1/2 π^(2/3).
        """
        return Rotation._ho2cu(Rotation._om2ho(om))


    #---------- Bunge Euler angles ----------
    @staticmethod
    def _eu2qu(eu: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
        """
        Bunge Euler angles to quaternion.

        Parameters
        ----------
        eu : numpy.ndarray, shape (...,3)
            Euler angles (φ_1 ∈ [0,2π], ϕ ∈ [0,π], φ_2 ∈ [0,2π]).

        Returns
        -------
        qu : numpy.ndarray, shape (...,4)
            Unit quaternion (q_0, q_1, q_2, q_3) in positive real hemisphere, i.e. ǀqǀ = 1 and q_0 ≥ 0.
        """
        ee = 0.5*eu
        cPhi = np.cos(ee[...,1])
        sPhi = np.sin(ee[...,1])
        qu = np.stack([    cPhi*np.cos(ee[...,0]+ee[...,2]),
                       -_P*sPhi*np.cos(ee[...,0]-ee[...,2]),
                       -_P*sPhi*np.sin(ee[...,0]-ee[...,2]),
                       -_P*cPhi*np.sin(ee[...,0]+ee[...,2])], axis=-1)
        qu[qu[...,0] < 0.] *= -1.
        return qu

    @staticmethod
    def _eu2om(eu: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
        """
        Bunge Euler angles to rotation matrix.

        Parameters
        ----------
        eu : numpy.ndarray, shape (...,3)
            Euler angles (φ_1 ∈ [0,2π], ϕ ∈ [0,π], φ_2 ∈ [0,2π]).

        Returns
        -------
        om : numpy.ndarray, shape (...,3,3)
            Rotation matrix with det(R) = 1 and R.T ∙ R = I.
        """
        c = np.cos(eu)
        s = np.sin(eu)
        om = np.stack([+c[...,0]*c[...,2]-s[...,0]*s[...,2]*c[...,1],
                       +s[...,0]*c[...,2]+c[...,0]*s[...,2]*c[...,1],
                       +s[...,2]*s[...,1],
                       -c[...,0]*s[...,2]-s[...,0]*c[...,2]*c[...,1],
                       -s[...,0]*s[...,2]+c[...,0]*c[...,2]*c[...,1],
                       +c[...,2]*s[...,1],
                       +s[...,0]*s[...,1],
                       -c[...,0]*s[...,1],
                       +c[...,1]
                       ],axis=-1).reshape(eu.shape[:-1]+(3,3))
        om[np.abs(om) < 1.e-12] = 0.
        return om

    @staticmethod
    def _eu2ax(eu: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
        """
        Bunge Euler angles to axis–angle pair.

        Parameters
        ----------
        eu : numpy.ndarray, shape (...,3)
            Euler angles (φ_1 ∈ [0,2π], ϕ ∈ [0,π], φ_2 ∈ [0,2π]).

        Returns
        -------
        ax : numpy.ndarray, shape (...,4)
            Axis and angle (n_1, n_2, n_3, ω) with ǀnǀ = 1 and ω ∈ [0,π].
        """
        t = np.tan(eu[...,1]*0.5)
        sigma = 0.5*(eu[...,0]+eu[...,2])
        delta = 0.5*(eu[...,0]-eu[...,2])
        tau   = np.linalg.norm(np.stack([t,np.sin(sigma)],axis=-1),axis=-1)
        alpha = np.where(np.abs(np.cos(sigma))<1.e-12,np.pi,2.*np.arctan(tau/np.cos(sigma)))
        with np.errstate(invalid='ignore',divide='ignore'):
            ax = np.where((np.abs(alpha)<1.e-12)[...,np.newaxis],
                          [0.,0.,1.,0.],
                          np.stack([-_P/tau*t*np.cos(delta),
                                    -_P/tau*t*np.sin(delta),
                                    -_P/tau*  np.sin(sigma),
                                     alpha
                                   ], axis=-1))
        ax[(alpha<0.).squeeze()] *= -1.
        return ax

    @staticmethod
    def _eu2ro(eu: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
        """
        Bunge Euler angles to Rodrigues–Frank vector.

        Parameters
        ----------
        eu : numpy.ndarray, shape (...,3)
            Euler angles (φ_1 ∈ [0,2π], ϕ ∈ [0,π], φ_2 ∈ [0,2π]).

        Returns
        -------
        ro : numpy.ndarray, shape (...,4)
            Rodrigues–Frank vector [n_1, n_2, n_3, tan(ω/2)] with ǀnǀ = 1 and ω ∈ [0,π].
        """
        ax = Rotation._eu2ax(eu)
        ro = np.block([ax[...,:3],np.tan(ax[...,3:4]*.5)])
        ro[ax[...,3] >= np.pi,3] = np.inf
        ro[np.abs(ax[...,3])<1.e-16] = np.array([0.,0.,_P,0.])
        return ro

    @staticmethod
    def _eu2ho(eu: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
        """
        Bunge Euler angles to homochoric vector.

        Parameters
        ----------
        eu : numpy.ndarray, shape (...,3)
            Euler angles (φ_1 ∈ [0,2π], ϕ ∈ [0,π], φ_2 ∈ [0,2π]).

        Returns
        -------
        ho : numpy.ndarray, shape (...,3)
            Homochoric vector (h_1, h_2, h_3) with ǀhǀ < (3π/4)^(1/3).
        """
        return Rotation._ax2ho(Rotation._eu2ax(eu))

    @staticmethod
    def _eu2cu(eu: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
        """
        Bunge Euler angles to cubochoric vector.

        Parameters
        ----------
        eu : numpy.ndarray, shape (...,3)
            Euler angles (φ_1 ∈ [0,2π], ϕ ∈ [0,π], φ_2 ∈ [0,2π]).

        Returns
        -------
        cu : numpy.ndarray, shape (...,3)
            Cubochoric vector (x_1, x_2, x_3) with max(x_i) < 1/2 π^(2/3).
        """
        return Rotation._ho2cu(Rotation._eu2ho(eu))


    #---------- Axis angle pair ----------
    @staticmethod
    def _ax2qu(ax: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
        """
        Axis–angle pair to quaternion.

        Parameters
        ----------
        ax : numpy.ndarray, shape (...,4)
            Axis and angle (n_1, n_2, n_3, ω) with ǀnǀ = 1 and ω ∈ [0,π].

        Returns
        -------
        qu : numpy.ndarray, shape (...,4)
            Unit quaternion (q_0, q_1, q_2, q_3) in positive real hemisphere, i.e. ǀqǀ = 1 and q_0 ≥ 0.
        """
        qu = np.where(np.abs(ax[...,3:4]) < 1.e-6,
                      [1.,0.,0.,0.],
                      np.block([np.cos(ax[...,3:4]*.5),ax[...,:3]*np.sin(ax[...,3:4]*.5)]))
        return qu

    @staticmethod
    def _ax2om(ax: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
        """
        Axis-angle pair to rotation matrix.

        Parameters
        ----------
        ax : numpy.ndarray, shape (...,4)
            Axis and angle (n_1, n_2, n_3, ω) with ǀnǀ = 1 and ω ∈ [0,π].

        Returns
        -------
        om : numpy.ndarray, shape (...,3,3)
            Rotation matrix with det(R) = 1 and R.T ∙ R = I.
        """
        c = np.cos(ax[...,3])
        s = np.sin(ax[...,3])
        omc = 1.-c
        om = np.stack([c+omc*ax[...,0]**2,
                         omc*ax[...,0]*ax[...,1] + s*ax[...,2],
                         omc*ax[...,0]*ax[...,2] - s*ax[...,1],
                         omc*ax[...,0]*ax[...,1] - s*ax[...,2],
                       c+omc*ax[...,1]**2,
                         omc*ax[...,1]*ax[...,2] + s*ax[...,0],
                         omc*ax[...,0]*ax[...,2] + s*ax[...,1],
                         omc*ax[...,1]*ax[...,2] - s*ax[...,0],
                       c+omc*ax[...,2]**2],axis=-1).reshape(ax.shape[:-1]+(3,3))
        return om if _P < 0. else np.swapaxes(om,-1,-2)

    @staticmethod
    def _ax2eu(ax: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
        """
        Rotation matrix to Bunge Euler angles.

        Parameters
        ----------
        ax : numpy.ndarray, shape (...,4)
            Axis and angle (n_1, n_2, n_3, ω) with ǀnǀ = 1 and ω ∈ [0,π].

        Returns
        -------
        eu : numpy.ndarray, shape (...,3)
            Euler angles (φ_1 ∈ [0,2π], ϕ ∈ [0,π], φ_2 ∈ [0,2π]).
        """
        return Rotation._om2eu(Rotation._ax2om(ax))

    @staticmethod
    def _ax2ro(ax: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
        """
        Axis–angle pair to Rodrigues–Frank vector.

        Parameters
        ----------
        ax : numpy.ndarray, shape (...,4)
            Axis and angle (n_1, n_2, n_3, ω) with ǀnǀ = 1 and ω ∈ [0,π].

        Returns
        -------
        ro : numpy.ndarray, shape (...,4)
            Rodrigues–Frank vector [n_1, n_2, n_3, tan(ω/2)] with ǀnǀ = 1 and ω ∈ [0,π].
        """
        ro = np.block([ax[...,:3],
                       np.where(np.isclose(ax[...,3:4],np.pi,atol=1.e-15,rtol=.0),
                                np.inf,
                                np.tan(ax[...,3:4]*0.5))
                      ])
        ro[np.abs(ax[...,3]) < 1.e-6] = np.array([.0,.0,_P,.0])
        return ro

    @staticmethod
    def _ax2ho(ax: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
        """
        Axis–angle pair to homochoric vector.

        Parameters
        ----------
        ax : numpy.ndarray, shape (...,4)
            Axis and angle (n_1, n_2, n_3, ω) with ǀnǀ = 1 and ω ∈ [0,π].

        Returns
        -------
        ho : numpy.ndarray, shape (...,3)
            Homochoric vector (h_1, h_2, h_3) with ǀhǀ < (3π/4)^(1/3).
        """
        f = (0.75 * ( ax[...,3:4] - np.sin(ax[...,3:4]) ))**(1./3.)
        return ax[...,:3] * f

    @staticmethod
    def _ax2cu(ax: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
        """
        Axis–angle pair to cubochoric vector.

        Parameters
        ----------
        ax : numpy.ndarray, shape (...,4)
            Axis and angle (n_1, n_2, n_3, ω) with ǀnǀ = 1 and ω ∈ [0,π].

        Returns
        -------
        cu : numpy.ndarray, shape (...,3)
            Cubochoric vector (x_1, x_2, x_3) with max(x_i) < 1/2 π^(2/3).
        """
        return Rotation._ho2cu(Rotation._ax2ho(ax))


    #---------- Rodrigues-Frank vector ----------
    @staticmethod
    def _ro2qu(ro: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
        """
        Rodrigues–Frank vector to quaternion.

        Parameters
        ----------
        ro : numpy.ndarray, shape (...,4)
            Rodrigues–Frank vector [n_1, n_2, n_3, tan(ω/2)] with ǀnǀ = 1 and ω ∈ [0,π].

        Returns
        -------
        qu : numpy.ndarray, shape (...,4)
            Unit quaternion (q_0, q_1, q_2, q_3) in positive real hemisphere, i.e. ǀqǀ = 1 and q_0 ≥ 0.
        """
        return Rotation._ax2qu(Rotation._ro2ax(ro))

    @staticmethod
    def _ro2om(ro: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
        """
        Rodgrigues–Frank vector to rotation matrix.

        Parameters
        ----------
        ro : numpy.ndarray, shape (...,4)
            Rodrigues–Frank vector [n_1, n_2, n_3, tan(ω/2)] with ǀnǀ = 1 and ω ∈ [0,π].

        Returns
        -------
        om : numpy.ndarray, shape (...,3,3)
            Rotation matrix with det(R) = 1 and R.T ∙ R = I.
        """
        return Rotation._ax2om(Rotation._ro2ax(ro))

    @staticmethod
    def _ro2eu(ro: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
        """
        Rodrigues–Frank vector to Bunge Euler angles.

        Parameters
        ----------
        ro : numpy.ndarray, shape (...,4)
            Rodrigues–Frank vector [n_1, n_2, n_3, tan(ω/2)] with ǀnǀ = 1 and ω ∈ [0,π].

        Returns
        -------
        eu : numpy.ndarray, shape (...,3)
            Euler angles (φ_1 ∈ [0,2π], ϕ ∈ [0,π], φ_2 ∈ [0,2π]).
        """
        return Rotation._om2eu(Rotation._ro2om(ro))

    @staticmethod
    def _ro2ax(ro: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
        """
        Rodrigues–Frank vector to axis–angle pair.

        Parameters
        ----------
        ro : numpy.ndarray, shape (...,4)
            Rodrigues–Frank vector [n_1, n_2, n_3, tan(ω/2)] with ǀnǀ = 1 and ω ∈ [0,π].

        Returns
        -------
        ax : numpy.ndarray, shape (...,4)
            Axis and angle (n_1, n_2, n_3, ω) with ǀnǀ = 1 and ω ∈ [0,π].
        """
        with np.errstate(invalid='ignore',divide='ignore'):
            ax = np.where(np.isfinite(ro[...,3:4]),
                 np.block([ro[...,0:3]*np.linalg.norm(ro[...,0:3],axis=-1,keepdims=True),2.*np.arctan(ro[...,3:4])]),
                 np.block([ro[...,0:3],np.broadcast_to(np.pi,ro[...,3:4].shape)]))
        ax[np.abs(ro[...,3]) < 1.e-8] = np.array([0.,0.,1.,0.])
        return ax

    @staticmethod
    def _ro2ho(ro: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
        """
        Rodrigues–Frank vector to homochoric vector.

        Parameters
        ----------
        ro : numpy.ndarray, shape (...,4)
            Rodrigues–Frank vector [n_1, n_2, n_3, tan(ω/2)] with ǀnǀ = 1 and ω ∈ [0,π].

        Returns
        -------
        ho : numpy.ndarray, shape (...,3)
            Homochoric vector (h_1, h_2, h_3) with ǀhǀ < (3π/4)^(1/3).
        """
        f = np.where(np.isfinite(ro[...,3:4]),2.*np.arctan(ro[...,3:4]) -np.sin(2.*np.arctan(ro[...,3:4])),np.pi)
        return np.where(np.sum(ro[...,0:3]**2,axis=-1,keepdims=True) < 1.e-8,
                        0.0,
                        ro[...,0:3]*(0.75*f)**(1./3.))

    @staticmethod
    def _ro2cu(ro: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
        """
        Rodrigues–Frank vector to cubochoric vector.

        Parameters
        ----------
        ro : numpy.ndarray, shape (...,4)
            Rodrigues–Frank vector [n_1, n_2, n_3, tan(ω/2)] with ǀnǀ = 1 and ω ∈ [0,π].

        Returns
        -------
        cu : numpy.ndarray, shape (...,3)
            Cubochoric vector (x_1, x_2, x_3) with max(x_i) < 1/2 π^(2/3).
        """
        return Rotation._ho2cu(Rotation._ro2ho(ro))


    #---------- Homochoric vector----------
    @staticmethod
    def _ho2qu(ho: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
        """
        Homochoric vector to quaternion.

        Parameters
        ----------
        ho : numpy.ndarray, shape (...,3)
            Homochoric vector (h_1, h_2, h_3) with ǀhǀ < (3π/4)^(1/3).

        Returns
        -------
        qu : numpy.ndarray, shape (...,4)
            Unit quaternion (q_0, q_1, q_2, q_3) in positive real hemisphere, i.e. ǀqǀ = 1 and q_0 ≥ 0.
        """
        return Rotation._ax2qu(Rotation._ho2ax(ho))

    @staticmethod
    def _ho2om(ho: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
        """
        Homochoric vector to rotation matrix.

        Parameters
        ----------
        ho : numpy.ndarray, shape (...,3)
            Homochoric vector (h_1, h_2, h_3) with ǀhǀ < (3π/4)^(1/3).

        Returns
        -------
        om : numpy.ndarray, shape (...,3,3)
            Rotation matrix with det(R) = 1 and R.T ∙ R = I.
        """
        return Rotation._ax2om(Rotation._ho2ax(ho))

    @staticmethod
    def _ho2eu(ho: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
        """
        Homochoric vector to Bunge Euler angles.

        Parameters
        ----------
        ho : numpy.ndarray, shape (...,3)
            Homochoric vector (h_1, h_2, h_3) with ǀhǀ < (3π/4)^(1/3).

        Returns
        -------
        eu : numpy.ndarray, shape (...,3)
            Euler angles (φ_1 ∈ [0,2π], ϕ ∈ [0,π], φ_2 ∈ [0,2π]).
        """
        return Rotation._ax2eu(Rotation._ho2ax(ho))

    @staticmethod
    def _ho2ax(ho: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
        """
        Homochoric vector to axis–angle pair.

        Parameters
        ----------
        ho : numpy.ndarray, shape (...,3)
            Homochoric vector (h_1, h_2, h_3) with ǀhǀ < (3π/4)^(1/3).

        Returns
        -------
        ax : numpy.ndarray, shape (...,4)
            Axis and angle (n_1, n_2, n_3, ω) with ǀnǀ = 1 and ω ∈ [0,π].
        """
        tfit = np.array([+0.9999999999999968,     -0.49999999999986866,     -0.025000000000632055,
                         -0.003928571496460683,   -0.0008164666077062752,   -0.00019411896443261646,
                         -0.00004985822229871769, -0.000014164962366386031, -1.9000248160936107e-6,
                         -5.72184549898506e-6,    +7.772149920658778e-6,    -0.00001053483452909705,
                         +9.528014229335313e-6,   -5.660288876265125e-6,    +1.2844901692764126e-6,
                         +1.1255185726258763e-6,  -1.3834391419956455e-6,   +7.513691751164847e-7,
                         -2.401996891720091e-7,   +4.386887017466388e-8,    -3.5917775353564864e-9])
        hmag_squared = np.sum(ho**2,axis=-1,keepdims=True)
        s = np.sum(tfit*hmag_squared**np.arange(len(tfit)),axis=-1,keepdims=True)
        with np.errstate(invalid='ignore'):
            return np.where(np.abs(hmag_squared)<1.e-8,
                            [0.,0.,1.,0.],
                            np.block([ho/np.sqrt(hmag_squared),2.*np.arccos(np.clip(s,-1.,1.))]))

    @staticmethod
    def _ho2ro(ho: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
        """
        Homochoric vector to Rodrigues–Frank vector.

        Parameters
        ----------
        ho : numpy.ndarray, shape (...,3)
            Homochoric vector (h_1, h_2, h_3) with ǀhǀ < (3π/4)^(1/3).

        Returns
        -------
        ro : numpy.ndarray, shape (...,4)
            Rodrigues–Frank vector [n_1, n_2, n_3, tan(ω/2)] with ǀnǀ = 1 and ω ∈ [0,π].
        """
        return Rotation._ax2ro(Rotation._ho2ax(ho))

    @staticmethod
    def _ho2cu(ho: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
        """
        Homochoric vector to cubochoric vector.

        Parameters
        ----------
        ho : numpy.ndarray, shape (...,3)
            Homochoric vector (h_1, h_2, h_3) with ǀhǀ < (3π/4)^(1/3).

        Returns
        -------
        cu : numpy.ndarray, shape (...,3)
            Cubochoric vector (x_1, x_2, x_3) with max(x_i) < 1/2 π^(2/3).

        References
        ----------
        D. Roşca et al., Modelling and Simulation in Materials Science and Engineering 22:075013, 2014
        https://doi.org/10.1088/0965-0393/22/7/075013
        """
        rs = np.linalg.norm(ho,axis=-1,keepdims=True)

        xyz3 = np.take_along_axis(ho,Rotation._get_pyramid_order(ho,'forward'),-1)

        with np.errstate(invalid='ignore',divide='ignore'):
            # inverse M_3
            xyz2 = xyz3[...,0:2] * np.sqrt( 2.*rs/(rs+np.abs(xyz3[...,2:3])) )
            qxy = np.sum(xyz2**2,axis=-1,keepdims=True)

            q2 = qxy + np.max(np.abs(xyz2),axis=-1,keepdims=True)**2
            sq2 = np.sqrt(q2)
            q = (_beta/np.sqrt(2.)/_R1) * np.sqrt(q2*qxy/(q2-np.max(np.abs(xyz2),axis=-1,keepdims=True)*sq2))
            tt = np.clip((np.min(np.abs(xyz2),axis=-1,keepdims=True)**2\
                +np.max(np.abs(xyz2),axis=-1,keepdims=True)*sq2)/np.sqrt(2.)/qxy,-1.,1.)
            T_inv = np.where(np.abs(xyz2[...,1:2]) <= np.abs(xyz2[...,0:1]),
                             np.block([np.ones_like(tt),np.arccos(tt)/np.pi*12.]),
                             np.block([np.arccos(tt)/np.pi*12.,np.ones_like(tt)]))*q
            T_inv[xyz2<0.] *= -1.
            T_inv[np.broadcast_to(np.isclose(qxy,0.,rtol=0.,atol=1.e-12),T_inv.shape)] = 0.
            cu = np.block([T_inv, np.where(xyz3[...,2:3]<0.,-rs,rs)/np.sqrt(6./np.pi)])/_sc

        cu[np.isclose(np.sum(np.abs(ho),axis=-1),0.,rtol=0.,atol=1.e-16)] = 0.
        return np.take_along_axis(cu,Rotation._get_pyramid_order(ho,'backward'),-1)

    #---------- Cubochoric ----------
    @staticmethod
    def _cu2qu(cu: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
        """
        Cubochoric vector to quaternion.

        Parameters
        ----------
        cu : numpy.ndarray, shape (...,3)
            Cubochoric vector (x_1, x_2, x_3) with max(x_i) < 1/2 π^(2/3).

        Returns
        -------
        qu : numpy.ndarray, shape (...,4)
            Unit quaternion (q_0, q_1, q_2, q_3) in positive real hemisphere, i.e. ǀqǀ = 1 and q_0 ≥ 0.
        """
        return Rotation._ho2qu(Rotation._cu2ho(cu))

    @staticmethod
    def _cu2om(cu: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
        """
        Cubochoric vector to rotation matrix.

        Parameters
        ----------
        cu : numpy.ndarray, shape (...,3)
            Cubochoric vector (x_1, x_2, x_3) with max(x_i) < 1/2 π^(2/3).

        Returns
        -------
        om : numpy.ndarray, shape (...,3,3)
            Rotation matrix with det(R) = 1 and R.T ∙ R = I.
        """
        return Rotation._ho2om(Rotation._cu2ho(cu))

    @staticmethod
    def _cu2eu(cu: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
        """
        Cubochoric vector to Bunge Euler angles.

        Parameters
        ----------
        cu : numpy.ndarray, shape (...,3)
            Cubochoric vector (x_1, x_2, x_3) with max(x_i) < 1/2 π^(2/3).

        Returns
        -------
        eu : numpy.ndarray, shape (...,3)
            Euler angles (φ_1 ∈ [0,2π], ϕ ∈ [0,π], φ_2 ∈ [0,2π]).
        """
        return Rotation._ho2eu(Rotation._cu2ho(cu))

    @staticmethod
    def _cu2ax(cu: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
        """
        Cubochoric vector to axis–angle pair.

        Parameters
        ----------
        cu : numpy.ndarray, shape (...,3)
            Cubochoric vector (x_1, x_2, x_3) with max(x_i) < 1/2 π^(2/3).

        Returns
        -------
        ax : numpy.ndarray, shape (...,4)
            Axis and angle (n_1, n_2, n_3, ω) with ǀnǀ = 1 and ω ∈ [0,π].
        """
        return Rotation._ho2ax(Rotation._cu2ho(cu))

    @staticmethod
    def _cu2ro(cu: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
        """
        Cubochoric vector to Rodrigues–Frank vector.

        Parameters
        ----------
        cu : numpy.ndarray, shape (...,3)
            Cubochoric vector (x_1, x_2, x_3) with max(x_i) < 1/2 π^(2/3).

        Returns
        -------
        ro : numpy.ndarray, shape (...,4)
            Rodrigues–Frank vector [n_1, n_2, n_3, tan(ω/2)] with ǀnǀ = 1 and ω ∈ [0,π].
        """
        return Rotation._ho2ro(Rotation._cu2ho(cu))

    @staticmethod
    def _cu2ho(cu: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
        """
        Cubochoric vector to homochoric vector.

        Parameters
        ----------
        cu : numpy.ndarray, shape (...,3)
            Cubochoric vector (x_1, x_2, x_3) with max(x_i) < 1/2 π^(2/3).

        Returns
        -------
        ho : numpy.ndarray, shape (...,3)
            Homochoric vector (h_1, h_2, h_3) with ǀhǀ < (3π/4)^(1/3).

        References
        ----------
        D. Roşca et al., Modelling and Simulation in Materials Science and Engineering 22:075013, 2014
        https://doi.org/10.1088/0965-0393/22/7/075013
        """
        with np.errstate(invalid='ignore',divide='ignore'):
            # get pyramid and scale by grid parameter ratio
            XYZ = np.take_along_axis(cu,Rotation._get_pyramid_order(cu,'forward'),-1) * _sc
            order = np.abs(XYZ[...,1]) <= np.abs(XYZ[...,0])
            q = np.pi/12. * np.where(order,XYZ[...,1],XYZ[...,0]) / np.where(order,XYZ[...,0],XYZ[...,1])
            c = np.cos(q)
            s = np.sin(q)
            q = _R1*2.**0.25/_beta/ np.sqrt(np.sqrt(2.)-c) \
              * np.where(order,XYZ[...,0],XYZ[...,1])

            T = np.stack([(np.sqrt(2.)*c - 1.), np.sqrt(2.) * s], axis=-1) * q[...,np.newaxis]

            # transform to sphere grid (inverse Lambert)
            c = np.sum(T**2,axis=-1)
            s = c *         np.pi/24. /XYZ[...,2]**2
            c = c * np.sqrt(np.pi/24.)/XYZ[...,2]
            q = np.sqrt( 1. - s)

            ho = np.where(np.isclose(np.sum(np.abs(XYZ[...,0:2]),axis=-1,keepdims=True),0.,rtol=0.,atol=1.e-16),
                          np.block([np.zeros_like(XYZ[...,0:2]),np.sqrt(6./np.pi)*XYZ[...,2:3]]),
                          np.stack([np.where(order,T[...,0],T[...,1])*q,
                                    np.where(order,T[...,1],T[...,0])*q,
                                    np.sqrt(6./np.pi) * XYZ[...,2] - c], axis=-1)
                          )

        ho[np.isclose(np.sum(np.abs(cu),axis=-1),0.,rtol=0.,atol=1.e-16)] = 0.
        return np.take_along_axis(ho,Rotation._get_pyramid_order(cu,'backward'),-1)


    @staticmethod
    def _get_pyramid_order(xyz: np.ndarray,
                           direction: Literal['forward', 'backward']) -> np.ndarray:
        """
        Get order of the coordinates.

        Depending on the pyramid in which the point is located, the order need to be adjusted.

        Parameters
        ----------
        xyz : numpy.ndarray
           Coordinates of a point on a uniform refinable grid on a ball or
           in a uniform refinable cubical grid.
        direction : {'forward', 'backward'}
            Whether to map from ball to cube or from cube to ball.

        References
        ----------
        D. Roşca et al., Modelling and Simulation in Materials Science and Engineering 22:075013, 2014
        https://doi.org/10.1088/0965-0393/22/7/075013
        """
        order = {'forward': np.array([[0,1,2],[1,2,0],[2,0,1]]),
                 'backward':np.array([[0,1,2],[2,0,1],[1,2,0]])}

        p = np.select([np.maximum(np.abs(xyz[...,0]),np.abs(xyz[...,1])) <= np.abs(xyz[...,2]),
                       np.maximum(np.abs(xyz[...,1]),np.abs(xyz[...,2])) <= np.abs(xyz[...,0])],
                      [0, 1], default=2)

        return order[direction][p]
