Skip to content

Commit 41bc84a

Browse files
authored
Merge pull request #600 from argerlt/Add-Rotation_from_path_ends
Add rotation from path ends
2 parents a6615a1 + 715a8d0 commit 41bc84a

File tree

7 files changed

+393
-0
lines changed

7 files changed

+393
-0
lines changed

CHANGELOG.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ Added
3434
An example of a custom projection is the :class:`~orix.plot.StereographicPlot`.
3535
This function replaces the previous behavior of relying on a side-effect of importing
3636
the :mod:`orix.plot` module, which also registered the projections.
37+
- Method ``from_path_ends()`` to return quaternions, rotations, orientations, or
38+
misorientations along the shortest path between two or more points.
3739

3840
Changed
3941
-------
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
#
2+
# Copyright 2018-2025 the orix developers
3+
#
4+
# This file is part of orix.
5+
#
6+
# orix is free software: you can redistribute it and/or modify
7+
# it under the terms of the GNU General Public License as published by
8+
# the Free Software Foundation, either version 3 of the License, or
9+
# (at your option) any later version.
10+
#
11+
# orix is distributed in the hope that it will be useful,
12+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
# GNU General Public License for more details.
15+
#
16+
# You should have received a copy of the GNU General Public License
17+
# along with orix. If not, see <http://www.gnu.org/licenses/>.
18+
#
19+
20+
"""
21+
===============================================
22+
Visualizing paths between rotations and vectors
23+
===============================================
24+
25+
This example shows how define and plot paths through either rotation or vector space.
26+
This is akin to describing crystallographic fiber textures in metallurgy, or the
27+
shortest arcs connecting points on the surface of a unit sphere.
28+
29+
In both cases, "shortest" is defined as the route that minimizes the movement required
30+
to transform from point to point, which is typically not a stright line when plotted
31+
into a euclidean projection (axis-angle, stereographic, etc.).
32+
"""
33+
34+
import matplotlib as mpl
35+
import matplotlib.pyplot as plt
36+
import numpy as np
37+
38+
from orix.plot import register_projections
39+
from orix.plot.direction_color_keys import DirectionColorKeyTSL
40+
from orix.quaternion import Orientation, Rotation
41+
from orix.quaternion.symmetry import C1, Oh
42+
from orix.sampling import sample_S2
43+
from orix.vector import Vector3d
44+
45+
register_projections() # Register our custom Matplotlib projections
46+
np.random.seed(2319) # Reproducible random data
47+
48+
# Number of steps along each path
49+
n_steps = 30
50+
51+
########################################################################################
52+
# Example 1: Continuous path
53+
# ==========================
54+
#
55+
# This plot traces the path of an object rotated 90 degrees around the x-axis, then 90
56+
# degrees around the y-axis.
57+
58+
oris1 = Orientation.from_axes_angles(
59+
[[1, 0, 0], [1, 0, 0], [0, 1, 0]], [0, 90, 90], degrees=True
60+
)
61+
oris1[2] = oris1[1] * oris1[2]
62+
path = Orientation.from_path_ends(oris1, steps=n_steps)
63+
64+
# Create a list of RGBA color values for a gradient red line and blue line
65+
colors1 = np.vstack(
66+
[
67+
mpl.colormaps["Reds"](np.linspace(0.5, 1, n_steps)),
68+
mpl.colormaps["Blues"](np.linspace(0.5, 1, n_steps)),
69+
]
70+
)
71+
72+
# Here, we use the built-in plotting method from Orientation.scatter to auto-generate
73+
# the plot.
74+
# This is especially handy when plotting only a single set of orientations.
75+
path.scatter(marker=">", c=colors1)
76+
_ = plt.gca().set_title("Axis-angle space, two 90\N{DEGREE SIGN} rotations")
77+
78+
########################################################################################
79+
# Example 2: Multiple paths
80+
# =========================
81+
#
82+
# This plot shows several paths through the cubic (*m3m*) fundamental zone created by
83+
# rotating 20 randomly chosen points 30 degrees around the z-axis.
84+
# These paths are drawn in Rodrigues space, which is an equal-angle projection of
85+
# rotation space.
86+
# As such, notice how all lines tracing out axial rotations are straight, but lines
87+
# starting closer to the center of the fundamental zone appear shorter.
88+
#
89+
# The same paths are then also plotted in the inverse pole figure (IPF) for the sample
90+
# direction (0, 0, 1), IPF-Z.
91+
92+
# Random orientations with the cubic *m3m* crystal symmetry, located inside the
93+
# fundamental zone of the proper point group (*432*)
94+
oris2 = Orientation.random(10, symmetry=Oh).reduce()
95+
96+
# Rotation around the z-axis
97+
ori_shift = Orientation.from_axes_angles([0, 0, 1], -30, degrees=True)
98+
99+
# Plot path for the first orientation (to get a figure to add to)
100+
rot_end = ori_shift * oris2[0]
101+
points = Orientation.stack([oris2[0], rot_end])
102+
path = Orientation.from_path_ends(points, steps=n_steps)
103+
path.symmetry = Oh
104+
105+
colors2 = mpl.colormaps["inferno"](np.linspace(0, 1, n_steps))
106+
fig = path.scatter("rodrigues", position=121, return_figure=True, c=colors2)
107+
path.scatter("ipf", position=122, figure=fig, c=colors2)
108+
109+
# Plot the rest
110+
rod_ax, ipf_ax = fig.axes
111+
rod_ax.set_title("Orientation paths in Rodrigues space")
112+
ipf_ax.set_title("Vector paths in IPF-Z", pad=15)
113+
114+
for ori_start in oris2[1:]:
115+
rot_end = ori_shift * ori_start
116+
points = Orientation.stack([ori_start, rot_end])
117+
path = Orientation.from_path_ends(points, steps=n_steps)
118+
path.symmetry = Oh
119+
rod_ax.scatter(path, c=colors2)
120+
ipf_ax.scatter(path, c=colors2)
121+
122+
########################################################################################
123+
# Example 3: Multiple vector paths
124+
# ================================
125+
#
126+
# Rotate vectors around the (1, 1, 1) axis on a stereographic plot.
127+
128+
vec_ax = plt.subplot(projection="stereographic")
129+
vec_ax.set_title(r"Stereographic")
130+
vec_ax.set_labels("X", "Y")
131+
132+
ipf_colormap = DirectionColorKeyTSL(C1)
133+
134+
# Define a mesh of vectors with approximately 20 degree spacing, and within 80 degrees
135+
# of the z-axis
136+
vecs = sample_S2(20)
137+
vecs = vecs[vecs.polar < np.deg2rad(80)]
138+
139+
# Define a 15 degree rotation around (1, 1, 1)
140+
rot111 = Rotation.from_axes_angles([1, 1, 1], [0, 15], degrees=True)
141+
142+
for vec in vecs:
143+
path_ends = rot111 * vec
144+
145+
# Handle case where path start end end are the same vector
146+
if np.isclose(path_ends[0].dot(path_ends[1]), 1):
147+
vec_ax.scatter(path_ends[0], c=ipf_colormap.direction2color(path_ends[0]))
148+
continue
149+
150+
# Color each path using a gradient based on the IPF coloring
151+
colors3 = ipf_colormap.direction2color(vec)
152+
path = Vector3d.from_path_ends(path_ends, steps=100)
153+
colors3_segment = colors3 * np.linspace(0.25, 1, path.size)[:, np.newaxis]
154+
vec_ax.scatter(path, c=colors3_segment)

orix/quaternion/misorientation.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,50 @@ def from_scipy_rotation(
273273
M.symmetry = symmetry
274274
return M
275275

276+
@classmethod
277+
def from_path_ends(
278+
cls, points: Misorientation, closed: bool = False, steps: int = 100
279+
) -> Misorientation:
280+
"""Return misorientations tracing the shortest path between two
281+
or more consecutive points.
282+
283+
Parameters
284+
----------
285+
points
286+
Two or more misorientations that define points along the
287+
path.
288+
closed
289+
Add a final trip from the last point back to the first, thus
290+
closing the loop. Default is False.
291+
steps
292+
Number of misorientations to return between each point along
293+
the path given by *points*. Default is 100.
294+
295+
Returns
296+
-------
297+
path
298+
Regularly spaced misorientations along the path.
299+
300+
See Also
301+
--------
302+
:class:`~orix.quaternion.Quaternion.from_path_ends`,
303+
:class:`~orix.quaternion.Orientation.from_path_ends`
304+
305+
Notes
306+
-----
307+
This function traces the shortest path between points without
308+
considering symmetry. The concept of "shortest path" is not
309+
well-defined for misorientations, which can define multiple
310+
symmetrically equivalent points with non-equivalent paths.
311+
"""
312+
points_type = type(points)
313+
if points_type is not cls:
314+
raise TypeError(
315+
f"Points must be misorientations, not of type {points_type}"
316+
)
317+
out = Rotation.from_path_ends(points=points, closed=closed, steps=steps)
318+
return cls(out.data, symmetry=points.symmetry)
319+
276320
@classmethod
277321
def random(
278322
cls,

orix/quaternion/orientation.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,47 @@ def from_scipy_rotation(
352352
O.symmetry = symmetry
353353
return O
354354

355+
@classmethod
356+
def from_path_ends(
357+
cls, points: Orientation, closed: bool = False, steps: int = 100
358+
) -> Misorientation:
359+
"""Return orientations tracing the shortest path between two or
360+
more consecutive points.
361+
362+
Parameters
363+
----------
364+
points
365+
Two or more orientations that define points along the path.
366+
closed
367+
Add a final trip from the last point back to the first, thus
368+
closing the loop. Default is False.
369+
steps
370+
Number of orientations to return between each point along
371+
the path given by *points*. Default is 100.
372+
373+
Returns
374+
-------
375+
path
376+
Regularly spaced orientations along the path.
377+
378+
See Also
379+
--------
380+
:class:`~orix.quaternion.Quaternion.from_path_ends`,
381+
:class:`~orix.quaternion.Misorientation.from_path_ends`
382+
383+
Notes
384+
-----
385+
This function traces the shortest path between points without
386+
considering symmetry. The concept of "shortest path" is not
387+
well-defined for orientations, which can define multiple
388+
symmetrically equivalent points with non-equivalent paths.
389+
"""
390+
points_type = type(points)
391+
if points_type is not cls: # Disallow misorientations
392+
raise TypeError(f"Points must be orientations, not of type {points_type}")
393+
out = Rotation.from_path_ends(points=points, closed=closed, steps=steps)
394+
return cls(out.data, symmetry=points.symmetry)
395+
355396
@classmethod
356397
def random(
357398
cls, shape: int | tuple[int, ...] = 1, symmetry: Symmetry | None = None

orix/quaternion/quaternion.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -691,6 +691,56 @@ def from_align_vectors(
691691

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

694+
@classmethod
695+
def from_path_ends(
696+
cls, points: Quaternion, closed: bool = False, steps: int = 100
697+
) -> Quaternion:
698+
"""Return quaternions tracing the shortest path between two or
699+
more consecutive points.
700+
701+
Parameters
702+
----------
703+
points
704+
Two or more quaternions that define points along the path.
705+
closed
706+
Add a final trip from the last point back to the first, thus
707+
closing the loop. Default is False.
708+
steps
709+
Number of quaternions to return between each point along
710+
the path given by *points*. Default is 100.
711+
712+
Returns
713+
-------
714+
path
715+
Regularly spaced quaternions along the path.
716+
717+
See Also
718+
--------
719+
:class:`~orix.quaternion.Orientation.from_path_ends`,
720+
:class:`~orix.quaternion.Misorientation.from_path_ends`
721+
"""
722+
points = points.flatten()
723+
n = points.size
724+
if not closed:
725+
n = n - 1
726+
727+
path_list = []
728+
for i in range(n):
729+
# Get start and end for this part of the journey
730+
qu1 = points[i]
731+
qu2 = points[(i + 1) % (points.size)]
732+
# Get the axis-angle pair describing this part
733+
ax, ang = _conversions.qu2ax((~qu1 * qu2).data)
734+
# Get steps along the trip and add them to the journey
735+
angles = np.linspace(0, ang, steps)
736+
qu_trip = Quaternion.from_axes_angles(ax, angles)
737+
path_list.append((qu1 * qu_trip.flatten()).data)
738+
739+
path_data = np.concatenate(path_list, axis=0)
740+
path = cls(path_data)
741+
742+
return path
743+
694744
@classmethod
695745
def triple_cross(cls, q1: Quaternion, q2: Quaternion, q3: Quaternion) -> Quaternion:
696746
"""Pointwise cross product of three quaternions.

orix/tests/test_quaternion/test_orientation.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,60 @@ def test_symmetry_property_wrong_number_of_values_misorientation(error_type, val
311311
o.symmetry = value
312312

313313

314+
def test_from_path_ends():
315+
"""check from_path_ends returns what you would expect and
316+
preserves symmetry information.
317+
In particular, ensure the class of the returned object matches the class
318+
used for creating it, NOT the class of the object passed in.
319+
"""
320+
qu = Quaternion.random(10)
321+
rot = Rotation.random(10)
322+
ori = Orientation.random(10, Oh)
323+
mori = Misorientation.random(10, [D3, Oh])
324+
325+
# Quaternion sanity checks
326+
qu_path1 = Quaternion.from_path_ends(qu)
327+
assert isinstance(qu_path1, Quaternion)
328+
qu_path2 = Quaternion.from_path_ends(rot)
329+
assert isinstance(qu_path2, Quaternion)
330+
qu_path3 = Quaternion.from_path_ends(ori)
331+
assert isinstance(qu_path3, Quaternion)
332+
qu_path4 = Quaternion.from_path_ends(mori)
333+
assert isinstance(qu_path4, Quaternion)
334+
335+
# Rotation sanity checks
336+
rot_path1 = Rotation.from_path_ends(qu)
337+
assert isinstance(rot_path1, Rotation)
338+
rot_path2 = Rotation.from_path_ends(rot)
339+
assert isinstance(rot_path2, Rotation)
340+
rot_path3 = Rotation.from_path_ends(ori)
341+
assert isinstance(rot_path3, Rotation)
342+
rot_path4 = Rotation.from_path_ends(mori)
343+
assert isinstance(rot_path4, Rotation)
344+
345+
# Misorientation sanity checks
346+
with pytest.raises(TypeError, match="Points must be misorientations, "):
347+
Misorientation.from_path_ends(qu)
348+
with pytest.raises(TypeError, match="Points must be misorientations, "):
349+
Misorientation.from_path_ends(rot)
350+
with pytest.raises(TypeError, match="Points must be misorientations, "):
351+
Misorientation.from_path_ends(ori)
352+
mori_path = Misorientation.from_path_ends(mori)
353+
assert isinstance(mori_path, Misorientation)
354+
assert mori_path.symmetry == mori.symmetry
355+
356+
# Orientation sanity checks
357+
with pytest.raises(TypeError, match="Points must be orientations, "):
358+
Orientation.from_path_ends(qu)
359+
with pytest.raises(TypeError, match="Points must be orientations, "):
360+
Orientation.from_path_ends(rot)
361+
ori_path = Orientation.from_path_ends(ori)
362+
assert ori_path.symmetry == ori.symmetry
363+
assert isinstance(ori_path, Orientation)
364+
with pytest.raises(TypeError, match="Points must be orientations, "):
365+
qu_path4 = Orientation.from_path_ends(mori)
366+
367+
314368
class TestMisorientation:
315369
def test_get_distance_matrix(self):
316370
"""Compute distance between every misorientation in an instance
@@ -811,6 +865,11 @@ def test_in_fundamental_region(self):
811865
region = np.radians(pg.euler_fundamental_region)
812866
assert np.all(np.max(ori.in_euler_fundamental_region(), axis=0) <= region)
813867

868+
def test_from_path_ends(self):
869+
# generate paths with orientations to check symmetry copying
870+
wp_o = Orientation(data=np.eye(4)[:2], symmetry=Oh)
871+
assert Orientation.from_path_ends(wp_o)._symmetry == (C1, Oh)
872+
814873
def test_inverse(self):
815874
O1 = Orientation([np.sqrt(2) / 2, np.sqrt(2) / 2, 0, 0], D6)
816875
O2 = ~O1

0 commit comments

Comments
 (0)