diff --git a/examples/plotting/orientation_projections.py b/examples/plotting/orientation_projections.py
new file mode 100644
index 00000000..2f4f85c2
--- /dev/null
+++ b/examples/plotting/orientation_projections.py
@@ -0,0 +1,130 @@
+#
+# Copyright 2018-2025 the orix developers
+#
+# This file is part of orix.
+#
+# orix is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# orix 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with orix. If not, see .
+#
+r"""
+=======================
+Orientation projections
+=======================
+
+This example demonstrates the four different projections used by ORX to
+project Orientations (which are non-Euclidean) into either 2D or 3D
+orthogonal space. This is done by subclassing matplotlib's Axes and
+Axes3D classes.
+
+There are four options for plotting projections. The first and only 2D
+option is :class:`~orix.plot.InversePoleFigurePlot`, which is calculated
+as :math:`(X, Y) = ((v_x/(1-v_z)),(v_y/(1-v_z)))`. This is computationally
+efficient and translates well to print publication, but loses orientation
+information perpendicular to whatever axis is being plotted, similar to a 2D
+X-ray of a skeleton.
+
+The next three are 3D axis-angle projections, sometimes also called
+Neo-Eulerian projections. Here, the fact that any rotation can be described
+by a twist math:`\omega` around an axis :math:`\hat{\mathbf{n}}`. The
+math:`(X,Y,Z)` coordinates of the orientation's projection is the
+math:`(V_x,V_y,V_z)` coordinates of a unit vector describing
+math:`\hat{\mathbf{n}}`, scaled by a function of :math:`\omega`.
+The scaling options are:
+
+ * :class:`~orix.plot.AxAnglePlot`:
+ math:`\omega * \hat{\mathbf{n}}`, A linear projection
+ * :class:`~orix.plot.RodriguesPlot`
+ math:`tan(\omega/2) * \hat{\mathbf{n}}`, A Rectilinear projection,
+ where orientations sharing a common rotation axis linearly align
+ * :class:`~orix.plot.HomochoricPlot`
+ math:`(0.75*(\omega-sin(\omega)))^{1/3} * \hat{\mathbf{n}}`, An
+ equal-volume projection, where a cube placed anywhere inside takes
+ up an identical solid angle in orientation space.
+
+Note this list is not exhaustive and the descriptions are simplified.
+For a deeper dive into the advantages and disadganvages of these projections
+as well as enlightening comparisions of their warping of orientation space,
+refer to the following open access publication:
+
+
+.. _On three-dimensional misorientation spaces: https://royalsocietypublishing.org/doi/10.1098/rspa.2017.0274
+
+(doi link: https://doi.org/10.1098/rspa.2017.0274)
+
+"""
+
+import matplotlib.pyplot as plt
+import numpy as np
+
+from orix.plot import IPFColorKeyTSL, register_projections
+from orix.quaternion import Orientation, OrientationRegion
+from orix.quaternion.symmetry import D3
+
+plt.close("all")
+register_projections() # Register our custom Matplotlib projections
+np.random.seed(2319) # Create reproducible random data
+
+n = 30
+ori = Orientation.random(n, symmetry=D3)
+# create orientation-dependent colormap for more informative plots.
+color_key = IPFColorKeyTSL(D3)
+clrs = color_key.orientation2color(ori)
+
+############################################################################
+# Orientation plots can be made in one of two ways. The first and simplest
+# is via Orientation.scatter().
+fig = plt.figure(figsize=(12, 3), layout="constrained")
+ori.scatter(c=clrs, position=(1, 4, 1), projection="axangle", figure=fig)
+fig.axes[0].set_title("Axis-Angle Projection")
+ori.scatter(c=clrs, position=(1, 4, 2), projection="rodrigues", figure=fig)
+fig.axes[1].set_title("Rodrigues Projection")
+ori.scatter(c=clrs, position=(1, 4, 3), projection="homochoric", figure=fig)
+fig.axes[2].set_title("Homochoric Projection")
+# TODO: Following line does not plot properly due to the logic in
+# Orientation.scatter
+# ori.scatter(c=clrs, position=(1, 4, 4), projection="ipf", figure=fig)
+plt.tight_layout()
+
+
+# This can also be used to create standalone figures
+ori.scatter(c=clrs, projection="ipf")
+plt.tight_layout()
+
+
+############################################################################
+# The second method is by setting the projections when defining the
+# matplotlib axes. This can require more tinkering since the plots are not
+# auto-formatted like above, but it allows for more customization as well
+# as the plotting of multiple datasets on a single plot
+
+fig = plt.figure(figsize=(12, 4), layout="constrained")
+ax_ax = fig.add_subplot(1, 4, 1, projection="axangle")
+ax_rod = fig.add_subplot(1, 4, 2, projection="rodrigues")
+ax_hom = fig.add_subplot(1, 4, 3, projection="homochoric")
+ax_ipf = fig.add_subplot(1, 4, 4, projection="ipf", symmetry=D3)
+
+ax_ipf.scatter(ori, c=clrs)
+
+ax_ax.set_title("Axis-Angle Projection")
+ax_rod.set_title("Rodrigues Projection")
+ax_hom.set_title("Homochoric Projection")
+ax_ipf.set_title("Inverse Pole Figure Projection \n\n")
+
+fundamental_zone = OrientationRegion.from_symmetry(ori.symmetry)
+for ax in [ax_ax, ax_rod, ax_hom]:
+ ax.plot_wireframe(fundamental_zone)
+ ax.set_proj_type = "ortho"
+ ax.axis("off")
+ ax.scatter(ori, c=clrs)
+
+plt.tight_layout()
diff --git a/orix/plot/__init__.pyi b/orix/plot/__init__.pyi
index b5d567a9..7bb9d296 100644
--- a/orix/plot/__init__.pyi
+++ b/orix/plot/__init__.pyi
@@ -22,7 +22,12 @@ from ._util.formatting import format_labels
from .crystal_map_plot import CrystalMapPlot
from .direction_color_keys import DirectionColorKeyTSL
from .orientation_color_keys import EulerColorKey, IPFColorKeyTSL
-from .rotation_plot import AxAnglePlot, RodriguesPlot, RotationPlot
+from .rotation_plot import (
+ AxAnglePlot,
+ HomochoricPlot,
+ RodriguesPlot,
+ RotationPlot,
+)
from .stereographic_plot import StereographicPlot
# Must be imported below StereographicPlot since it imports it
@@ -35,6 +40,7 @@ __all__ = [
"CrystalMapPlot",
"DirectionColorKeyTSL",
"EulerColorKey",
+ "HomochoricPlot",
"InversePoleFigurePlot",
"IPFColorKeyTSL",
"RodriguesPlot",
diff --git a/orix/plot/_plot.py b/orix/plot/_plot.py
index cf2cd26e..8b0dd0b2 100644
--- a/orix/plot/_plot.py
+++ b/orix/plot/_plot.py
@@ -20,7 +20,7 @@
import matplotlib.projections as mprojections
from .crystal_map_plot import CrystalMapPlot
-from .rotation_plot import AxAnglePlot, RodriguesPlot
+from .rotation_plot import AxAnglePlot, HomochoricPlot, RodriguesPlot
from .stereographic_plot import StereographicPlot
# Inverse pole figure plot class must be imported below stereographic
@@ -43,6 +43,7 @@ def register_projections() -> None:
:class:`~orix.plot.InversePoleFigurePlot`
:class:`~orix.plot.AxAnglePlot`
:class:`~orix.plot.RodriguesPlot`
+ :class:`~orix.plot.HomochoricPlot`
:class:`~orix.plot.StereographicPlot`
Examples
@@ -63,6 +64,7 @@ def register_projections() -> None:
projections = [
AxAnglePlot,
CrystalMapPlot,
+ HomochoricPlot,
InversePoleFigurePlot,
RodriguesPlot,
StereographicPlot,
diff --git a/orix/plot/rotation_plot.py b/orix/plot/rotation_plot.py
index 09061992..371397d7 100644
--- a/orix/plot/rotation_plot.py
+++ b/orix/plot/rotation_plot.py
@@ -17,7 +17,7 @@
# along with orix. If not, see .
#
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Literal
from matplotlib import projections
import matplotlib.collections as mcollections
@@ -29,7 +29,7 @@
from mpl_toolkits.mplot3d.art3d import Line3DCollection
import numpy as np
-from orix.vector.neo_euler import AxAngle, Rodrigues
+from orix.vector.neo_euler import AxAngle, Homochoric, Rodrigues
if TYPE_CHECKING: # pragma: no cover
from orix.quaternion.misorientation import Misorientation
@@ -154,29 +154,37 @@ def _correct_aspect_ratio(
class RodriguesPlot(RotationPlot):
- """Plot rotations in Rodrigues-Frank space."""
+ """Plot rotations in Rodrigues-Frank (rectilinear) axis-angle space."""
name = "rodrigues"
transformation_class = Rodrigues
class AxAnglePlot(RotationPlot):
- """Plot rotations in a axis-angle space."""
+ """Plot rotations in a linearly scalled axis-angle space."""
name = "axangle"
transformation_class = AxAngle
+class HomochoricPlot(RotationPlot):
+ """Plot rotations in homochoric (equi-volume) axis-angle space."""
+
+ name = "homochoric"
+ transformation_class = Homochoric
+
+
projections.register_projection(RodriguesPlot)
projections.register_projection(AxAnglePlot)
+projections.register_projection(HomochoricPlot)
def _setup_rotation_plot(
figure: mfigure.Figure | None = None,
- projection: str = "axangle",
+ projection: Literal["axangle", "rodrigues", "homochoric"] = "axangle",
position: int | tuple | SubplotSpec | None = (1, 1, 1),
figure_kwargs: dict | None = None,
-) -> tuple[mfigure.Figure, AxAnglePlot | RodriguesPlot]:
+) -> tuple[mfigure.Figure, AxAnglePlot | RodriguesPlot | HomochoricPlot]:
"""Return a figure and rotation plot axis of the correct type.
This is a convenience method used in e.g.
@@ -185,13 +193,13 @@ def _setup_rotation_plot(
Parameters
----------
figure
- If given, a new plot axis :class:`orix.plot.AxAnglePlot` or
- :class:`orix.plot.RodriguesPlot` is added to the figure in
- the position specified by `position`. If not given, a new
- figure is created.
+ If given, a new plot axis :class:`orix.plot.AxAnglePlot`,
+ :class:`orix.plot.RodriguesPlot`, or `orix.plot.HomochoricPlot`
+ is added to the figure in the position specified by `position`.
+ If not given, a new figure is created.
projection
Which orientation space to plot orientations in, either
- "axangle" (default) or "rodrigues".
+ "axangle" (default), "rodrigues", or 'homochoric'.
position
Where to add the new plot axis. 121 or (1, 2, 1) places it
in the first of two positions in a grid of 1 row and 2
@@ -206,7 +214,7 @@ def _setup_rotation_plot(
figure
Figure with the added plot axis.
ax
- The axis-angle or Rodrigues plot axis.
+ The plot axis.
"""
if figure is None:
if figure_kwargs is None:
diff --git a/orix/quaternion/misorientation.py b/orix/quaternion/misorientation.py
index 7a800e7c..8687123c 100644
--- a/orix/quaternion/misorientation.py
+++ b/orix/quaternion/misorientation.py
@@ -20,7 +20,7 @@
from __future__ import annotations
from itertools import product as iproduct
-from typing import Any
+from typing import Any, Literal
import warnings
import dask.array as da
@@ -34,7 +34,11 @@
from orix._utils.deprecation import deprecated
from orix.quaternion.orientation_region import OrientationRegion
from orix.quaternion.rotation import Rotation
-from orix.quaternion.symmetry import C1, Symmetry, _get_unique_symmetry_elements
+from orix.quaternion.symmetry import (
+ C1,
+ Symmetry,
+ _get_unique_symmetry_elements,
+)
from orix.vector.miller import Miller
@@ -206,7 +210,10 @@ def from_align_vectors(
out = list(out)
try:
- out[0].symmetry = (initial.phase.point_group, other.phase.point_group)
+ out[0].symmetry = (
+ initial.phase.point_group,
+ other.phase.point_group,
+ )
except (AttributeError, ValueError):
pass
@@ -416,7 +423,7 @@ def reduce(self, verbose: bool = False) -> Misorientation:
def scatter(
self,
- projection: str = "axangle",
+ projection: Literal["axangle", "rodrigues", "homochoric"] = "axangle",
figure: mfigure.Figure | None = None,
position: int | tuple[int, int, int] | SubplotSpec = (1, 1, 1),
return_figure: bool = False,
@@ -425,18 +432,23 @@ def scatter(
figure_kwargs: dict | None = None,
**kwargs,
) -> mfigure.Figure | None:
- """Plot misorientations in axis-angle space or the Rodrigues
- fundamental zone.
+ """Plot misorientations in 3D Euclidean space using a
+ Neo-Eulerian projection.
Parameters
----------
projection
+ Which axis-angle projection to use for plotting into
+ Euclidean space. The options are "axangle" (default)
+ for a linear scaling, "homochoric" for an equal-volume
+ scaling, or "rodrigues" for an rectilinear scaling.
+
Which misorientation space to plot misorientations in,
- either ``"axangle"`` (default) or ``"rodrigues"``.
+ either "axangle" (default), "homochoric", or "rodrigues".
figure
- If given, a new plot axis :class:`~orix.plot.AxAnglePlot` or
- :class:`~orix.plot.RodriguesPlot` is added to the figure in
- the position specified by ``position``. If not given, a new
+ If given, a new plot axis with the projection specified
+ by ``projection`` is added to the figure in the position
+ specified by "position" If not given, a new
figure is created.
position
Where to add the new plot axis. 121 or (1, 2, 1) places it
@@ -447,8 +459,7 @@ def scatter(
Whether to return the figure. Default is ``False``.
wireframe_kwargs
Keyword arguments passed to
- :meth:`orix.plot.AxAnglePlot.plot_wireframe` or
- :meth:`orix.plot.RodriguesPlot.plot_wireframe`.
+ :meth:`orix.plot.AxAnglePlot.plot_wireframe`.
size
If not given, all misorientations are plotted. If given, a
random sample of this ``size`` of the misorientations is
@@ -457,9 +468,8 @@ def scatter(
Dictionary of keyword arguments passed to
:func:`matplotlib.pyplot.figure` if ``figure`` is not given.
**kwargs
- Keyword arguments passed to
- :meth:`orix.plot.AxAnglePlot.scatter` or
- :meth:`orix.plot.RodriguesPlot.scatter`.
+ Keyword arguments passed to the orix plotting class set by
+ 'position'.
Returns
-------
@@ -470,6 +480,7 @@ def scatter(
--------
orix.plot.AxAnglePlot
orix.plot.RodriguesPlot
+ orix.plot.HomochoricPlot
"""
from orix.plot.rotation_plot import _setup_rotation_plot
diff --git a/orix/quaternion/orientation.py b/orix/quaternion/orientation.py
index e9bd7281..08aef7ad 100644
--- a/orix/quaternion/orientation.py
+++ b/orix/quaternion/orientation.py
@@ -31,7 +31,11 @@
from orix.quaternion.misorientation import Misorientation
from orix.quaternion.rotation import Rotation
-from orix.quaternion.symmetry import C1, Symmetry, _get_unique_symmetry_elements
+from orix.quaternion.symmetry import (
+ C1,
+ Symmetry,
+ _get_unique_symmetry_elements,
+)
from orix.vector.miller import Miller
from orix.vector.vector3d import Vector3d
@@ -730,20 +734,21 @@ def scatter(
figure_kwargs: dict | None = None,
**kwargs,
) -> mfigure.Figure | None:
- """Plot orientations in axis-angle space, the Rodrigues
- fundamental zone, or an inverse pole figure (IPF) given a sample
- direction.
+ """Plot misorientations in 3D Euclidean space using either
+ a 3D neo-Eulerian projection or a 2D stereographic projection.
Parameters
----------
projection
- Which orientation space to plot orientations in, either
- "axangle" (default), "rodrigues" or "ipf" (inverse pole
- figure).
+ Which projection to use for plotting into Euclidean
+ space. The 3D options "axangle" (default) for a linear
+ scaling, "homochoric" for an equal-volume scaling, or
+ "rodrigues" for an rectilinear scaling. The 2D option
+ is "ipf" to give an inverse pole figure.
figure
- If given, a new plot axis :class:`~orix.plot.AxAnglePlot` or
- :class:`~orix.plot.RodriguesPlot` is added to the figure in
- the position specified by `position`. If not given, a new
+ If given, a new plot axis with the projection specified
+ by ``projection`` is added to the figure in the position
+ specified by ``position``. If not given, a new
figure is created.
position
Where to add the new plot axis. 121 or (1, 2, 1) places it
@@ -756,21 +761,21 @@ def scatter(
Keyword arguments passed to
:meth:`orix.plot.AxAnglePlot.plot_wireframe` or
:meth:`orix.plot.RodriguesPlot.plot_wireframe`.
+ :meth:`orix.plot.AxAnglePlot.plot_wireframe`. Only applies
+ to neu-Eulerian plots.
size
If not given, all orientations are plotted. If given, a
random sample of this `size` of the orientations is plotted.
direction
Sample direction to plot with respect to crystal directions.
If not given, the out of plane direction, sample Z, is used.
- Only used when plotting IPF(s).
+ Only used when plotting inverse pole figures.
figure_kwargs
Dictionary of keyword arguments passed to
:func:`matplotlib.pyplot.figure` if `figure` is not given.
**kwargs
- Keyword arguments passed to
- :meth:`orix.plot.AxAnglePlot.scatter`,
- :meth:`orix.plot.RodriguesPlot.scatter`, or
- :meth:`orix.plot.InversePoleFigurePlot.scatter`.
+ Keyword arguments passed to the orix plotting class set by
+ 'position'.
Returns
-------
@@ -779,7 +784,9 @@ def scatter(
See Also
--------
- orix.plot.AxAnglePlot, orix.plot.RodriguesPlot,
+ orix.plot.AxAnglePlot,
+ orix.plot.RodriguesPlot,
+ orix.plot.HomochoricPlot,
orix.plot.InversePoleFigurePlot
"""
if projection.lower() != "ipf":
diff --git a/orix/tests/test_plot/test_rotation_plot.py b/orix/tests/test_plot/test_rotation_plot.py
index feece1cc..9f0f3202 100644
--- a/orix/tests/test_plot/test_rotation_plot.py
+++ b/orix/tests/test_plot/test_rotation_plot.py
@@ -22,7 +22,7 @@
import numpy as np
import pytest
-from orix.plot import AxAnglePlot, RodriguesPlot, RotationPlot
+from orix.plot import AxAnglePlot, HomochoricPlot, RodriguesPlot, RotationPlot
from orix.quaternion import Misorientation, Orientation, OrientationRegion
from orix.quaternion.symmetry import C1, D6
@@ -34,6 +34,13 @@ def test_creation(self):
assert isinstance(ax, RodriguesPlot)
+class TestHomochoricPlot:
+ def test_creation(self):
+ fig = plt.figure()
+ ax = fig.add_subplot(projection="homochoric")
+ assert isinstance(ax, HomochoricPlot)
+
+
class TestAxisAnglePlot:
def test_creation(self):
fig = plt.figure()