Skip to content
Merged
2 changes: 2 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ Added
An example of a custom projection is the :class:`~orix.plot.StereographicPlot`.
This function replaces the previous behavior of relying on a side-effect of importing
the :mod:`orix.plot` module, which also registered the projections.
- Method ``from_path_ends()`` to return quaternions, rotations, orientations, or
misorientations along the shortest path between two or more points.

Changed
-------
Expand Down
154 changes: 154 additions & 0 deletions examples/plotting/visualizing_rotation_vector_paths.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
#
# 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 <http://www.gnu.org/licenses/>.
#

"""
===============================================
Visualizing paths between rotations and vectors
===============================================

This example shows how define and plot paths through either rotation or vector space.
This is akin to describing crystallographic fiber textures in metallurgy, or the
shortest arcs connecting points on the surface of a unit sphere.

In both cases, "shortest" is defined as the route that minimizes the movement required
to transform from point to point, which is typically not a stright line when plotted
into a euclidean projection (axis-angle, stereographic, etc.).
"""

import matplotlib as mpl
import matplotlib.pyplot as plt
import numpy as np

from orix.plot import register_projections
from orix.plot.direction_color_keys import DirectionColorKeyTSL
from orix.quaternion import Orientation, Rotation
from orix.quaternion.symmetry import C1, Oh
from orix.sampling import sample_S2
from orix.vector import Vector3d

register_projections() # Register our custom Matplotlib projections
np.random.seed(2319) # Reproducible random data

# Number of steps along each path
n_steps = 30

########################################################################################
# Example 1: Continuous path
# ==========================
#
# This plot traces the path of an object rotated 90 degrees around the x-axis, then 90
# degrees around the y-axis.

oris1 = Orientation.from_axes_angles(
[[1, 0, 0], [1, 0, 0], [0, 1, 0]], [0, 90, 90], degrees=True
)
oris1[2] = oris1[1] * oris1[2]
path = Orientation.from_path_ends(oris1, steps=n_steps)

# Create a list of RGBA color values for a gradient red line and blue line
colors1 = np.vstack(
[
mpl.colormaps["Reds"](np.linspace(0.5, 1, n_steps)),
mpl.colormaps["Blues"](np.linspace(0.5, 1, n_steps)),
]
)

# Here, we use the built-in plotting method from Orientation.scatter to auto-generate
# the plot.
# This is especially handy when plotting only a single set of orientations.
path.scatter(marker=">", c=colors1)
_ = plt.gca().set_title("Axis-angle space, two 90\N{DEGREE SIGN} rotations")

########################################################################################
# Example 2: Multiple paths
# =========================
#
# This plot shows several paths through the cubic (*m3m*) fundamental zone created by
# rotating 20 randomly chosen points 30 degrees around the z-axis.
# These paths are drawn in Rodrigues space, which is an equal-angle projection of
# rotation space.
# As such, notice how all lines tracing out axial rotations are straight, but lines
# starting closer to the center of the fundamental zone appear shorter.
#
# The same paths are then also plotted in the inverse pole figure (IPF) for the sample
# direction (0, 0, 1), IPF-Z.

# Random orientations with the cubic *m3m* crystal symmetry, located inside the
# fundamental zone of the proper point group (*432*)
oris2 = Orientation.random(10, symmetry=Oh).reduce()

# Rotation around the z-axis
ori_shift = Orientation.from_axes_angles([0, 0, 1], -30, degrees=True)

# Plot path for the first orientation (to get a figure to add to)
rot_end = ori_shift * oris2[0]
points = Orientation.stack([oris2[0], rot_end])
path = Orientation.from_path_ends(points, steps=n_steps)
path.symmetry = Oh

colors2 = mpl.colormaps["inferno"](np.linspace(0, 1, n_steps))
fig = path.scatter("rodrigues", position=121, return_figure=True, c=colors2)
path.scatter("ipf", position=122, figure=fig, c=colors2)

# Plot the rest
rod_ax, ipf_ax = fig.axes
rod_ax.set_title("Orientation paths in Rodrigues space")
ipf_ax.set_title("Vector paths in IPF-Z", pad=15)

for ori_start in oris2[1:]:
rot_end = ori_shift * ori_start
points = Orientation.stack([ori_start, rot_end])
path = Orientation.from_path_ends(points, steps=n_steps)
path.symmetry = Oh
rod_ax.scatter(path, c=colors2)
ipf_ax.scatter(path, c=colors2)

########################################################################################
# Example 3: Multiple vector paths
# ================================
#
# Rotate vectors around the (1, 1, 1) axis on a stereographic plot.

vec_ax = plt.subplot(projection="stereographic")
vec_ax.set_title(r"Stereographic")
vec_ax.set_labels("X", "Y")

ipf_colormap = DirectionColorKeyTSL(C1)

# Define a mesh of vectors with approximately 20 degree spacing, and within 80 degrees
# of the z-axis
vecs = sample_S2(20)
vecs = vecs[vecs.polar < np.deg2rad(80)]

# Define a 15 degree rotation around (1, 1, 1)
rot111 = Rotation.from_axes_angles([1, 1, 1], [0, 15], degrees=True)

for vec in vecs:
path_ends = rot111 * vec

# Handle case where path start end end are the same vector
if np.isclose(path_ends[0].dot(path_ends[1]), 1):
vec_ax.scatter(path_ends[0], c=ipf_colormap.direction2color(path_ends[0]))
continue

# Color each path using a gradient based on the IPF coloring
colors3 = ipf_colormap.direction2color(vec)
path = Vector3d.from_path_ends(path_ends, steps=100)
colors3_segment = colors3 * np.linspace(0.25, 1, path.size)[:, np.newaxis]
vec_ax.scatter(path, c=colors3_segment)
44 changes: 44 additions & 0 deletions orix/quaternion/misorientation.py
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,50 @@ def from_scipy_rotation(
M.symmetry = symmetry
return M

@classmethod
def from_path_ends(
cls, points: Misorientation, closed: bool = False, steps: int = 100
) -> Misorientation:
"""Return misorientations tracing the shortest path between two
or more consecutive points.

Parameters
----------
points
Two or more misorientations that define points along the
path.
closed
Add a final trip from the last point back to the first, thus
closing the loop. Default is False.
steps
Number of misorientations to return between each point along
the path given by *points*. Default is 100.

Returns
-------
path
Regularly spaced misorientations along the path.

See Also
--------
:class:`~orix.quaternion.Quaternion.from_path_ends`,
:class:`~orix.quaternion.Orientation.from_path_ends`

Notes
-----
This function traces the shortest path between points without
considering symmetry. The concept of "shortest path" is not
well-defined for misorientations, which can define multiple
symmetrically equivalent points with non-equivalent paths.
"""
points_type = type(points)
if points_type is not cls:
raise TypeError(
f"Points must be misorientations, not of type {points_type}"
)
out = Rotation.from_path_ends(points=points, closed=closed, steps=steps)
return cls(out.data, symmetry=points.symmetry)

@classmethod
def random(
cls,
Expand Down
41 changes: 41 additions & 0 deletions orix/quaternion/orientation.py
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,47 @@ def from_scipy_rotation(
O.symmetry = symmetry
return O

@classmethod
def from_path_ends(
cls, points: Orientation, closed: bool = False, steps: int = 100
) -> Misorientation:
"""Return orientations tracing the shortest path between two or
more consecutive points.

Parameters
----------
points
Two or more orientations that define points along the path.
closed
Add a final trip from the last point back to the first, thus
closing the loop. Default is False.
steps
Number of orientations to return between each point along
the path given by *points*. Default is 100.

Returns
-------
path
Regularly spaced orientations along the path.

See Also
--------
:class:`~orix.quaternion.Quaternion.from_path_ends`,
:class:`~orix.quaternion.Misorientation.from_path_ends`

Notes
-----
This function traces the shortest path between points without
considering symmetry. The concept of "shortest path" is not
well-defined for orientations, which can define multiple
symmetrically equivalent points with non-equivalent paths.
"""
points_type = type(points)
if points_type is not cls: # Disallow misorientations
raise TypeError(f"Points must be orientations, not of type {points_type}")
out = Rotation.from_path_ends(points=points, closed=closed, steps=steps)
return cls(out.data, symmetry=points.symmetry)

@classmethod
def random(
cls, shape: int | tuple[int, ...] = 1, symmetry: Symmetry | None = None
Expand Down
50 changes: 50 additions & 0 deletions orix/quaternion/quaternion.py
Original file line number Diff line number Diff line change
Expand Up @@ -691,6 +691,56 @@ def from_align_vectors(

return out[0] if len(out) == 1 else tuple(out)

@classmethod
def from_path_ends(
cls, points: Quaternion, closed: bool = False, steps: int = 100
) -> Quaternion:
"""Return quaternions tracing the shortest path between two or
more consecutive points.

Parameters
----------
points
Two or more quaternions that define points along the path.
closed
Add a final trip from the last point back to the first, thus
closing the loop. Default is False.
steps
Number of quaternions to return between each point along
the path given by *points*. Default is 100.

Returns
-------
path
Regularly spaced quaternions along the path.

See Also
--------
:class:`~orix.quaternion.Orientation.from_path_ends`,
:class:`~orix.quaternion.Misorientation.from_path_ends`
"""
points = points.flatten()
n = points.size
if not closed:
n = n - 1

path_list = []
for i in range(n):
# Get start and end for this part of the journey
qu1 = points[i]
qu2 = points[(i + 1) % (points.size)]
# Get the axis-angle pair describing this part
ax, ang = _conversions.qu2ax((~qu1 * qu2).data)
# Get steps along the trip and add them to the journey
angles = np.linspace(0, ang, steps)
qu_trip = Quaternion.from_axes_angles(ax, angles)
path_list.append((qu1 * qu_trip.flatten()).data)

path_data = np.concatenate(path_list, axis=0)
path = cls(path_data)

return path

@classmethod
def triple_cross(cls, q1: Quaternion, q2: Quaternion, q3: Quaternion) -> Quaternion:
"""Pointwise cross product of three quaternions.
Expand Down
59 changes: 59 additions & 0 deletions orix/tests/test_quaternion/test_orientation.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,60 @@ def test_symmetry_property_wrong_number_of_values_misorientation(error_type, val
o.symmetry = value


def test_from_path_ends():
"""check from_path_ends returns what you would expect and
preserves symmetry information.
In particular, ensure the class of the returned object matches the class
used for creating it, NOT the class of the object passed in.
"""
qu = Quaternion.random(10)
rot = Rotation.random(10)
ori = Orientation.random(10, Oh)
mori = Misorientation.random(10, [D3, Oh])

# Quaternion sanity checks
qu_path1 = Quaternion.from_path_ends(qu)
assert isinstance(qu_path1, Quaternion)
qu_path2 = Quaternion.from_path_ends(rot)
assert isinstance(qu_path2, Quaternion)
qu_path3 = Quaternion.from_path_ends(ori)
assert isinstance(qu_path3, Quaternion)
qu_path4 = Quaternion.from_path_ends(mori)
assert isinstance(qu_path4, Quaternion)

# Rotation sanity checks
rot_path1 = Rotation.from_path_ends(qu)
assert isinstance(rot_path1, Rotation)
rot_path2 = Rotation.from_path_ends(rot)
assert isinstance(rot_path2, Rotation)
rot_path3 = Rotation.from_path_ends(ori)
assert isinstance(rot_path3, Rotation)
rot_path4 = Rotation.from_path_ends(mori)
assert isinstance(rot_path4, Rotation)

# Misorientation sanity checks
with pytest.raises(TypeError, match="Points must be misorientations, "):
Misorientation.from_path_ends(qu)
with pytest.raises(TypeError, match="Points must be misorientations, "):
Misorientation.from_path_ends(rot)
with pytest.raises(TypeError, match="Points must be misorientations, "):
Misorientation.from_path_ends(ori)
mori_path = Misorientation.from_path_ends(mori)
assert isinstance(mori_path, Misorientation)
assert mori_path.symmetry == mori.symmetry

# Orientation sanity checks
with pytest.raises(TypeError, match="Points must be orientations, "):
Orientation.from_path_ends(qu)
with pytest.raises(TypeError, match="Points must be orientations, "):
Orientation.from_path_ends(rot)
ori_path = Orientation.from_path_ends(ori)
assert ori_path.symmetry == ori.symmetry
assert isinstance(ori_path, Orientation)
with pytest.raises(TypeError, match="Points must be orientations, "):
qu_path4 = Orientation.from_path_ends(mori)


class TestMisorientation:
def test_get_distance_matrix(self):
"""Compute distance between every misorientation in an instance
Expand Down Expand Up @@ -811,6 +865,11 @@ def test_in_fundamental_region(self):
region = np.radians(pg.euler_fundamental_region)
assert np.all(np.max(ori.in_euler_fundamental_region(), axis=0) <= region)

def test_from_path_ends(self):
# generate paths with orientations to check symmetry copying
wp_o = Orientation(data=np.eye(4)[:2], symmetry=Oh)
assert Orientation.from_path_ends(wp_o)._symmetry == (C1, Oh)

def test_inverse(self):
O1 = Orientation([np.sqrt(2) / 2, np.sqrt(2) / 2, 0, 0], D6)
O2 = ~O1
Expand Down
Loading
Loading