"""Axes assembly module."""

from __future__ import annotations

from abc import abstractmethod
from collections.abc import Sequence
import itertools
from typing import TYPE_CHECKING
from typing import Any
from typing import Literal
from typing import NamedTuple
from typing import TypedDict

import numpy as np

import pyvista as pv
from pyvista import BoundsTuple
from pyvista.core import _validation
from pyvista.core._validation.validate import _validate_color_sequence
from pyvista.core.utilities.geometric_sources import AxesGeometrySource
from pyvista.core.utilities.geometric_sources import OrthogonalPlanesSource
from pyvista.core.utilities.geometric_sources import _AxisEnum
from pyvista.core.utilities.geometric_sources import _PartEnum
from pyvista.core.utilities.misc import _NameMixin
from pyvista.core.utilities.misc import abstract_class
from pyvista.plotting import _vtk
from pyvista.plotting.actor import Actor
from pyvista.plotting.colors import Color
from pyvista.plotting.prop3d import Prop3D
from pyvista.plotting.prop3d import _Prop3DMixin
from pyvista.plotting.text import Label
from pyvista.plotting.text import TextProperty

if TYPE_CHECKING:
    from collections.abc import Iterator
    import sys

    from pyvista.core._typing_core import MatrixLike
    from pyvista.core._typing_core import VectorLike
    from pyvista.core.dataset import DataSet
    from pyvista.plotting._typing import ColorLike

    if sys.version_info >= (3, 11):
        from typing import Unpack
    else:
        from typing_extensions import Unpack


class _AxesPropTuple(NamedTuple):
    x_shaft: float | str | ColorLike
    y_shaft: float | str | ColorLike
    z_shaft: float | str | ColorLike
    x_tip: float | str | ColorLike
    y_tip: float | str | ColorLike
    z_tip: float | str | ColorLike


class _AxesGeometryKwargs(TypedDict):
    shaft_type: AxesGeometrySource.GeometryTypes | DataSet
    shaft_radius: float
    shaft_length: float | VectorLike[float]
    tip_type: AxesGeometrySource.GeometryTypes | DataSet
    tip_radius: float
    tip_length: float | VectorLike[float]
    symmetric_bounds: bool


class _OrthogonalPlanesKwargs(TypedDict):
    bounds: VectorLike[float]
    resolution: int | VectorLike[int]
    normal_sign: Literal['+', '-'] | Sequence[str]


class _XYZTuple(NamedTuple):
    x: Any
    y: Any
    z: Any


@abstract_class
class _XYZAssembly(_vtk.DisableVtkSnakeCase, _Prop3DMixin, _NameMixin, _vtk.vtkPropAssembly):
    DEFAULT_LABELS = _XYZTuple('X', 'Y', 'Z')

    def __init__(
        self,
        *,
        xyz_actors: tuple[Any, Any, Any],
        xyz_label_actors: tuple[Any, Any, Any],
        x_label,
        y_label,
        z_label,
        labels,
        label_color,
        show_labels,
        label_position,
        label_size,
        x_color,
        y_color,
        z_color,
        position: VectorLike[float],
        orientation: VectorLike[float],
        origin: VectorLike[float],
        scale: float | VectorLike[float],
        user_matrix: MatrixLike[float] | None,
        name: str | None = None,
    ):
        super().__init__()

        def _make_xyz_tuple(xyz):
            def _get_tuple(actor_or_actors):
                return actor_or_actors if isinstance(actor_or_actors, tuple) else (actor_or_actors,)

            actor_tuples = [_get_tuple(actors) for actors in xyz]
            return _XYZTuple(*actor_tuples)

        self._assembly_actors = _make_xyz_tuple(xyz_actors)
        self._assembly_label_actors = _make_xyz_tuple(xyz_label_actors)

        # Add all actors to assembly
        for parts in (*self._assembly_actors, *self._assembly_label_actors):
            for part in parts:
                self.AddPart(part)

        # Set colors
        if x_color is None:
            x_color = pv.global_theme.axes.x_color
        if y_color is None:
            y_color = pv.global_theme.axes.y_color
        if z_color is None:
            z_color = pv.global_theme.axes.z_color

        self.x_color = x_color
        self.y_color = y_color
        self.z_color = z_color

        # Set text labels
        if labels is None:
            self.x_label = self.DEFAULT_LABELS.x if x_label is None else x_label
            self.y_label = self.DEFAULT_LABELS.y if y_label is None else y_label
            self.z_label = self.DEFAULT_LABELS.z if z_label is None else z_label
        else:
            msg = "Cannot initialize '{}' and 'labels' properties together. Specify one or the other, not both."
            if x_label is not None:
                raise ValueError(msg.format('x_label'))
            if y_label is not None:
                raise ValueError(msg.format('y_label'))
            if z_label is not None:
                raise ValueError(msg.format('z_label'))
            self.labels = labels
        self.show_labels = show_labels
        self.label_color = label_color
        self.label_size = label_size
        self.label_position = label_position

        self.position = position  # type: ignore[assignment]
        self.orientation = orientation  # type: ignore[assignment]
        self.scale = scale  # type: ignore[assignment]
        self.origin = origin  # type: ignore[assignment]
        self.user_matrix = user_matrix  # type: ignore[assignment]

        self._name = name  # type: ignore[assignment]

    @property
    def parts(self):
        collection = self.GetParts()
        return tuple([collection.GetItemAsObject(i) for i in range(collection.GetNumberOfItems())])

    @property
    def _label_actor_iterator(self) -> Iterator[Label]:
        return itertools.chain.from_iterable(self._assembly_label_actors)

    def _post_set_update(self):
        # Update prop3D attributes for all assembly parts
        parts = self.parts
        new_matrix = pv.array_from_vtkmatrix(self._prop3d.GetMatrix())
        for part in parts:
            if isinstance(part, (Prop3D, _Prop3DMixin)) and not np.array_equal(
                part.user_matrix, new_matrix
            ):
                part.user_matrix = new_matrix  # type: ignore[method-assign]

    def _get_bounds(self) -> BoundsTuple:  # numpydoc ignore=RT01
        return BoundsTuple(*self.GetBounds())

    @property
    def show_labels(self) -> bool:  # numpydoc ignore=RT01
        """Show or hide the text labels for the axes."""
        return self._show_labels

    @show_labels.setter
    def show_labels(self, value: bool):
        self._show_labels = value
        for label in self._label_actor_iterator:
            label.SetVisibility(value)

    @property
    @abstractmethod
    def labels(self):  # numpydoc ignore=RT01
        """XYZ labels."""

    @labels.setter
    @abstractmethod
    def labels(self, labels):
        """XYZ labels."""

    @property
    @abstractmethod
    def x_label(self):  # numpydoc ignore=RT01
        """Text label for the x-axis."""

    @x_label.setter
    @abstractmethod
    def x_label(self, label):
        """Text label for the x-axis."""

    @property
    @abstractmethod
    def y_label(self):  # numpydoc ignore=RT01
        """Text label for the y-axis."""

    @y_label.setter
    @abstractmethod
    def y_label(self, label):
        """Text label for the y-axis."""

    @property
    @abstractmethod
    def z_label(self):  # numpydoc ignore=RT01
        """Text label for the z-axis."""

    @z_label.setter
    @abstractmethod
    def z_label(self, label):
        """Text label for the z-axis."""

    @property
    @abstractmethod
    def label_size(self):  # numpydoc ignore=RT01
        """Size of the text labels."""

    @label_size.setter
    @abstractmethod
    def label_size(self, size):
        """Size of the text labels."""

    @property
    @abstractmethod
    def label_position(self):  # numpydoc ignore=RT01
        """Position of the text labels."""

    @label_position.setter
    @abstractmethod
    def label_position(self, position):
        """Position of the text labels."""

    @property
    def label_color(self) -> Color:  # numpydoc ignore=RT01
        """Color of the text labels."""
        return self._label_color

    @label_color.setter
    def label_color(self, color: ColorLike):
        valid_color = Color(color)
        self._label_color = valid_color
        for label in self._label_actor_iterator:
            label.prop.color = valid_color

    @property
    @abstractmethod
    def x_color(self):  # numpydoc ignore=RT01
        """Color of the x-axis actors."""

    @x_color.setter
    @abstractmethod
    def x_color(self, color):
        """Color of the x-axis actors."""

    @property
    @abstractmethod
    def y_color(self):  # numpydoc ignore=RT01
        """Color of the y-axis actors."""

    @y_color.setter
    @abstractmethod
    def y_color(self, color):
        """Color of the y-axis actors."""

    @property
    @abstractmethod
    def z_color(self):  # numpydoc ignore=RT01
        """Color of the z-axis actors."""

    @z_color.setter
    @abstractmethod
    def z_color(self, color):
        """Color of the z-axis actors."""


class AxesAssembly(_XYZAssembly):
    """Assembly of arrow-style axes parts.

    The axes may be used as a widget or added to a scene.

    Parameters
    ----------
    x_label : str, default: 'X'
        Text label for the x-axis. Alternatively, set the label with :attr:`labels`.

    y_label : str, default: 'Y'
        Text label for the y-axis. Alternatively, set the label with :attr:`labels`.

    z_label : str, default: 'Z'
        Text label for the z-axis. Alternatively, set the label with :attr:`labels`.

    labels : Sequence[str], optional,
        Text labels for the axes. This is an alternative parameter to using
        :attr:`x_label`, :attr:`y_label`, and :attr:`z_label` separately.

    label_color : ColorLike, default: 'black'
        Color of the text labels.

    show_labels : bool, default: True
        Show or hide the text labels.

    label_position : float | VectorLike[float], optional
        Position of the text labels along each axis. By default, the labels are
        positioned at the ends of the shafts.

    label_size : int, default: 50
        Size of the text labels.

    x_color : ColorLike | Sequence[ColorLike], optional
        Color of the x-axis shaft and tip.

    y_color : ColorLike | Sequence[ColorLike], optional
        Color of the y-axis shaft and tip.

    z_color : ColorLike | Sequence[ColorLike], optional
        Color of the z-axis shaft and tip.

    position : VectorLike[float], default: (0.0, 0.0, 0.0)
        Position of the axes in space.

    orientation : VectorLike[float], default: (0, 0, 0)
        Orientation angles of the axes which define rotations about the
        world's x-y-z axes. The angles are specified in degrees and in
        x-y-z order. However, the actual rotations are applied in the
        around the y-axis first, then the x-axis, and finally the z-axis.

    origin : VectorLike[float], default: (0.0, 0.0, 0.0)
        Origin of the axes. This is the point about which all rotations take place. The
        rotations are defined by the :attr:`~pyvista.Prop3D.orientation`.

    scale : VectorLike[float], default: (1.0, 1.0, 1.0)
        Scaling factor applied to the axes.

    user_matrix : MatrixLike[float], optional
        A 4x4 transformation matrix applied to the axes. Defaults to the identity matrix.
        The user matrix is the last transformation applied to the actor.

    name : str, optional
        The name of this assembly used when tracking on a plotter.

        .. versionadded:: 0.45

    **kwargs
        Keyword arguments passed to :class:`pyvista.AxesGeometrySource`.

    Examples
    --------
    Add axes to a plot.

    >>> import pyvista as pv
    >>> axes = pv.AxesAssembly()
    >>> pl = pv.Plotter()
    >>> _ = pl.add_actor(axes)
    >>> pl.show()

    Customize the axes colors. Set each axis to a single color, or set the colors of
    each shaft and tip separately with two colors.

    >>> axes.x_color = ['cyan', 'blue']
    >>> axes.y_color = ['magenta', 'red']
    >>> axes.z_color = 'yellow'

    Customize the label color too.

    >>> axes.label_color = 'brown'

    >>> pl = pv.Plotter()
    >>> _ = pl.add_actor(axes)
    >>> pl.show()

    Create axes with custom geometry. Use pyramid shafts and hemisphere tips and
    modify the lengths.

    >>> axes = pv.AxesAssembly(
    ...     shaft_type='pyramid',
    ...     tip_type='hemisphere',
    ...     tip_length=0.1,
    ...     shaft_length=(0.5, 1.0, 1.5),
    ... )
    >>> pl = pv.Plotter()
    >>> _ = pl.add_actor(axes)
    >>> pl.show()

    Position and orient the axes in space.

    >>> axes = pv.AxesAssembly(position=(1.0, 2.0, 3.0), orientation=(10, 20, 30))
    >>> pl = pv.Plotter()
    >>> _ = pl.add_actor(axes)
    >>> pl.show()

    Add the axes as a custom orientation widget with
    :func:`~pyvista.Renderer.add_orientation_widget`:

    >>> import pyvista as pv

    >>> axes = pv.AxesAssembly(symmetric_bounds=True)

    >>> pl = pv.Plotter()
    >>> _ = pl.add_mesh(pv.Cone())
    >>> _ = pl.add_orientation_widget(
    ...     axes,
    ...     viewport=(0, 0, 0.5, 0.5),
    ... )
    >>> pl.show()

    """

    def _init_actors_from_source(self, geometry_source: AxesGeometrySource):
        # Init shaft and tip actors
        self._shaft_actors: tuple[Actor, Actor, Actor] = (Actor(), Actor(), Actor())
        self._tip_actors: tuple[Actor, Actor, Actor] = (Actor(), Actor(), Actor())
        self._shaft_and_tip_actors = (*self._shaft_actors, *self._tip_actors)

        # Init shaft and tip datasets
        self._shaft_and_tip_geometry_source = geometry_source
        shaft_tip_datasets = self._shaft_and_tip_geometry_source.output
        for actor, dataset in zip(self._shaft_and_tip_actors, shaft_tip_datasets):
            actor.mapper = pv.DataSetMapper(dataset=dataset)

    def __init__(
        self,
        *,
        x_label: str | None = None,
        y_label: str | None = None,
        z_label: str | None = None,
        labels: Sequence[str] | None = None,
        label_color: ColorLike = 'black',
        show_labels: bool = True,
        label_position: float | VectorLike[float] | None = None,
        label_size: int = 50,
        x_color: ColorLike | Sequence[ColorLike] | None = None,
        y_color: ColorLike | Sequence[ColorLike] | None = None,
        z_color: ColorLike | Sequence[ColorLike] | None = None,
        position: VectorLike[float] = (0.0, 0.0, 0.0),
        orientation: VectorLike[float] = (0.0, 0.0, 0.0),
        origin: VectorLike[float] = (0.0, 0.0, 0.0),
        scale: float | VectorLike[float] = (1.0, 1.0, 1.0),
        user_matrix: MatrixLike[float] | None = None,
        name: str | None = None,
        **kwargs: Unpack[_AxesGeometryKwargs],
    ):
        # Init shaft and tip actors
        self._init_actors_from_source(AxesGeometrySource(symmetric=False, **kwargs))
        # Init label actors
        self._label_actors = (Label(), Label(), Label())

        _XYZAssembly.__init__(
            self,
            xyz_actors=tuple(zip(self._shaft_actors, self._tip_actors)),  # type: ignore[arg-type]
            xyz_label_actors=self._label_actors,
            x_label=x_label,
            y_label=y_label,
            z_label=z_label,
            labels=labels,
            label_color=label_color,
            show_labels=show_labels,
            label_position=label_position,
            label_size=label_size,
            x_color=x_color,
            y_color=y_color,
            z_color=z_color,
            position=position,
            orientation=orientation,
            origin=origin,
            scale=scale,
            user_matrix=user_matrix,
            name=name,
        )
        self._set_default_label_props()

    def _set_default_label_props(self):
        # TODO: implement set_text_prop() and use that instead
        for label in self._label_actor_iterator:
            prop = label.prop
            prop.bold = True
            prop.italic = True
            prop.enable_shadow()
            prop.SetShadowOffset(-1, 1)

    def __repr__(self):
        """Representation of the axes assembly."""
        mat_info = 'Identity' if np.array_equal(self.user_matrix, np.eye(4)) else 'Set'
        bnds = self.bounds

        geometry_repr = repr(self._shaft_and_tip_geometry_source).splitlines()[1:]

        attr = [
            f'{type(self).__name__} ({hex(id(self))})',
            *geometry_repr,
            f"  X label:                    '{self.x_label}'",
            f"  Y label:                    '{self.y_label}'",
            f"  Z label:                    '{self.z_label}'",
            f'  Label color:                {self.label_color}',
            f'  Show labels:                {self.show_labels}',
            f'  Label position:             {self.label_position}',
            '  X Color:                                     ',
            f'      Shaft                   {self.x_color[0]}',
            f'      Tip                     {self.x_color[1]}',
            '  Y Color:                                     ',
            f'      Shaft                   {self.y_color[0]}',
            f'      Tip                     {self.y_color[1]}',
            '  Z Color:                                     ',
            f'      Shaft                   {self.z_color[0]}',
            f'      Tip                     {self.z_color[1]}',
            f'  Position:                   {self.position}',
            f'  Orientation:                {self.orientation}',
            f'  Origin:                     {self.origin}',
            f'  Scale:                      {self.scale}',
            f'  User matrix:                {mat_info}',
            f'  X Bounds                    {bnds.x_min:.3E}, {bnds.x_max:.3E}',
            f'  Y Bounds                    {bnds.y_min:.3E}, {bnds.y_max:.3E}',
            f'  Z Bounds                    {bnds.z_min:.3E}, {bnds.z_max:.3E}',
        ]
        return '\n'.join(attr)

    @property
    def labels(self) -> tuple[str, str, str]:  # numpydoc ignore=RT01
        """Return or set the axes labels.

        This property may be used as an alternative to using :attr:`x_label`,
        :attr:`y_label`, and :attr:`z_label` separately.

        Examples
        --------
        >>> import pyvista as pv
        >>> axes_assembly = pv.AxesAssembly()
        >>> axes_assembly.labels = ['X Axis', 'Y Axis', 'Z Axis']
        >>> axes_assembly.labels
        ('X Axis', 'Y Axis', 'Z Axis')

        """
        return self.x_label, self.y_label, self.z_label

    @labels.setter
    def labels(self, labels: list[str] | tuple[str, str, str]):
        labels = _validate_label_sequence(labels, n_labels=3, name='labels')
        self.x_label = labels[0]
        self.y_label = labels[1]
        self.z_label = labels[2]

    @property
    def x_label(self) -> str:  # numpydoc ignore=RT01
        """Text label for the x-axis.

        Examples
        --------
        >>> import pyvista as pv
        >>> axes_assembly = pv.AxesAssembly()
        >>> axes_assembly.x_label = 'This axis'
        >>> axes_assembly.x_label
        'This axis'

        """
        return self._label_actors[0].input

    @x_label.setter
    def x_label(self, label: str):
        self._label_actors[0].input = label

    @property
    def y_label(self) -> str:  # numpydoc ignore=RT01
        """Text label for the y-axis.

        Examples
        --------
        >>> import pyvista as pv
        >>> axes_assembly = pv.AxesAssembly()
        >>> axes_assembly.y_label = 'This axis'
        >>> axes_assembly.y_label
        'This axis'

        """
        return self._label_actors[1].input

    @y_label.setter
    def y_label(self, label: str):
        self._label_actors[1].input = label

    @property
    def z_label(self) -> str:  # numpydoc ignore=RT01
        """Text label for the z-axis.

        Examples
        --------
        >>> import pyvista as pv
        >>> axes_assembly = pv.AxesAssembly()
        >>> axes_assembly.z_label = 'This axis'
        >>> axes_assembly.z_label
        'This axis'

        """
        return self._label_actors[2].input

    @z_label.setter
    def z_label(self, label: str):
        self._label_actors[2].input = label

    @property
    def label_size(self) -> int:  # numpydoc ignore=RT01
        """Size of the text labels.

        Must be a positive integer.
        """
        return self._label_size

    @label_size.setter
    def label_size(self, size: int):
        self._label_size = size
        for label in self._label_actor_iterator:
            label.size = size

    @property
    def label_position(self) -> tuple[float, float, float]:  # numpydoc ignore=RT01
        """Position of the text label along each axis.

        By default, the labels are positioned at the ends of the shafts.

        Values must be non-negative.

        Examples
        --------
        >>> import pyvista as pv
        >>> axes_assembly = pv.AxesAssembly()
        >>> axes_assembly.label_position
        (0.8, 0.8, 0.8)
        >>> axes_assembly.label_position = 0.3
        >>> axes_assembly.label_position
        (0.3, 0.3, 0.3)
        >>> axes_assembly.label_position = (0.1, 0.4, 0.2)
        >>> axes_assembly.label_position
        (0.1, 0.4, 0.2)

        """
        position = self._label_position
        return self._shaft_and_tip_geometry_source.shaft_length if position is None else position

    @label_position.setter
    def label_position(self, position: float | VectorLike[float] | None):
        self._label_position = (
            None
            if position is None
            else _validation.validate_array3(
                position,
                broadcast=True,
                must_be_in_range=[0, np.inf],
                name='Label position',
                dtype_out=float,
                to_tuple=True,
            )
        )
        self._update_label_positions()

    @property
    def x_color(self) -> tuple[Color, Color]:  # numpydoc ignore=RT01
        """Color of the x-axis shaft and tip."""
        return self.get_actor_prop('color')[_AxisEnum.x :: 3]

    @x_color.setter
    def x_color(self, color: ColorLike | Sequence[ColorLike]):
        self.set_actor_prop('color', color, axis=_AxisEnum.x.value)  # type: ignore[arg-type]

    @property
    def y_color(self) -> tuple[Color, Color]:  # numpydoc ignore=RT01
        """Color of the y-axis shaft and tip."""
        return self.get_actor_prop('color')[_AxisEnum.y :: 3]

    @y_color.setter
    def y_color(self, color: ColorLike | Sequence[ColorLike]):
        self.set_actor_prop('color', color, axis=_AxisEnum.y.value)  # type: ignore[arg-type]

    @property
    def z_color(self) -> tuple[Color, Color]:  # numpydoc ignore=RT01
        """Color of the z-axis shaft and tip."""
        return self.get_actor_prop('color')[_AxisEnum.z.value :: 3]

    @z_color.setter
    def z_color(self, color: ColorLike | Sequence[ColorLike]):
        self.set_actor_prop('color', color, axis=_AxisEnum.z.value)  # type: ignore[arg-type]

    def set_actor_prop(
        self,
        name: str,
        value: float | str | ColorLike | Sequence[float | str | ColorLike],
        axis: Literal['x', 'y', 'z', 'all'] = 'all',
        part: Literal['shaft', 'tip', 'all'] = 'all',
    ):
        """Set :class:`~pyvista.Property` attributes for the axes shaft and/or tip actors.

        This is a generalized setter method which sets the value of a specific
        :class:`~pyvista.Property` attribute for any combination of axis shaft or tip
        parts.

        Parameters
        ----------
        name : str
            Name of the :class:`~pyvista.Property` attribute to set.

        value : float | str | ColorLike | Sequence[float | str | ColorLike]
            Value to set the attribute to. If a single value, set all specified axes
            shaft(s) or tip(s) :class:`~pyvista.Property` attributes to this value.
            If a sequence of values, set the specified parts to these values.

        axis : str | int, default: 'all'
            Set :class:`~pyvista.Property` attributes for a specific part of the axes.
            Specify one of:

            - ``'x'``: only set the property for the x-axis.
            - ``'y'``: only set the property for the y-axis.
            - ``'z'``: only set the property for the z-axis.
            - ``'all'``: set the property for all three axes.

        part : str | int, default: 'all'
            Set the property for a specific part of the axes. Specify one of:

            - ``'shaft'``: only set the property for the axes shafts.
            - ``'tip'``: only set the property for the axes tips.
            - ``'all'``: set the property for axes shafts and tips.

        Examples
        --------
        Set :attr:`~pyvista.Property.ambient` for all axes shafts and tips to a
        single value.

        >>> import pyvista as pv
        >>> axes_assembly = pv.AxesAssembly()
        >>> axes_assembly.set_actor_prop('ambient', 0.7)
        >>> axes_assembly.get_actor_prop('ambient')
        _AxesPropTuple(x_shaft=0.7, y_shaft=0.7, z_shaft=0.7, x_tip=0.7, y_tip=0.7, z_tip=0.7)

        Set the property again, but this time set separate values for each part.

        >>> values = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6]
        >>> axes_assembly.set_actor_prop('ambient', values)
        >>> axes_assembly.get_actor_prop('ambient')
        _AxesPropTuple(x_shaft=0.1, y_shaft=0.2, z_shaft=0.3, x_tip=0.4, y_tip=0.5, z_tip=0.6)

        Set :attr:`~pyvista.Property.opacity` for the x-axis only. The property is set
        for both the axis shaft and tip by default.

        >>> axes_assembly.set_actor_prop('opacity', 0.5, axis='x')
        >>> axes_assembly.get_actor_prop('opacity')
        _AxesPropTuple(x_shaft=0.5, y_shaft=1.0, z_shaft=1.0, x_tip=0.5, y_tip=1.0, z_tip=1.0)

        Set the property again, but this time set separate values for the shaft and tip.

        >>> axes_assembly.set_actor_prop('opacity', [0.3, 0.7], axis='x')
        >>> axes_assembly.get_actor_prop('opacity')
        _AxesPropTuple(x_shaft=0.3, y_shaft=1.0, z_shaft=1.0, x_tip=0.7, y_tip=1.0, z_tip=1.0)

        Set :attr:`~pyvista.Property.show_edges` for the axes shafts only. The property
        is set for all axes by default.

        >>> axes_assembly.set_actor_prop('show_edges', True, part='shaft')
        >>> axes_assembly.get_actor_prop('show_edges')
        _AxesPropTuple(x_shaft=True, y_shaft=True, z_shaft=True, x_tip=False, y_tip=False, z_tip=False)

        Set the property again, but this time set separate values for each shaft.

        >>> axes_assembly.set_actor_prop(
        ...     'show_edges', [True, False, True], part='shaft'
        ... )
        >>> axes_assembly.get_actor_prop('show_edges')
        _AxesPropTuple(x_shaft=True, y_shaft=False, z_shaft=True, x_tip=False, y_tip=False, z_tip=False)

        Set :attr:`~pyvista.Property.style` for a single axis and specific part.

        >>> axes_assembly.set_actor_prop('style', 'wireframe', axis='x', part='shaft')
        >>> axes_assembly.get_actor_prop('style')
        _AxesPropTuple(x_shaft='Wireframe', y_shaft='Surface', z_shaft='Surface', x_tip='Surface', y_tip='Surface', z_tip='Surface')

        """
        actors = self._filter_part_actors(axis=axis, part=part)
        values: Sequence[float | str | ColorLike]

        # Validate input as a sequence of values
        if 'color' in name:
            # Special case for color inputs
            if axis == 'all' and part == 'all':
                n_values = 6
            elif part == 'all':
                n_values = 2
            elif axis == 'all':
                n_values = 3
            else:
                n_values = 1
            values = _validate_color_sequence(value, n_values)  # type: ignore[arg-type]
        elif isinstance(value, Sequence) and not isinstance(value, str):
            # Number sequence
            values = value
        else:
            # Scalar number or string
            values = [value] * len(actors)

        if len(values) != len(actors):
            msg = f"Number of values ({len(values)}) in {value} must match the number of actors ({len(actors)}) for axis '{axis}' and part '{part}'"
            raise ValueError(msg)

        # Sequence is valid, now set values
        for actor, val in zip(actors, values):
            setattr(actor.prop, name, val)

    def get_actor_prop(self, name: str):
        """Get :class:`~pyvista.Property` attributes for the axes shaft and/or tip actors.

        This is a generalized getter method which returns the value of
        a specific :class:`pyvista.Property` attribute for all shafts and tips.

        Parameters
        ----------
        name : str
            Name of the :class:`~pyvista.Property` attribute to get.

        Returns
        -------
        tuple
            Named tuple with attribute values for the axes shafts and tips.
            The values are ordered ``(x_shaft, y_shaft, z_shaft, x_tip, y_tip, z_tip)``.

        Examples
        --------
        Get the ambient property of the axes shafts and tips.

        >>> import pyvista as pv
        >>> axes_assembly = pv.AxesAssembly()
        >>> axes_assembly.get_actor_prop('ambient')
        _AxesPropTuple(x_shaft=0.0, y_shaft=0.0, z_shaft=0.0, x_tip=0.0, y_tip=0.0, z_tip=0.0)

        """
        prop_values = [getattr(actor.prop, name) for actor in self._shaft_and_tip_actors]
        return _AxesPropTuple(*prop_values)

    def _filter_part_actors(
        self,
        axis: Literal['x', 'y', 'z', 'all'] = 'all',
        part: Literal['shaft', 'tip', 'all'] = 'all',
    ):
        valid_axis = [0, 1, 2, 'x', 'y', 'z', 'all']
        valid_axis_official = valid_axis[3:]
        if axis not in valid_axis:
            msg = f'Axis must be one of {valid_axis_official}.'
            raise ValueError(msg)
        valid_part = [0, 1, 'shaft', 'tip', 'all']
        valid_part_official = valid_part[2:]
        if part not in valid_part:
            msg = f'Part must be one of {valid_part_official}.'
            raise ValueError(msg)

        # Create ordered list of filtered actors
        # Iterate over parts in <shaft-xyz> then <tip-xyz> order
        actors: list[Actor] = []
        for part_type, axis_num in itertools.product(_PartEnum, _AxisEnum):
            if part in [part_type.name, part_type.value, 'all'] and axis in [
                axis_num.name,
                axis_num.value,
                'all',
            ]:
                # Add actor to list
                if part_type == _PartEnum.shaft:
                    actors.append(self._shaft_actors[axis_num])
                else:
                    actors.append(self._tip_actors[axis_num])

        return actors

    def _get_offset_label_position_vectors(self, position_scalars: tuple[float, float, float]):
        # Create position vectors
        position_vectors = np.diag(position_scalars)

        # Offset label positions radially by the tip radius
        tip_radius = self._shaft_and_tip_geometry_source.tip_radius
        offset_array = np.diag([tip_radius] * 3)
        radial_offset1 = np.roll(offset_array, shift=1, axis=1)
        radial_offset2 = np.roll(offset_array, shift=-1, axis=1)

        position_vectors += radial_offset1 + radial_offset2
        return position_vectors

    def _update_label_positions(self):
        labels = self._label_actors
        position_vectors = self._get_offset_label_position_vectors(self.label_position)
        for label, position in zip(labels, position_vectors):
            label.relative_position = position


def _validate_label_sequence(labels: Sequence[str], n_labels: int | Sequence[int], name: str):
    _validation.check_instance(labels, (list, tuple), name=name)
    _validation.check_iterable_items(labels, str, name=name)
    _validation.check_length(labels, exact_length=n_labels, name=name)
    return labels


class AxesAssemblySymmetric(AxesAssembly):
    """Symmetric assembly of arrow-style axes parts.

    This class is similar to :class:`~pyvista.AxesAssembly` but the axes are
    symmetric.

    The axes may be used as a widget or added to a scene.

    Parameters
    ----------
    x_label : str, default: ('+X', '-X')
        Text labels for the positive and negative x-axis. Specify two strings or a
        single string. If a single string, plus ``'+'`` and minus ``'-'`` characters
        are added. Alternatively, set the labels with :attr:`labels`.

    y_label : str, default: ('+Y', '-Y')
        Text labels for the positive and negative y-axis. Specify two strings or a
        single string. If a single string, plus ``'+'`` and minus ``'-'`` characters
        are added. Alternatively, set the labels with :attr:`labels`.

    z_label : str, default: ('+Z', '-Z')
        Text labels for the positive and negative z-axis. Specify two strings or a
        single string. If a single string, plus ``'+'`` and minus ``'-'`` characters
        are added. Alternatively, set the labels with :attr:`labels`.

    labels : Sequence[str], optional
        Text labels for the axes. Specify three strings, one for each axis, or
        six strings, one for each +/- axis. If three strings plus ``'+'`` and minus
        ``'-'`` characters are added. This is an alternative parameter to using
        :attr:`x_label`, :attr:`y_label`, and :attr:`z_label` separately.

    label_color : ColorLike, default: 'black'
        Color of the text labels.

    show_labels : bool, default: True
        Show or hide the text labels.

    label_position : float | VectorLike[float], optional
        Position of the text labels along each axis. By default, the labels are
        positioned at the ends of the shafts.

    label_size : int, default: 50
        Size of the text labels.

    x_color : ColorLike | Sequence[ColorLike], optional
        Color of the x-axis shaft and tip.

    y_color : ColorLike | Sequence[ColorLike], optional
        Color of the y-axis shaft and tip.

    z_color : ColorLike | Sequence[ColorLike], optional
        Color of the z-axis shaft and tip.

    position : VectorLike[float], default: (0.0, 0.0, 0.0)
        Position of the axes in space.

    orientation : VectorLike[float], default: (0, 0, 0)
        Orientation angles of the axes which define rotations about the
        world's x-y-z axes. The angles are specified in degrees and in
        x-y-z order. However, the actual rotations are applied in the
        around the y-axis first, then the x-axis, and finally the z-axis.

    origin : VectorLike[float], default: (0.0, 0.0, 0.0)
        Origin of the axes. This is the point about which all rotations take place. The
        rotations are defined by the :attr:`~pyvista.Prop3D.orientation`.

    scale : VectorLike[float], default: (1.0, 1.0, 1.0)
        Scaling factor applied to the axes.

    user_matrix : MatrixLike[float], optional
        A 4x4 transformation matrix applied to the axes. Defaults to the identity matrix.
        The user matrix is the last transformation applied to the actor.

    name : str, optional
        The name of this assembly used when tracking on a plotter.

        .. versionadded:: 0.45

    **kwargs
        Keyword arguments passed to :class:`pyvista.AxesGeometrySource`.

    Examples
    --------
    Add symmetric axes to a plot.

    >>> import pyvista as pv
    >>> axes_assembly = pv.AxesAssemblySymmetric()
    >>> pl = pv.Plotter()
    >>> _ = pl.add_actor(axes_assembly)
    >>> pl.show()

    Customize the axes labels.

    >>> axes_assembly.labels = [
    ...     'east',
    ...     'west',
    ...     'north',
    ...     'south',
    ...     'up',
    ...     'down',
    ... ]
    >>> axes_assembly.label_color = 'darkgoldenrod'

    >>> pl = pv.Plotter()
    >>> _ = pl.add_actor(axes_assembly)
    >>> pl.show()

    Add the axes as a custom orientation widget with
    :func:`~pyvista.Renderer.add_orientation_widget`. We also configure the labels to
    only show text for the positive axes.

    >>> axes_assembly = pv.AxesAssemblySymmetric(
    ...     x_label=('X', ''), y_label=('Y', ''), z_label=('Z', '')
    ... )
    >>> pl = pv.Plotter()
    >>> _ = pl.add_mesh(pv.Cone())
    >>> _ = pl.add_orientation_widget(
    ...     axes_assembly,
    ...     viewport=(0, 0, 0.5, 0.5),
    ... )
    >>> pl.show()

    """

    def __init__(
        self,
        *,
        x_label: str | Sequence[str] | None = None,
        y_label: str | Sequence[str] | None = None,
        z_label: str | Sequence[str] | None = None,
        labels: Sequence[str] | None = None,
        label_color: ColorLike = 'black',
        show_labels: bool = True,
        label_position: float | VectorLike[float] | None = None,
        label_size: int = 50,
        x_color: ColorLike | Sequence[ColorLike] | None = None,
        y_color: ColorLike | Sequence[ColorLike] | None = None,
        z_color: ColorLike | Sequence[ColorLike] | None = None,
        position: VectorLike[float] = (0.0, 0.0, 0.0),
        orientation: VectorLike[float] = (0.0, 0.0, 0.0),
        origin: VectorLike[float] = (0.0, 0.0, 0.0),
        scale: float | VectorLike[float] = (1.0, 1.0, 1.0),
        user_matrix: MatrixLike[float] | None = None,
        name: str | None = None,
        **kwargs: Unpack[_AxesGeometryKwargs],
    ):
        # Init shaft and tip actors
        self._init_actors_from_source(AxesGeometrySource(symmetric=True, **kwargs))
        # Init label actors
        self._label_actors = (Label(), Label(), Label())
        self._label_actors_symmetric = (Label(), Label(), Label())

        _XYZAssembly.__init__(
            self,
            xyz_actors=tuple(zip(self._shaft_actors, self._tip_actors)),  # type: ignore[arg-type]
            xyz_label_actors=tuple(zip(self._label_actors, self._label_actors_symmetric)),  # type: ignore[arg-type]
            x_label=x_label,
            y_label=y_label,
            z_label=z_label,
            labels=labels,
            label_color=label_color,
            show_labels=show_labels,
            label_position=label_position,
            label_size=label_size,
            x_color=x_color,
            y_color=y_color,
            z_color=z_color,
            position=position,
            orientation=orientation,
            origin=origin,
            scale=scale,
            user_matrix=user_matrix,
            name=name,
        )
        self._set_default_label_props()

    @property  # type: ignore[override]
    def labels(self) -> tuple[str, str, str, str, str, str]:  # numpydoc ignore=RT01
        """Return or set the axes labels.

        Specify three strings, one for each axis, or six strings, one for each +/- axis.
        If three strings, plus ``'+'`` and minus ``'-'`` characters are added.
        This property may be used as an alternative to using :attr:`x_label`,
        :attr:`y_label`, and :attr:`z_label` separately.

        Examples
        --------
        >>> import pyvista as pv
        >>> axes_assembly = pv.AxesAssemblySymmetric()

        Use three strings to set the labels. Plus ``'+'`` and minus ``'-'``
        characters are added automatically.

        >>> axes_assembly.labels = ['U', 'V', 'W']
        >>> axes_assembly.labels
        ('+U', '-U', '+V', '-V', '+W', '-W')

        Alternatively, use six strings to set the labels explicitly.

        >>> axes_assembly.labels = [
        ...     'east',
        ...     'west',
        ...     'north',
        ...     'south',
        ...     'up',
        ...     'down',
        ... ]
        >>> axes_assembly.labels
        ('east', 'west', 'north', 'south', 'up', 'down')

        """
        return *self.x_label, *self.y_label, *self.z_label

    @labels.setter
    def labels(
        self, labels: list[str] | tuple[str, str, str] | tuple[str, str, str, str, str, str]
    ):
        valid_labels = _validate_label_sequence(labels, n_labels=[3, 6], name='labels')
        if len(valid_labels) == 3:
            self.x_label = valid_labels[0]
            self.y_label = valid_labels[1]
            self.z_label = valid_labels[2]
        else:
            self.x_label = valid_labels[0:2]
            self.y_label = valid_labels[2:4]
            self.z_label = valid_labels[4:6]

    def _get_axis_label(self, axis: _AxisEnum) -> tuple[str, str]:
        label_plus = self._label_actors[axis].input
        label_minus = self._label_actors_symmetric[axis].input
        return label_plus, label_minus

    def _set_axis_label(self, axis: _AxisEnum, label: str | list[str] | tuple[str, str]):
        if isinstance(label, str):
            label_plus, label_minus = '+' + label, '-' + label
        else:
            label_plus, label_minus = _validate_label_sequence(label, n_labels=2, name='label')
        self._label_actors[axis].input = label_plus
        self._label_actors_symmetric[axis].input = label_minus

    @property  # type: ignore[override]
    def x_label(self) -> tuple[str, str]:  # numpydoc ignore=RT01
        """Return or set the labels for the positive and negative x-axis.

        The labels may be set with a single string or two strings. If a single string,
        plus ``'+'`` and minus ``'-'`` characters are added. Alternatively, set the
        labels with :attr:`labels`.

        Examples
        --------
        Set the labels with a single string. Plus ``'+'`` and minus ``'-'``
        characters are added automatically.

        >>> import pyvista as pv
        >>> axes_assembly = pv.AxesAssemblySymmetric()
        >>> axes_assembly.x_label = 'Axis'
        >>> axes_assembly.x_label
        ('+Axis', '-Axis')

        Set the labels explicitly with two strings.

        >>> axes_assembly.x_label = 'anterior', 'posterior'
        >>> axes_assembly.x_label
        ('anterior', 'posterior')

        """
        return self._get_axis_label(_AxisEnum.x)

    @x_label.setter
    def x_label(self, label: str | list[str] | tuple[str, str]):
        self._set_axis_label(_AxisEnum.x, label)

    @property  # type: ignore[override]
    def y_label(self) -> tuple[str, str]:  # numpydoc ignore=RT01
        """Return or set the labels for the positive and negative y-axis.

        The labels may be set with a single string or two strings. If a single string,
        plus ``'+'`` and minus ``'-'`` characters are added. Alternatively, set the
        labels with :attr:`labels`.

        Examples
        --------
        Set the labels with a single string. Plus ``'+'`` and minus ``'-'``
        characters are added automatically.

        >>> import pyvista as pv
        >>> axes_assembly = pv.AxesAssemblySymmetric()
        >>> axes_assembly.y_label = 'Axis'
        >>> axes_assembly.y_label
        ('+Axis', '-Axis')

        Set the labels explicitly with two strings.

        >>> axes_assembly.y_label = 'left', 'right'
        >>> axes_assembly.y_label
        ('left', 'right')

        """
        return self._get_axis_label(_AxisEnum.y)

    @y_label.setter
    def y_label(self, label: str | list[str] | tuple[str, str]):
        self._set_axis_label(_AxisEnum.y, label)

    @property  # type: ignore[override]
    def z_label(self) -> tuple[str, str]:  # numpydoc ignore=RT01
        """Return or set the labels for the positive and negative z-axis.

        The labels may be set with a single string or two strings. If a single string,
        plus ``'+'`` and minus ``'-'`` characters are added. Alternatively, set the
        labels with :attr:`labels`.

        Examples
        --------
        Set the labels with a single string. Plus ``'+'`` and minus ``'-'``
        characters are added automatically.

        >>> import pyvista as pv
        >>> axes_assembly = pv.AxesAssemblySymmetric()
        >>> axes_assembly.z_label = 'Axis'
        >>> axes_assembly.z_label
        ('+Axis', '-Axis')

        Set the labels explicitly with two strings.

        >>> axes_assembly.z_label = 'superior', 'inferior'
        >>> axes_assembly.z_label
        ('superior', 'inferior')

        """
        return self._get_axis_label(_AxisEnum.z)

    @z_label.setter
    def z_label(self, label: str | list[str] | tuple[str, str]):
        self._set_axis_label(_AxisEnum.z, label)

    def _update_label_positions(self):
        # Update plus labels using parent method
        AxesAssembly._update_label_positions(self)

        # Update minus labels
        label_position = self.label_position
        label_position_minus = (-label_position[0], -label_position[1], -label_position[2])
        labels_minus = self._label_actors_symmetric
        vector_position_minus = self._get_offset_label_position_vectors(label_position_minus)
        for label, position in zip(labels_minus, vector_position_minus):
            label.relative_position = position


class PlanesAssembly(_XYZAssembly):
    """Assembly of orthogonal planes.

    Assembly of three orthogonal plane meshes with labels.

    The labels can be 2D or 3D, and will follow the camera such that they have the
    correct orientation and remain parallel to the edges of the planes.

    The positioning of the labels may be customized using the :attr:`label_edge`,
    :attr:`label_position`, and :attr:`label_offset` attributes.

    .. warning::

        The :attr:`camera` must be set before rendering the assembly. Otherwise,
        attempting to render it will cause python to crash.

    .. versionadded:: 0.45

    Parameters
    ----------
    x_label : str, default: 'YZ'
        Text label for the yz-plane. Alternatively, set the label with :attr:`labels`.

    y_label : str, default: 'ZX'
        Text label for the zx-plane. Alternatively, set the label with :attr:`labels`.

    z_label : str, default: 'XY'
        Text label for the xy-plane. Alternatively, set the label with :attr:`labels`.

    labels : Sequence[str], optional,
        Text labels for the planes. This is an alternative parameter to using
        :attr:`x_label`, :attr:`y_label`, and :attr:`z_label` separately.

    label_color : ColorLike, default: 'black'
        Color of the text labels.

    show_labels : bool, default: True
        Show or hide the text labels.

    label_position : float | VectorLike[float], default: 0.5
        Normalized relative position of the text labels along each plane's respective
        :attr:`label_edge`. The positions are normalized to have a range of
        ``[-1.0, 1.0]`` such that ``0.0`` is at the center of the edge and ``-1.0`` and
        ``1.0`` are at the corners.

        .. note::

            The label text is centered horizontally at the specified positions.

    label_edge : str, default: 'right'
        Edge on which to position each plane's label. Can be ``'top'``, ``'bottom'``,
        ``'right'``, or ``'left'``. Use a single value to set the edge for all labels
        or set each edge independently.

    label_offset : float | VectorLike[float], optional
        Vertical offset of the text labels. The offset is proportional to
        the :attr:`~pyvista.Prop3D.length` of the assembly. Positive values move the labels away
        from the center; negative values move them towards it.

    label_size : int, default: 50
        Size of the text labels. If :attr:`label_mode` is ``'2D'``, this is the
        font size. If :attr:`label_mode` is ``'3D'``, the labels are scaled
        proportional to the :attr:`~pyvista.Prop3D.length` of the assembly.

    label_mode : '2D' | '3D', default: '2D'
        Mode to use for text labels. In 2D mode, the label actors are always visible
        and have a constant size regardless of window size. In 3D mode, the label actors
        may be occluded by other geometry and will scale with changes to the window
        size. The two modes also have minor differences in appearance and behavior in
        terms of how they follow the camera.

    x_color : ColorLike, optional
        Color of the xy-plane.

    y_color : ColorLike, optional
        Color of the yz-plane.

    z_color : ColorLike, optional
        Color of the zx-plane.

    opacity : float, default: 0.3
        Opacity of the planes.

    position : VectorLike[float], default: (0.0, 0.0, 0.0)
        Position of the planes in space.

    orientation : VectorLike[float], default: (0, 0, 0)
        Orientation angles of the assembly which define rotations about the
        world's x-y-z axes. The angles are specified in degrees and in
        x-y-z order. However, the actual rotations are applied in the
        around the y-axis first, then the x-axis, and finally the z-axis.

    origin : VectorLike[float], default: (0.0, 0.0, 0.0)
        Origin of the assembly. This is the point about which all rotations take place.
        The rotations are defined by the :attr:`~pyvista.Prop3D.orientation`.

    scale : VectorLike[float], default: (1.0, 1.0, 1.0)
        Scaling factor applied to the assembly.

    user_matrix : MatrixLike[float], optional
        A 4x4 transformation matrix applied to the assembly. Defaults to the identity
        matrix. The user matrix is the last transformation applied to the actor.

    name : str, optional
        The name of this assembly used when tracking on a plotter.

        .. versionadded:: 0.45

    **kwargs
        Keyword arguments passed to :class:`pyvista.OrthogonalPlanesSource`.

    Examples
    --------
    Fit planes to a model of a human.

    >>> import numpy as np
    >>> import pyvista as pv
    >>> from pyvista import examples
    >>> human = examples.download_human()
    >>> labels = 'Sagittal', 'Coronal', 'Transverse'
    >>> planes = pv.PlanesAssembly(
    ...     bounds=human.bounds, labels=labels, label_size=25
    ... )

    Plot the planes and the model.

    >>> pl = pv.Plotter()
    >>> human_actor = pl.add_mesh(human, scalars='Color', rgb=True)
    >>> _ = pl.add_actor(planes)
    >>> planes.camera = pl.camera
    >>> pl.show()

    Apply a transformation to the planes and the model.

    >>> transform = np.array(
    ...     [
    ...         [0.70645893, 0.69636424, 0.12646197, 1.0],
    ...         [-0.62246712, 0.69636424, -0.35722756, 2.0],
    ...         [-0.33682409, 0.17364818, 0.92541658, 3.0],
    ...         [0.0, 0.0, 0.0, 1.0],
    ...     ]
    ... )
    >>> planes.user_matrix = transform
    >>> human_actor.user_matrix = transform

    >>> pl = pv.Plotter()
    >>> _ = pl.add_actor(human_actor)
    >>> _ = pl.add_actor(planes)
    >>> planes.camera = pl.camera
    >>> pl.show()

    Create a new assembly and customize the colors and opacity.

    >>> color = pv.global_theme.color
    >>> planes = pv.PlanesAssembly(
    ...     bounds=human.bounds,
    ...     x_color=color,
    ...     y_color=color,
    ...     z_color=color,
    ...     opacity=1.0,
    ... )

    Since the planes are opaque, the 3D labels may be occluded. Use 2D labels instead
    so the labels are always visible.

    >>> planes.label_mode = '2D'

    Offset the labels to position them inside the bounds of the planes.

    >>> planes.label_offset = -0.05

    Move the labels for the two larger planes closer to the corners.

    >>> planes.label_position = (0.8, 0.8, 0.5)

    Visualize the result.

    >>> pl = pv.Plotter()
    >>> _ = pl.add_mesh(human, scalars='Color', rgb=True)
    >>> _ = pl.add_actor(planes)
    >>> planes.camera = pl.camera
    >>> pl.show()

    """

    DEFAULT_LABELS = _XYZTuple('YZ', 'ZX', 'XY')

    def __init__(
        self,
        *,
        x_label: str | None = None,
        y_label: str | None = None,
        z_label: str | None = None,
        labels: Sequence[str] | None = None,
        label_color: ColorLike = 'black',
        show_labels: bool = True,
        label_position: float | VectorLike[float] = 0.5,
        label_edge: Literal['top', 'bottom', 'right', 'left'] | Sequence[str] = 'right',
        label_offset: float = 0.05,
        label_size: int = 50,
        label_mode: Literal['2D', '3D'] = '3D',
        x_color: ColorLike | None = None,
        y_color: ColorLike | None = None,
        z_color: ColorLike | None = None,
        opacity: float | VectorLike[float] = 0.3,
        position: VectorLike[float] = (0.0, 0.0, 0.0),
        orientation: VectorLike[float] = (0.0, 0.0, 0.0),
        origin: VectorLike[float] = (0.0, 0.0, 0.0),
        scale: float | VectorLike[float] = (1.0, 1.0, 1.0),
        user_matrix: MatrixLike[float] | None = None,
        name: str | None = None,
        **kwargs: Unpack[_OrthogonalPlanesKwargs],
    ):
        # Init plane actors
        self._plane_actors = (Actor(), Actor(), Actor())
        # Init planes from source
        self._geometry_source = OrthogonalPlanesSource(**kwargs)
        self._planes = self._geometry_source.output
        self._plane_sources = self._geometry_source.sources

        for actor, dataset in zip(self._plane_actors, self.planes):
            actor.mapper = pv.DataSetMapper(dataset=dataset)

        # Init label actors
        self._axis_actors = (_AxisActor(), _AxisActor(), _AxisActor())

        # Tempt init values for call to super class, will validate inputs later
        self._label_offset = 0.05
        self._label_edge = ('right', 'right', 'right')
        self._label_position = 0.5, 0.5, 0.5

        _XYZAssembly.__init__(
            self,
            xyz_actors=self._plane_actors,
            xyz_label_actors=self._axis_actors,
            x_label=x_label,
            y_label=y_label,
            z_label=z_label,
            labels=labels,
            label_color=label_color,
            show_labels=show_labels,
            label_position=label_position,
            label_size=label_size,
            x_color=x_color,
            y_color=y_color,
            z_color=z_color,
            position=position,
            orientation=orientation,
            origin=origin,
            scale=scale,
            user_matrix=user_matrix,
            name=name,
        )

        self.opacity = opacity  # type: ignore[assignment]
        self.label_mode = label_mode
        self.label_offset = label_offset
        self.label_edge = label_edge  # type: ignore[assignment]

        # Set default properties
        for actor in self._plane_actors:
            prop = actor.prop
            prop.show_edges = True
            prop.line_width = 3

        # Set default text properties
        # TODO: implement set_text_prop() and use that instead
        for label in self._label_actor_iterator:
            prop = label.prop
            prop.justification_vertical = 'center'
            prop.justification_horizontal = 'center'

    def __repr__(self):
        """Representation of the planes assembly."""
        mat_info = 'Identity' if np.array_equal(self.user_matrix, np.eye(4)) else 'Set'
        bnds = self.bounds

        attr = [
            f'{type(self).__name__} ({hex(id(self))})',
            f'  Resolution:                 {self._geometry_source.resolution}',
            f'  Normal sign:                {self._geometry_source.normal_sign}',
            f"  X label:                    '{self.x_label}'",
            f"  Y label:                    '{self.y_label}'",
            f"  Z label:                    '{self.z_label}'",
            f'  Label color:                {self.label_color}',
            f'  Show labels:                {self.show_labels}',
            f'  Label position:             {self.label_position}',
            f'  Label edge:                 {self.label_edge}',
            f'  Label offset:               {self.label_offset}',
            f"  Label mode:                 '{self.label_mode}'",
            f'  X Color:                    {self.x_color}',
            f'  Y Color:                    {self.y_color}',
            f'  Z Color:                    {self.z_color}',
            f'  Position:                   {self.position}',
            f'  Orientation:                {self.orientation}',
            f'  Origin:                     {self.origin}',
            f'  Scale:                      {self.scale}',
            f'  User matrix:                {mat_info}',
            f'  X Bounds                    {bnds.x_min:.3E}, {bnds.x_max:.3E}',
            f'  Y Bounds                    {bnds.y_min:.3E}, {bnds.y_max:.3E}',
            f'  Z Bounds                    {bnds.z_min:.3E}, {bnds.z_max:.3E}',
        ]
        return '\n'.join(attr)

    @property
    def labels(self) -> tuple[str, str, str]:  # numpydoc ignore=RT01
        """Return or set the labels for the planes.

        This property may be used as an alternative to using :attr:`x_label`,
        :attr:`y_label`, and :attr:`z_label` separately.

        Examples
        --------
        >>> import pyvista as pv
        >>> planes = pv.PlanesAssembly()
        >>> planes.labels = ['Sagittal', 'Coronal', 'Transverse']
        >>> planes.labels
        ('Sagittal', 'Coronal', 'Transverse')

        """
        return self.x_label, self.y_label, self.z_label

    @labels.setter
    def labels(self, labels: list[str] | tuple[str, str, str]):
        labels = _validate_label_sequence(labels, n_labels=3, name='labels')
        self.x_label = labels[0]
        self.y_label = labels[1]
        self.z_label = labels[2]

    @property
    def x_label(self) -> str:  # numpydoc ignore=RT01
        """Text label for the yz-plane.

        Examples
        --------
        >>> import pyvista as pv
        >>> planes = pv.PlanesAssembly()
        >>> planes.x_label = 'This plane'
        >>> planes.x_label
        'This plane'

        """
        return self._axis_actors[0].GetTitle()

    @x_label.setter
    def x_label(self, label: str):
        self._axis_actors[0].SetTitle(label)
        self.planes.set_block_name(0, label)

    @property
    def y_label(self) -> str:  # numpydoc ignore=RT01
        """Text label for the zx-plane.

        Examples
        --------
        >>> import pyvista as pv
        >>> planes = pv.PlanesAssembly()
        >>> planes.y_label = 'This plane'
        >>> planes.y_label
        'This plane'

        """
        return self._axis_actors[1].GetTitle()

    @y_label.setter
    def y_label(self, label: str):
        self._axis_actors[1].SetTitle(label)
        self.planes.set_block_name(1, label)

    @property
    def z_label(self) -> str:  # numpydoc ignore=RT01
        """Text label for the xy-plane.

        Examples
        --------
        >>> import pyvista as pv
        >>> planes = pv.PlanesAssembly()
        >>> planes.z_label = 'This plane'
        >>> planes.z_label
        'This plane'

        """
        return self._axis_actors[2].GetTitle()

    @z_label.setter
    def z_label(self, label: str):
        self._axis_actors[2].SetTitle(label)
        self.planes.set_block_name(2, label)

    @property
    def label_size(self) -> int:  # numpydoc ignore=RT01
        """Size of the text labels.

        Must be a positive integer.
        """
        return self._label_size

    @label_size.setter
    def label_size(self, size: int):
        valid_size = _validation.validate_number(
            size,
            must_be_in_range=[0, np.inf],
            must_be_integer=True,
            dtype_out=int,
            name='label size',
        )
        self._label_size = valid_size
        # 2D labels use font size (int) but 3D labels use a scaling factor (float)
        # For 3D labels, we re-scale the text proportional to the planes assembly
        # Values on the order of 0.01-0.05 seem to work best. Use a normalization
        # factor so that input values are on the order of 10-50 and roughly match 2D sizes
        NORM_FACTOR = 1000
        scale = self.planes.length * float(valid_size) / NORM_FACTOR
        for axis in self._axis_actors:
            axis.GetTitleActor().SetScale(scale)  # 3D labels
            axis.GetTitleTextProperty().SetFontSize(size)  # 2D labels

    @property
    def label_position(self) -> tuple[float, float, float]:  # numpydoc ignore=RT01
        """Normalized relative position of the text labels along the edge of each plane.

        Labels are positioned relative to each plane's respective :attr:`label_edge`.
        The positions are normalized to have a range of ``[-1.0, 1.0]`` such that ``0.0``
        is at the center of the edge and ``-1.0`` and ``1.0`` are at the corners.

        .. note::

            The label text is centered horizontally at the specified positions.

        Examples
        --------
        Position the labels at the center (along the edges) and plot the assembly.

        >>> import pyvista as pv
        >>> planes = pv.PlanesAssembly(label_position=0)
        >>> planes.label_position
        (0.0, 0.0, 0.0)

        >>> pl = pv.Plotter()
        >>> _ = pl.add_actor(planes)
        >>> planes.camera = pl.camera
        >>> pl.show()

        Position the labels at the corners.

        >>> planes.label_position = 1.0
        >>> pl = pv.Plotter()
        >>> _ = pl.add_actor(planes)
        >>> planes.camera = pl.camera
        >>> pl.show()

        Vary the position of the labels independently for each plane. The values may be
        negative and/or exceed a value of ``1.0``.

        >>> planes.label_position = (-1.3, -1.0, -0.5)
        >>> pl = pv.Plotter()
        >>> _ = pl.add_actor(planes)
        >>> planes.camera = pl.camera
        >>> pl.show()

        """
        return self._label_position

    @label_position.setter
    def label_position(self, position: int | VectorLike[int]):
        self._label_position = _validation.validate_array3(
            position,
            broadcast=True,
            name='Label position',
            dtype_out=float,
            to_tuple=True,
        )
        self._update_label_positions()

    @property
    def label_edge(self) -> tuple[str, str, str]:  # numpydoc ignore=RT01
        """Edge on which to position each plane's label.

        Edge can be ``'top'``,``'bottom'``,``'right'``, or ``'left'``, and can be
        set independently for each plane or to the same edge for all planes.

        The edge is relative to each plane's local ``i`` and ``j`` coordinates.

        Examples
        --------
        Position the labels at the top edge and plot.

        >>> import pyvista as pv
        >>> planes = pv.PlanesAssembly(label_edge='top')
        >>> planes.label_edge
        ('top', 'top', 'top')

        >>> pl = pv.Plotter()
        >>> _ = pl.add_actor(planes)
        >>> planes.camera = pl.camera
        >>> pl.show()

        Position the labels at the bottom.

        >>> planes.label_edge = 'bottom'
        >>> pl = pv.Plotter()
        >>> _ = pl.add_actor(planes)
        >>> planes.camera = pl.camera
        >>> pl.show()

        Vary the edge of the labels independently for each plane.

        >>> planes.label_edge = ('top', 'right', 'left')
        >>> pl = pv.Plotter()
        >>> _ = pl.add_actor(planes)
        >>> planes.camera = pl.camera
        >>> pl.show()

        """
        return self._label_edge

    @label_edge.setter
    def label_edge(self, edge: Literal['top', 'bottom', 'right', 'left'] | Sequence[str]):
        valid_edge = (
            [edge] * 3
            if isinstance(edge, str)
            else _validate_label_sequence(edge, n_labels=3, name='label edge')
        )
        for edge_ in valid_edge:
            _validation.check_contains(
                ['top', 'bottom', 'right', 'left'], must_contain=edge_, name='label_edge'
            )
        self._label_edge = tuple(valid_edge)
        self._update_label_positions()

    @property
    def label_offset(self) -> float:  # numpydoc ignore=RT01
        """Vertical offset of the text labels.

        The offset is proportional to the :attr:`~pyvista.Prop3D.length` of the assembly. Positive
        values move the labels away from the center; negative values move them
        towards it.
        """
        return self._label_offset

    @label_offset.setter
    def label_offset(self, offset: int):
        self._label_offset = _validation.validate_number(offset, dtype_out=float)
        self._update_label_positions()

    @property
    def label_mode(self) -> Literal['2D', '3D']:  # numpydoc ignore=RT01
        """Mode to use for text labels.

        Mode must be either ``'2D'`` or ``'3D'``. In 2D mode, the label actors are
        always visible and have a constant size regardless of window size. In 3D mode,
        the label actors may be occluded by other geometry and will scale with changes
        to the window size. The two modes also have minor differences in appearance as
        well as behavior in terms of how they follow the camera.
        """
        return self._label_mode

    @label_mode.setter
    def label_mode(self, mode: Literal['2D', '3D']):
        _validation.check_contains(['2D', '3D'], must_contain=mode, name='label_mode')
        self._label_mode = mode
        use_2D = mode == '2D'
        for axis in self._axis_actors:
            axis.SetUse2DMode(use_2D)

    @property
    def x_color(self) -> Color:  # numpydoc ignore=RT01
        """Color of the yz-plane."""
        return self._plane_actors[0].prop.color

    @x_color.setter
    def x_color(self, color: ColorLike):
        self._plane_actors[0].prop.color = color

    @property
    def y_color(self) -> Color:  # numpydoc ignore=RT01
        """Color of the zx-plane."""
        return self._plane_actors[1].prop.color

    @y_color.setter
    def y_color(self, color: ColorLike):
        self._plane_actors[1].prop.color = color

    @property
    def z_color(self) -> Color:  # numpydoc ignore=RT01
        """Color of the xy-plane."""
        return self._plane_actors[2].prop.color

    @z_color.setter
    def z_color(self, color: ColorLike):
        self._plane_actors[2].prop.color = color

    @property
    def opacity(self) -> tuple[float, float, float]:  # numpydoc ignore=RT01
        """Opacity of the planes."""
        return self._opacity

    @opacity.setter
    def opacity(self, opacity: float):
        valid_opacity = _validation.validate_array3(
            opacity, broadcast=True, dtype_out=float, to_tuple=True
        )
        self._opacity = valid_opacity
        for actor, opacity in zip(self._plane_actors, valid_opacity):
            actor.prop.opacity = opacity

    @property
    def camera(self):  # numpydoc ignore=RT01
        """Camera to use for displaying the labels."""
        if not hasattr(self, '_camera'):
            msg = 'Camera has not been set.'
            raise ValueError(msg)
        return self._camera

    @camera.setter
    def camera(self, camera):
        self._camera = camera
        for axis in self._axis_actors:
            axis.SetCamera(camera)

    @property
    def planes(self):
        """Get the orthogonal plane datasets of the assembly.

        The planes are :class:`pyvista.PolyData` meshes stored as a
        :class:`pyvista.MultiBlock`. The names of the blocks match the names of the
        assembly's :attr:`labels`.

        The planes are initially generated with :class:`pyvista.OrthogonalPlanesSource`.

        Returns
        -------
        pyvista.MultiBlock
            Composite mesh with three planes.

        """
        return self._planes

    def _update_label_positions(self):
        axis_actors = self._axis_actors
        plane_sources = self._plane_sources
        transformation_matrix = self._transformation_matrix

        def transform_point(point):
            return (transformation_matrix @ (*point, 1))[:3]

        def set_axis_location(plane_id, edge: str, position: float):
            this_plane_source = plane_sources[plane_id]
            this_axis_actor = axis_actors[plane_id]

            # Get vectors which define the plane
            origin, point1, point2 = (
                np.array(this_plane_source.GetOrigin()),
                np.array(this_plane_source.GetPoint1()),
                np.array(this_plane_source.GetPoint2()),
            )

            vector1 = point1 - origin
            vector2 = point2 - origin

            # Define corners
            corner_bottom_left = origin
            corner_bottom_right = origin + vector1
            corner_top_left = origin + vector2
            corner_top_right = corner_bottom_right + vector2

            # Define axis points in counter-clockwise order
            if edge == 'top':
                axis_point1, axis_point2 = corner_top_right, corner_top_left
            elif edge == 'left':
                axis_point1, axis_point2 = corner_top_left, corner_bottom_left
            elif edge == 'bottom':
                axis_point1, axis_point2 = corner_bottom_left, corner_bottom_right
            else:  # 'right'
                axis_point1, axis_point2 = corner_bottom_right, corner_top_right

            # Move axis to position along the edge
            axis_vector = axis_point1 - axis_point2
            # Define position relative to center of edge
            position_vector = np.abs(axis_vector) * position * 0.5
            axis_point1 += position_vector
            axis_point2 += position_vector

            # Add offset
            axis_dir = axis_vector / np.linalg.norm(axis_vector)
            offset_dir = np.cross(axis_dir, this_plane_source.GetNormal())
            offset_dir = -1 * offset_dir / np.linalg.norm(offset_dir)
            offset_mag = self.planes.length * self.label_offset
            offset = offset_mag * offset_dir
            axis_point1 += offset
            axis_point2 += offset

            # Set axis points
            this_axis_actor.SetPoint1(transform_point(axis_point1))
            this_axis_actor.SetPoint2(transform_point(axis_point2))

        edge = self.label_edge
        position = self.label_position
        set_axis_location(0, edge[0], position[0])
        set_axis_location(1, edge[1], position[1])
        set_axis_location(2, edge[2], position[2])

    def _post_set_update(self):
        _XYZAssembly._post_set_update(self)
        # Need to manually update axis actors
        self._update_label_positions()


class _AxisActor(_vtk.DisableVtkSnakeCase, _vtk.vtkAxisActor):
    def __init__(self):
        super().__init__()
        # Only show the title
        self.TitleVisibilityOn()
        self.MinorTicksVisibleOff()
        self.TickVisibilityOff()
        self.DrawGridlinesOff()
        self.AxisVisibilityOff()  # Turn this on for debugging

        # Set empty tick labels
        labels = _vtk.vtkStringArray()
        labels.SetNumberOfTuples(0)
        # labels.SetValue(0, "")
        self.SetLabels(labels)

        # Ignore the axis bounds when rendering. Otherwise, the bounds must be
        # set with SetBounds() every time the axis is updated
        self.SetUseBounds(False)

        # Format title positioning
        offset = (0,) if pv.vtk_version_info < (9, 3) else (0, 0)
        self.SetTitleOffset(*offset)
        self.SetLabelOffset(0)

        # For 2D mode only
        self.SetVerticalOffsetXTitle2D(0)
        self.SetHorizontalOffsetYTitle2D(0)
        text_prop = TextProperty()
        text_prop.justification_vertical = 'center'
        self.SetTitleTextProperty(text_prop)
        self.GetTitleActor()

        # For 3D mode only
        self.GetProperty().SetLighting(False)

    @property
    def prop(self) -> TextProperty:
        return self.GetTitleTextProperty()
