Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit ad26517

Browse files
committed
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'
1 parent 03a73c8 commit ad26517

File tree

3 files changed

+277
-15
lines changed

3 files changed

+277
-15
lines changed
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+
Rotating 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: 156 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,23 @@ def _calc_coord(self, xv, yv, renderer=None):
15021503
p2 = p1 - scale*vec
15031504
return p2, pane_idx
15041505

1506+
def _arcball(self, p):
1507+
"""
1508+
Convert a point p = [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+
p = 2 * p
1516+
r = p[0]**2 + p[1]**2
1517+
if r > 1:
1518+
p = np.concatenate(([0], p/math.sqrt(r)))
1519+
else:
1520+
p = np.concatenate(([math.sqrt(1-r)], p))
1521+
return p
1522+
15051523
def _on_move(self, event):
15061524
"""
15071525
Mouse moving.
@@ -1537,12 +1555,25 @@ def _on_move(self, event):
15371555
if dx == 0 and dy == 0:
15381556
return
15391557

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