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