10000 Introduce natural 3D rotation with mouse (#28290) · matplotlib/matplotlib@57e187a · GitHub
[go: up one dir, main page]

Skip to content

Commit 57e187a

Browse files
Introduce natural 3D rotation with mouse (#28290)
* Natural 3D rotation with mouse - Addresses Issue #28288 - Introduces three-dimensional rotation by mouse using a variation on Ken Shoemake's ARCBALL - Provides a minimal Quaternion class, to avoid an additional dependency on a large package like 'numpy-quaternion' * Suggestions from reviewers - makes axes3d.Quaternion a private _Quaternion class - shortens as_cardan_angles() - adds two extra tests to test_axes3d::test_quaternion(): from_cardan_angles() should return a unit quaternion, and as_cardan_angles() should be insensitive to quaternion magnitude - updates "mplot3d View Angles" documentation (the mouse can control both azimuth, elevation, and roll; and matlab does have a roll angle nowadays) - put in a reference to quaternion multiplication using scalar and vector parts (wikipedia) - rename class method that constructs a quaternion from two vectors to `rotate_from_to()` - clarify docstring: "The quaternion for the shortest rotation from vector r1 to vector r2" - issue warning when vectors are anti-parallel: "shortest rotation is ambiguous" - construct a perpendicular vector for generic r2 == -r1 - add test case for anti-parallel vectors - add test for the warning - add reference to Ken Shoemake's arcball, in axes3d.py - point out that angles are in radians, not degrees, in quaternion class docstrings - in test_axes3d, add an import for axes3d._Quaternion, to avoid repetition - add Quaternion conjugate(), and tests for it - add Quaternion norm, and tests - add Quaternion normalize(), and tests - add Quaternion reciprocal(), and tests - add Quaternion division, and tests - add Quaternion rotate(vector), and a test * Update axes3d.py's arcball - change argument from 2 element numpy array to x, y - add type hints * Update doc/api/toolkits/mplot3d/view_angles.rst --------- Co-authored-by: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com>
1 parent e513cab commit 57e187a

File tree

4 files changed

+280
-19
lines changed

4 files changed

+280
-19
lines changed

doc/api/toolkits/mplot3d/view_angles.rst

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,17 @@ The position of the viewport "camera" in a 3D plot is defined by three angles:
1212
points towards the center of the plot box volume. The angle direction is a
1313
common convention, and is shared with
1414
`PyVista <https://docs.pyvista.org/api/core/camera.html>`_ and
15-
`MATLAB <https://www.mathworks.com/help/matlab/ref/view.html>`_
16-
(though MATLAB lacks a roll angle). Note that a positive roll angle rotates the
15+
`MATLAB <https://www.mathworks.com/help/matlab/ref/view.html>`_.
16+
Note that a positive roll angle rotates the
1717
viewing plane clockwise, so the 3d axes will appear to rotate
1818
counter-clockwise.
1919

2020
.. image:: /_static/mplot3d_view_angles.png
2121
:align: center
2222
:scale: 50
2323

24-
Rotating the plot using the mouse will control only the azimuth and elevation,
25-
but all three angles can be set programmatically::
24+
Rotating the plot using the mouse will control azimuth, elevation,
25+
as well as roll, and all three angles can be set programmatically::
2626

2727
import matplotlib.pyplot as plt
2828
ax = plt.figure().add_subplot(projection='3d')
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
Rotating 3d plots with the mouse
2+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
3+
4+
R 341A otating three-dimensional plots with the mouse has been made more intuitive.
5+
The plot now reacts the same way to mouse movement, independent of the
6+
particular orientation at hand; and it is possible to control all 3 rotational
7+
degrees of freedom (azimuth, elevation, and roll). It uses a variation on
8+
Ken Shoemake's ARCBALL [Shoemake1992]_.
9+
10+
.. [Shoemake1992] Ken Shoemake, "ARCBALL: A user interface for specifying
11+
three-dimensional rotation using a mouse." in Proceedings of Graphics
12+
Interface '92, 1992, pp. 151-156, https://doi.org/10.20380/GI1992.18

lib/mpl_toolkits/mplot3d/axes3d.py

Lines changed: 155 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import itertools
1515
import math
1616
import textwrap
17+
import warnings
1718

1819
import numpy as np
1920

@@ -1502,6 +1503,24 @@ def _calc_coord(self, xv, yv, renderer=None):
15021503
p2 = p1 - scale*vec
15031504
return p2, pane_idx
15041505

1506+
def _arcball(self, x: float, y: float) -> np.ndarray:
1507+
"""
1508+
Convert a point (x, y) to a point on a virtual trackball
1509+
This is Ken Shoemake's arcball
1510+
See: Ken Shoemake, "ARCBALL: A user interface for specifying
1511+
three-dimensional rotation using a mouse." in
1512+
Proceedings of Graphics Interface '92, 1992, pp. 151-156,
1513+
https://doi.org/10.20380/GI1992.18
1514+
"""
1515+
x *= 2
1516+
y *= 2
1517+
r2 = x*x + y*y
1518+
if r2 > 1:
1519+
p = np.array([0, x/math.sqrt(r2), y/math.sqrt(r2)])
1520+
else:
1521+
p = np.array([math.sqrt(1-r2), x, y])
1522+
return p
1523+
15051524
def _on_move(self, event):
15061525
"""
15071526
Mouse moving.
@@ -1537,12 +1556,23 @@ def _on_move(self, event):
15371556
if dx == 0 and dy == 0:
15381557
return
15391558

1559+
# Convert to quaternion
1560+
elev = np.deg2rad(self.elev)
1561+
azim = np.deg2rad(self.azim)
15401562
roll = np.deg2rad(self.roll)
1541-
delev = -(dy/h)*180*np.cos(roll) + (dx/w)*180*np.sin(roll)
1542-
dazim = -(dy/h)*180*np.sin(roll) - (dx/w)*180*np.cos(roll)
1543-
elev = self.elev + delev
1544-
azim = self.azim + dazim
1545-
roll = self.roll
1563+
q = _Quaternion.from_cardan_angles(elev, azim, roll)
1564+
1565+
# Update quaternion - a variation on Ken Shoemake's ARCBALL
1566+
current_vec = self._arcball(self._sx/w, self._sy/h)
1567+
new_vec = self._arcball(x/w, y/h)
1568+
dq = _Quaternion.rotate_from_to(current_vec, new_vec)
1569+
q = dq * q
1570+
1571+
# Convert to elev, azim, roll
1572+
elev, azim, roll = q.as_cardan_angles()
1573+
azim = np.rad2deg(azim)
1574+
elev = np.rad2deg(elev)
1575+
roll = np.rad2deg(roll)
15461576
vertical_axis = self._axis_names[self._vertical_axis]
15471577
self.view_init(
15481578
elev=elev,
@@ -3725,3 +3755,123 @@ def get_test_data(delta=0.05):
37253755
Y = Y * 10
37263756
Z = Z * 500
37273757
return X, Y, Z
3758+
3759+
3760+
class _Quaternion:
3761+
"""
3762+
Quaternions
3763+
consisting of scalar, along 1, and vector, with components along i, j, k
3764+
"""
3765+
3766+
def __init__(self, scalar, vector):
3767+
self.scalar = scalar
3768+
self.vector = np.array(vector)
3769+
3770+
def __neg__(self):
3771+
return self.__class__(-self.scalar, -self.vector)
3772+
3773+
def __mul__(self, other):
3774+
"""
3775+
Product of two quaternions
3776+
i*i = j*j = k*k = i*j*k = -1
3777+
Quaternion multiplication can be expressed concisely
3778+
using scalar and vector parts,
3779+
see <https://en.wikipedia.org/wiki/Quaternion#Scalar_and_vector_parts>
3780+
"""
3781+
return self.__class__(
3782+
self.scalar*other.scalar - np.dot(self.vector, other.vector),
3783+
self.scalar*other.vector + self.vector*other.scalar
3784+
+ np.cross(self.vector, other.vector))
3785+
3786+
def conjugate(self):
3787+
"""The conjugate quaternion -(1/2)*(q+i*q*i+j*q*j+k*q*k)"""
3788+
return self.__class__(self.scalar, -self.vector)
3789+
3790+
@property
3791+
def norm(self):
3792+
"""The 2-norm, q*q', a scalar"""
3793+
return self.scalar*self.scalar + np.dot(self.vector, self.vector)
3794+
3795+
def normalize(self):
3796+
"""Scaling such that norm equals 1"""
3797+
n = np.sqrt(self.norm)
3798+
return self.__class__(self.scalar/n, self.vector/n)
3799+
3800+
def reciprocal(self):
3801+
"""The reciprocal, 1/q = q'/(q*q') = q' / norm(q)"""
3802+
n = self.norm
3803+
return self.__class__(self.scalar/n, -self.vector/n)
3804+
3805+
def __div__(self, other):
3806+
return self*other.reciprocal()
3807+
3808+
__truediv__ = __div__
3809+
3810+
def rotate(self, v):
3811+
# Rotate the vector v by the quaternion q, i.e.,
3812+
# calculate (the vector part of) q*v/q
3813+
v = self.__class__(0, v)
3814+
v = self*v/self
3815+
return v.vector
3816+
3817+
def __eq__(self, other):
3818+
return (self.scalar == other.scalar) and (self.vector == other.vector).all
3819+
3820+
def __repr__(self):
3821+
return "_Quaternion({}, {})".format(repr(self.scalar), repr(self.vector))
3822+
3823+
@classmethod
3824+
def rotate_from_to(cls, r1, r2):
3825+
"""
3826+
The quaternion for the shortest rotation from vector r1 to vector r2
3827+
i.e., q = sqrt(r2*r1'), normalized.
3828+
If r1 and r2 are antiparallel, then the result is ambiguous;
3829+
a normal vector will be returned, and a warning will be issued.
3830+
"""
3831+
k = np.cross(r1, r2)
3832+
nk = np.linalg.norm(k)
3833+
th = np.arctan2(nk, np.dot(r1, r2))
3834+
th = th/2
3835+
if nk == 0: # r1 and r2 are parallel or anti-parallel
3836+
if np.dot(r1, r2) < 0:
3837+
warnings.warn("Rotation defined by anti-parallel vectors is ambiguous")
3838+
k = np.zeros(3)
3839+
k[np.argmin(r1*r1)] = 1 # basis vector most perpendicular to r1-r2
3840+
k = np.cross(r1, k)
3841+
k = k / np.linalg.norm(k) # unit vector normal to r1-r2
3842+
q = cls(0, k)
3843+
else:
3844+
q = cls(1, [0, 0, 0]) # = 1, no rotation
3845+
else:
3846+
q = cls(math.cos(th), k*math.sin(th)/nk)
3847+
return q
3848+
3849+
@classmethod
3850+
def from_cardan_angles(cls, elev, azim, roll):
3851+
"""
3852+
Converts the angles to a quaternion
3853+
q = exp((roll/2)*e_x)*exp((elev/2)*e_y)*exp((-azim/2)*e_z)
3854+
i.e., the angles are a kind of Tait-Bryan angles, -z,y',x".
3855+
The angles should be given in radians, not degrees.
3856+
"""
3857+
ca, sa = np.cos(azim/2), np.sin(azim/2)
3858+
ce, se = np.cos(elev/2), np.sin(elev/2)
3859+
cr, sr = np.cos(roll/2), np.sin(roll/2)
3860+
3861+
qw = ca*ce*cr + sa*se*sr
3862+
qx = ca*ce*sr - sa*se*cr
3863+
qy = ca*se*cr + sa*ce*sr
3864+
qz = ca*se*sr - sa*ce*cr
3865+
return cls(qw, [qx, qy, qz])
3866+
3867+
def as_cardan_angles(self):
3868+
"""
3869+
The inverse of `from_cardan_angles()`.
3870+
Note that the angles returned are in radians, not degrees.
3871+
"""
3872+
qw = self.scalar
3873+
qx, qy, qz = self.vector[..., :]
3874+
azim = np.arctan2(2*(-qw*qz+qx*qy), qw*qw+qx*qx-qy*qy-qz*qz)
3875+
elev = np.arcsin( 2*( qw*qy+qz*qx)/(qw*qw+qx*qx+qy*qy+qz*qz)) # noqa E201
3876+
roll = np.arctan2(2*( qw*qx-qy*qz), qw*qw-qx*qx-qy*qy+qz*qz) # noqa E201
3877+
return elev, azim, roll

lib/mpl_toolkits/mplot3d/tests/test_axes3d.py

Lines changed: 109 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import pytest
66

77
from mpl_toolkits.mplot3d import Axes3D, axes3d, proj3d, art3d
8+
from mpl_toolkits.mplot3d.axes3d import _Quaternion as Quaternion
89
import matplotlib as mpl
910
from matplotlib.backend_bases import (MouseButton, MouseEvent,
1011
NavigationToolbar2)
@@ -1766,29 +1767,127 @@ def test_shared_axes_retick():
17661767
assert ax2.get_zlim() == (-0.5, 2.5)
17671768

17681769

1770+
def test_quaternion():
1771+
# 1:
1772+
q1 = Quaternion(1, [0, 0, 0])
1773+
assert q1.scalar == 1
1774+
assert (q1.vector == [0, 0, 0]).all
1775+
# __neg__:
1776+
assert (-q1).scalar == -1
1777+
assert ((-q1).vector == [0, 0, 0]).all
1778+
# i, j, k:
1779+
qi = Quaternion(0, [1, 0, 0])
1780+
assert qi.scalar == 0
1781+
assert (qi.vector == [1, 0, 0]).all
1782+
qj = Quaternion(0, [0, 1, 0])
1783+
assert qj.scalar == 0
1784+
assert (qj.vector == [0, 1, 0]).all
1785+
qk = Quaternion(0, [0, 0, 1])
1786+
assert qk.scalar == 0
1787+
assert (qk.vector == [0, 0, 1]).all
1788+
# i^2 = j^2 = k^2 = -1:
1789+
assert qi*qi == -q1
1790+
assert qj*qj == -q1
1791+
assert qk*qk == -q1
1792+
# identity:
1793+
assert q1*qi == qi
1794+
assert q1*qj == qj
1795+
assert q1*qk == qk
1796+
# i*j=k, j*k=i, k*i=j:
1797+
assert qi*qj == qk
1798+
assert qj*qk == qi
1799+
assert qk*qi == qj
1800+
assert qj*qi == -qk
1801+
assert qk*qj == -qi
1802+
assert qi*qk == -qj
1803+
# __mul__:
1804+
assert (Quaternion(2, [3, 4, 5]) * Quaternion(6, [7, 8, 9])
1805+
== Quaternion(-86, [28, 48, 44]))
1806+
# conjugate():
1807+
for q in [q1, qi, qj, qk]:
1808+
assert q.conjugate().scalar == q.scalar
1809+
assert (q.conjugate().vector == -q.vector).all
1810+
assert q.conjugate().conjugate() == q
1811+
assert ((q*q.conjugate()).vector == 0).all
1812+
# norm:
1813+
q0 = Quaternion(0, [0, 0, 0])
1814+
assert q0.norm == 0
1815+
assert q1.norm == 1
1816+
assert qi.norm == 1
1817+
assert qj.norm == 1
1818+
assert qk.norm == 1
1819+
for q in [q0, q1, qi, qj, qk]:
1820+
assert q.norm == (q*q.conjugate()).scalar
1821+
# normalize():
1822+
for q in [
1823+
Quaternion(2, [0, 0, 0]),
1824+
Quaternion(0, [3, 0, 0]),
1825+
Quaternion(0, [0, 4, 0]),
1826+
Quaternion(0, [0, 0, 5]),
1827+
Quaternion(6, [7, 8, 9])
1828+
]:
1829+
assert q.normalize().norm == 1
1830+
# reciprocal():
1831+
for q in [q1, qi, qj, qk]:
1832+
assert q*q.reciprocal() == q1
1833+
assert q.reciprocal()*q == q1
1834+
# rotate():
1835+
assert (qi.rotate([1, 2, 3]) == np.array([1, -2, -3])).all
1836+
# rotate_from_to():
1837+
for r1, r2, q in [
1838+
([1, 0, 0], [0, 1, 0], Quaternion(np.sqrt(1/2), [0, 0, np.sqrt(1/2)])),
1839+
([1, 0, 0], [0, 0, 1], Quaternion(np.sqrt(1/2), [0, -np.sqrt(1/2), 0])),
1840+
([1, 0, 0], [1, 0, 0], Quaternion(1, [0, 0, 0]))
1841+
]:
1842+
assert Quaternion.rotate_from_to(r1, r2) == q
1843+
# rotate_from_to(), special case:
1844+
for r1 in [[1, 0, 0], [0, 1, 0], [0, 0, 1], [1, 1, 1]]:
1845+
r1 = np.array(r1)
1846+
with pytest.warns(UserWarning):
1847+
q = Quaternion.rotate_from_to(r1, -r1)
1848+
assert np.isclose(q.norm, 1)
1849+
assert np.dot(q.vector, r1) == 0
1850+
# from_cardan_angles(), as_cardan_angles():
1851+
for elev, azim, roll in [(0, 0, 0),
1852+
(90, 0, 0), (0, 90, 0), (0, 0, 90),
1853+
(0, 30, 30), (30, 0, 30), (30, 30, 0),
1854+
(47, 11, -24)]:
1855+
for mag in [1, 2]:
1856+
q = Quaternion.from_cardan_angles(
1857+
np.deg2rad(elev), np.deg2rad(azim), np.deg2rad(roll))
1858+
assert np.isclose(q.norm, 1)
1859+
q = Quaternion(mag * q.scalar, mag * q.vector)
1860+
e, a, r = np.rad2deg(Quaternion.as_cardan_angles(q))
1861+
assert np.isclose(e, elev)
1862+
assert np.isclose(a, azim)
1863+
assert np.isclose(r, roll)
1864+
1865+
17691866
def test_rotate():
17701867
"""Test rotating using the left mouse button."""
1771-
for roll in [0, 30]:
1868+
for roll, dx, dy, new_elev, new_azim, new_roll in [
1869+
[0, 0.5, 0, 0, -90, 0],
1870+
[30, 0.5, 0, 30, -90, 0],
1871+
[0, 0, 0.5, -90, 0, 0],
1872+
[30, 0, 0.5, -60, -90, 90],
1873+
[0, 0.5, 0.5, -45, -90, 45],
1874+
[30, 0.5, 0.5, -15, -90, 45]]:
17721875
fig = plt.figure()
17731876
ax = fig.add_subplot(1, 1, 1, projection='3d')
17741877
ax.view_init(0, 0, roll)
17751878
ax.figure.canvas.draw()
17761879

1777-
# drag mouse horizontally to change azimuth
1778-
dx = 0.1
1779-
dy = 0.2
1880+
# drag mouse to change orientation
17801881
ax._button_press(
17811882
mock_event(ax, button=MouseButton.LEFT, xdata=0, ydata=0))
17821883
ax._on_move(
17831884
mock_event(ax, button=MouseButton.LEFT,
17841885
xdata=dx*ax._pseudo_w, ydata=dy*ax._pseudo_h))
17851886
ax.figure.canvas.draw()
1786-
roll_radians = np.deg2rad(ax.roll)
1787-
cs = np.cos(roll_radians)
1788-
sn = np.sin(roll_radians)
1789-
assert ax.elev == (-dy*180*cs + dx*180*sn)
1790-
assert ax.azim == (-dy*180*sn - dx*180*cs)
1791-
assert ax.roll == roll
1887+
1888+
assert np.isclose(ax.elev, new_elev)
1889+
assert np.isclose(ax.azim, new_azim)
1890+
assert np.isclose(ax.roll, new_roll)
17921891

17931892

17941893
def test_pan():

0 commit comments

Comments
 (0)
0