Skip to content

Commit 077ba10

Browse files
committed
Make mplot3d mouse rotation style adjustable
Addresses Issue matplotlib#28408 - matplotlibrc: add axes3d.mouserotationstyle and axes3d.trackballsize - lib/matplotlib/rcsetup.py: add validation for axes3d.mouserotationstyle and axes3d.trackballsize - axes3d.py: implement various mouse rotation styles - update test_axes3d.py::test_rotate() - view_angles.rst: add documentation for the mouse rotation styles - update next_whats_new/mouse_rotation.rst
1 parent 4b35874 commit 077ba10

File tree

6 files changed

+266
-57
lines changed

6 files changed

+266
-57
lines changed

doc/api/toolkits/mplot3d/view_angles.rst

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,119 @@ further documented in the `.mplot3d.axes3d.Axes3D.view_init` API.
3838

3939
.. plot:: gallery/mplot3d/view_planes_3d.py
4040
:align: center
41+
42+
43+
Rotation with mouse
44+
===================
45+
46+
3D plots can be reoriented by dragging the mouse.
47+
There are various ways to accomplish this; the style of mouse rotation
48+
can be specified by setting ``rcParams.axes3d.mouserotationstyle``, see
49+
:doc:`/users/explain/customizing`.
50+
51+
Originally (with ``mouserotationstyle: azel``), the 2D mouse position
52+
corresponded directly to azimuth and elevation; this is also how it is done
53+
in `MATLAB <https://www.mathworks.com/help/matlab/ref/view.html>`_.
54+
This approach works fine for polar plots, where the *z* axis is special;
55+
however, it leads to a kind of 'gimbal lock' when looking down the *z* axis:
56+
the plot reacts differently to mouse movement, dependent on the particular
57+
orientation at hand. Also, 'roll' cannot be controlled.
58+
59+
As an alternative, there are various mouse rotation styles where the mouse
60+
manipulates a 'trackball'. In its simplest form (``mouserotationstyle: trackball``),
61+
the trackball rotates around an in-plane axis perpendicular to the mouse motion
62+
(it is as if there is a plate laying on the trackball; the plate itself is fixed
63+
in orientation, but you can drag the plate with the mouse, thus rotating the ball).
64+
This is more natural to work with than the ``azel`` style; however,
65+
the plot cannot be easily rotated around the viewing direction - one has to
66+
drag the mouse in circles with a handedness opposite to the desired rotation.
67+
68+
A different variety of trackball rotates along the shortest arc on the virtual
69+
sphere (``mouserotationstyle: arcball``); it is a variation on Ken Shoemake's
70+
ARCBALL [Shoemake1992]_. Rotating around the viewing direction is straightforward
71+
with it. Shoemake's original arcball is also available
72+
(``mouserotationstyle: Shoemake``); it is free of hysteresis, i.e.,
73+
returning mouse to the original position returns the figure to its original
74+
orientation, the rotation is independent of the details of the path the mouse
75+
took. However, Shoemake's arcball rotates at twice the angular rate of the
76+
mouse movement (it is quite noticeable, especially when adjusting roll).
77+
So it is a trade-off.
78+
79+
Shoemake's arcball has an abrupt edge; this is remedied in Holroyd's arcball
80+
(``mouserotationstyle: Holroyd``).
81+
82+
Henriksen et al. [Henriksen2002]_ provide an overview.
83+
84+
In summary:
85+
86+
.. list-table::
87+
:width: 100%
88+
:widths: 30 20 20 20 35
89+
90+
* - Style
91+
- traditional [1]_
92+
- incl. roll [2]_
93+
- uniform [3]_
94+
- path independent [4]_
95+
* - azel
96+
- ✔️
97+
- ❌
98+
- ❌
99+
- ✔️
100+
* - trackball
101+
- ❌
102+
- ~
103+
- ✔️
104+
- ❌
105+
* - arcball
106+
- ❌
107+
- ✔️
108+
- ✔️
109+
- ❌
110+
* - Shoemake
111+
- ❌
112+
- ✔️
113+
- ✔️
114+
- ✔️
115+
* - Holroyd
116+
- ❌
117+
- ✔️
118+
- ✔️
119+
- ✔️
120+
121+
122+
.. [1] The way it was historically; this is also MATLAB's style
123+
.. [2] Mouse controls roll too (not only azimuth and elevation)
124+
.. [3] Figure reacts the same way to mouse movements, regardless of orientation (no difference between 'poles' and 'equator')
125+
.. [4] Returning mouse to original position returns figure to original orientation (no hysteresis: rotation is independent of the details of the path the mouse took)
126+
127+
Try it out by adding a file ``matplotlibrc`` to folder ``matplotlib\galleries\examples\mplot3d``,
128+
with contents::
129+
130+
axes3d.mouserotationstyle: arcball
131+
132+
(or any of the other styles), and run a suitable example, e.g.::
133+
134+
python surfaced3d.py
135+
136+
(If eternal compatibility with the horrors of the past is less of a consideration
137+
for you, then it is likely that you would want to go with ``arcball``, ``Shoemake``,
138+
or ``Holroyd``.)
139+
140+
The size of the trackball or arcball can be adjusted by setting
141+
``rcParams.axes3d.trackballsize``, in units of the Axes bounding box;
142+
i.e., to make the trackball span the whole bounding box, set it to 1.
143+
A size of ca. 2/3 appears to work reasonably well.
144+
145+
----
146+
147+
.. [Shoemake1992] Ken Shoemake, "ARCBALL: A user interface for specifying
148+
three-dimensional rotation using a mouse", in Proceedings of Graphics
149+
Interface '92, 1992, pp. 151-156, https://doi.org/10.20380/GI1992.18
150+
151+
.. [Henriksen2002] Knud Henriksen, Jon Sporring, Kasper Hornbæk,
152+
"Virtual Trackballs Revisited", in Proceedings of DSAGM'2002:
153+
http://www.diku.dk/~kash/papers/DSAGM2002_henriksen.pdf;
154+
and in IEEE Transactions on Visualization
155+
and Computer Graphics, Volume 10, Issue 2, March-April 2004, pp. 206-216,
156+
https://doi.org/10.1109/TVCG.2004.1260772

doc/users/next_whats_new/mouse_rotation.rst

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@ Rotating 3d plots with the mouse
44
Rotating three-dimensional plots with the mouse has been made more intuitive.
55
The plot now reacts the same way to mouse movement, independent of the
66
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]_.
7+
degrees of freedom (azimuth, elevation, and roll). By default,
8+
it uses a variation on Ken Shoemake's ARCBALL [1]_.
9+
The particular style of mouse rotation can be set via
10+
``rcParams.axes3d.mouserotationstyle``.
11+
See also :doc:`/api/toolkits/mplot3d/view_angles`.
912

10-
.. [Shoemake1992] Ken Shoemake, "ARCBALL: A user interface for specifying
11-
three-dimensional rotation using a mouse." in Proceedings of Graphics
13+
.. [1] Ken Shoemake, "ARCBALL: A user interface for specifying
14+
three-dimensional rotation using a mouse", in Proceedings of Graphics
1215
Interface '92, 1992, pp. 151-156, https://doi.org/10.20380/GI1992.18

lib/matplotlib/mpl-data/matplotlibrc

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,10 @@
433433
#axes3d.yaxis.panecolor: (0.90, 0.90, 0.90, 0.5) # background pane on 3D axes
434434
#axes3d.zaxis.panecolor: (0.925, 0.925, 0.925, 0.5) # background pane on 3D axes
435435

436+
#axes3d.mouserotationstyle: arcball # {azel, trackball, arcball, Shoemake, Holroyd}
437+
# See also https://matplotlib.org/stable/api/toolkits/mplot3d/view_angles.html#rotation-with-mouse
438+
#axes3d.trackballsize: 0.667 # trackball diameter, in units of the Axes bbox
439+
436440
## ***************************************************************************
437441
## * AXIS *
438442
## ***************************************************************************

lib/matplotlib/rcsetup.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1132,6 +1132,10 @@ def _convert_validator_spec(key, conv):
11321132
"axes3d.yaxis.panecolor": validate_color, # 3d background pane
11331133
"axes3d.zaxis.panecolor": validate_color, # 3d background pane
11341134

1135+
"axes3d.mouserotationstyle": ["azel", "trackball", "arcball",
1136+
"Shoemake", "Holroyd"],
1137+
"axes3d.trackballsize": validate_float,
1138+
11351139
# scatter props
11361140
"scatter.marker": _validate_marker,
11371141
"scatter.edgecolors": validate_string,

lib/mpl_toolkits/mplot3d/axes3d.py

Lines changed: 59 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1508,7 +1508,7 @@ def _calc_coord(self, xv, yv, renderer=None):
15081508
p2 = p1 - scale*vec
15091509
return p2, pane_idx
15101510

1511-
def _arcball(self, x: float, y: float) -> np.ndarray:
1511+
def _arcball(self, x: float, y: float, Holroyd: bool) -> np.ndarray:
15121512
"""
15131513
Convert a point (x, y) to a point on a virtual trackball
15141514
This is Ken Shoemake's arcball
@@ -1517,13 +1517,20 @@ def _arcball(self, x: float, y: float) -> np.ndarray:
15171517
Proceedings of Graphics Interface '92, 1992, pp. 151-156,
15181518
https://doi.org/10.20380/GI1992.18
15191519
"""
1520-
x *= 2
1521-
y *= 2
1520+
s = mpl.rcParams['axes3d.trackballsize'] / 2
1521+
x /= s
1522+
y /= s
15221523
r2 = x*x + y*y
1523-
if r2 > 1:
1524-
p = np.array([0, x/math.sqrt(r2), y/math.sqrt(r2)])
1525-
else:
1526-
p = np.array([math.sqrt(1-r2), x, y])
1524+
if Holroyd:
1525+
if r2 > 0.5:
1526+
p = np.array([1/(2*math.sqrt(r2)), x, y])/math.sqrt(1/(4*r2)+r2)
1527+
else:
1528+
p = np.array([math.sqrt(1-r2), x, y])
1529+
else: # Shoemake
1530+
if r2 > 1:
1531+
p = np.array([0, x/math.sqrt(r2), y/math.sqrt(r2)])
1532+
else:
1533+
p = np.array([math.sqrt(1-r2), x, y])
15271534
return p
15281535

15291536
def _on_move(self, event):
@@ -1561,23 +1568,49 @@ def _on_move(self, event):
15611568
if dx == 0 and dy == 0:
15621569
return
15631570

1564-
# Convert to quaternion
1565-
elev = np.deg2rad(self.elev)
1566-
azim = np.deg2rad(self.azim)
1567-
roll = np.deg2rad(self.roll)
1568-
q = _Quaternion.from_cardan_angles(elev, azim, roll)
1569-
1570-
# Update quaternion - a variation on Ken Shoemake's ARCBALL
1571-
current_vec = self._arcball(self._sx/w, self._sy/h)
1572-
new_vec = self._arcball(x/w, y/h)
1573-
dq = _Quaternion.rotate_from_to(current_vec, new_vec)
1574-
q = dq * q
1575-
1576-
# Convert to elev, azim, roll
1577-
elev, azim, roll = q.as_cardan_angles()
1578-
azim = np.rad2deg(azim)
1579-
elev = np.rad2deg(elev)
1580-
roll = np.rad2deg(roll)
1571+
style = mpl.rcParams['axes3d.mouserotationstyle']
1572+
if style == 'azel':
1573+
roll = np.deg2rad(self.roll)
1574+
delev = -(dy/h)*180*np.cos(roll) + (dx/w)*180*np.sin(roll)
1575+
dazim = -(dy/h)*180*np.sin(roll) - (dx/w)*180*np.cos(roll)
1576+
elev = self.elev + delev
1577+
azim = self.azim + dazim
1578+
roll = self.roll
1579+
else:
1580+
# Convert to quaternion
1581+
elev = np.deg2rad(self.elev)
1582+
azim = np.deg2rad(self.azim)
1583+
roll = np.deg2rad(self.roll)
1584+
q = _Quaternion.from_cardan_angles(elev, azim, roll)
1585+
1586+
if style in ['arcball', 'Shoemake', 'Holroyd']:
1587+
# Update quaternion
1588+
is_Holroyd = (style == 'Holroyd')
1589+
current_vec = self._arcball(self._sx/w, self._sy/h, is_Holroyd)
1590+
new_vec = self._arcball(x/w, y/h, is_Holroyd)
1591+
if style == 'arcball':
1592+
dq = _Quaternion.rotate_from_to(current_vec, new_vec)
1593+
else: # 'Shoemake', 'Holroyd'
1594+
dq = _Quaternion(0, new_vec) * _Quaternion(0, -current_vec)
1595+
q = dq * q
1596+
elif style == 'trackball':
1597+
s = mpl.rcParams['axes3d.trackballsize'] / 2
1598+
k = np.array([0, -(y-self._sy)/h, (x-self._sx)/w]) / s
1599+
nk = np.linalg.norm(k)
1600+
th = nk / 2
1601+
dq = _Quaternion(math.cos(th), k*math.sin(th)/nk)
1602+
q = dq * q
1603+
else:
1604+
warnings.warn("Mouse rotation style (axes3d.mouserotationstyle: " +
1605+
style + ") not recognized.")
1606+
1607+
# Convert to elev, azim, roll
1608+
elev, azim, roll = q.as_cardan_angles()
1609+
elev = np.rad2deg(elev)
1610+
azim = np.rad2deg(azim)
1611+
roll = np.rad2deg(roll)
1612+
1613+
# update view
15811614
vertical_axis = self._axis_names[self._vertical_axis]
15821615
self.view_init(
15831616
elev=elev,
@@ -3984,7 +4017,7 @@ def rotate_from_to(cls, r1, r2):
39844017
k = np.cross(r1, r2)
39854018
nk = np.linalg.norm(k)
39864019
th = np.arctan2(nk, np.dot(r1, r2))
3987-
th = th/2
4020+
th /= 2
39884021
if nk == 0: # r1 and r2 are parallel or anti-parallel
39894022
if np.dot(r1, r2) < 0:
39904023
warnings.warn("Rotation defined by anti-parallel vectors is ambiguous")
@@ -4021,6 +4054,7 @@ def as_cardan_angles(self):
40214054
"""
40224055
The inverse of `from_cardan_angles()`.
40234056
Note that the angles returned are in radians, not degrees.
4057+
The angles are not sensitive to the quaternion's norm().
40244058
"""
40254059
qw = self.scalar
40264060
qx, qy, qz = self.vector[..., :]

lib/mpl_toolkits/mplot3d/tests/test_axes3d.py

Lines changed: 76 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1939,37 +1939,85 @@ def test_quaternion():
19391939
np.deg2rad(elev), np.deg2rad(azim), np.deg2rad(roll))
19401940
assert np.isclose(q.norm, 1)
19411941
q = Quaternion(mag * q.scalar, mag * q.vector)
1942-
e, a, r = np.rad2deg(Quaternion.as_cardan_angles(q))
1943-
assert np.isclose(e, elev)
1944-
assert np.isclose(a, azim)
1945-
assert np.isclose(r, roll)
1942+
np.testing.assert_allclose(np.rad2deg(Quaternion.as_cardan_angles(q)),
1943+
(elev, azim, roll), atol=1e-6)
19461944

19471945

1948-
def test_rotate():
1946+
@pytest.mark.parametrize('style',
1947+
('azel', 'trackball', 'arcball', 'Shoemake', 'Holroyd'))
1948+
def test_rotate(style):
19491949
"""Test rotating using the left mouse button."""
1950-
for roll, dx, dy, new_elev, new_azim, new_roll in [
1951-
[0, 0.5, 0, 0, -90, 0],
1952-
[30, 0.5, 0, 30, -90, 0],
1953-
[0, 0, 0.5, -90, 0, 0],
1954-
[30, 0, 0.5, -60, -90, 90],
1955-
[0, 0.5, 0.5, -45, -90, 45],
1956-
[30, 0.5, 0.5, -15, -90, 45]]:
1957-
fig = plt.figure()
1958-
ax = fig.add_subplot(1, 1, 1, projection='3d')
1959-
ax.view_init(0, 0, roll)
1960-
fig.canvas.draw()
1961-
1962-
# drag mouse to change orientation
1963-
ax._button_press(
1964-
mock_event(ax, button=MouseButton.LEFT, xdata=0, ydata=0))
1965-
ax._on_move(
1966-
mock_event(ax, button=MouseButton.LEFT,
1967-
xdata=dx*ax._pseudo_w, ydata=dy*ax._pseudo_h))
1968-
fig.canvas.draw()
1969-
1970-
assert np.isclose(ax.elev, new_elev)
1971-
assert np.isclose(ax.azim, new_azim)
1972-
assert np.isclose(ax.roll, new_roll)
1950+
if style == 'azel':
1951+
s = 0.5
1952+
else:
1953+
s = mpl.rcParams['axes3d.trackballsize'] / 2
1954+
s *= 0.5
1955+
with mpl.rc_context({'axes3d.mouserotationstyle': style}):
1956+
for roll, dx, dy in [
1957+
[0, 1, 0],
1958+
[30, 1, 0],
1959+
[0, 0, 1],
1960+
[30, 0, 1],
1961+
[0, 0.5, np.sqrt(3)/2],
1962+
[30, 0.5, np.sqrt(3)/2],
1963+
[0, 2, 0]]:
1964+
fig = plt.figure()
1965+
ax = fig.add_subplot(1, 1, 1, projection='3d')
1966+
ax.view_init(0, 0, roll)
1967+
ax.figure.canvas.draw()
1968+
1969+
# drag mouse to change orientation
1970+
ax._button_press(
1971+
mock_event(ax, button=MouseButton.LEFT, xdata=0, ydata=0))
1972+
ax._on_move(
1973+
mock_event(ax, button=MouseButton.LEFT,
1974+
xdata=s*dx*ax._pseudo_w, ydata=s*dy*ax._pseudo_h))
1975+
ax.figure.canvas.draw()
1976+
1977+
c = np.sqrt(3)/2
1978+
expectations = {
1979+
('azel', 0, 1, 0): (0, -45, 0),
1980+
('azel', 0, 0, 1): (-45, 0, 0),
1981+
('azel', 0, 0.5, c): (-38.971143, -22.5, 0),
1982+
('azel', 0, 2, 0): (0, -90, 0),
1983+
('azel', 30, 1, 0): (22.5, -38.971143, 30),
1984+
('azel', 30, 0, 1): (-38.971143, -22.5, 30),
1985+
('azel', 30, 0.5, c): (-22.5, -38.971143, 30),
1986+
1987+
('trackball', 0, 1, 0): (0, -28.64789, 0),
1988+
('trackball', 0, 0, 1): (-28.64789, 0, 0),
1989+
('trackball', 0, 0.5, c): (-24.531578, -15.277726, 3.340403),
1990+
('trackball', 0, 2, 0): (0, -180/np.pi, 0),
1991+
('trackball', 30, 1, 0): (13.869588, -25.319385, 26.87008),
1992+
('trackball', 30, 0, 1): (-24.531578, -15.277726, 33.340403),
1993+
('trackball', 30, 0.5, c): (-13.869588, -25.319385, 33.129920),
1994+
1995+
('arcball', 0, 1, 0): (0, -30, 0),
1996+
('arcball', 0, 0, 1): (-30, 0, 0),
1997+
('arcball', 0, 0.5, c): (-25.658906, -16.102114, 3.690068),
1998+
('arcball', 0, 2, 0): (0, -90, 0),
1999+
('arcball', 30, 1, 0): (14.477512, -26.565051, 26.565051),
2000+
('arcball', 30, 0, 1): (-25.658906, -16.102114, 33.690068),
2001+
('arcball', 30, 0.5, c): (-14.477512, -26.565051, 33.434949),
2002+
2003+
('Shoemake', 0, 1, 0): (0, -60, 0),
2004+
('Shoemake', 0, 0, 1): (-60, 0, 0),
2005+
('Shoemake', 0, 0.5, c): (-48.590378, -40.893395, 19.106605),
2006+
('Shoemake', 0, 2, 0): (0, 180, 0),
2007+
('Shoemake', 30, 1, 0): (25.658906, -56.309932, 16.102114),
2008+
('Shoemake', 30, 0, 1): (-48.590378, -40.893395, 49.106605),
2009+
('Shoemake', 30, 0.5, c): (-25.658906, -56.309932, 43.897886),
2010+
2011+
('Holroyd', 0, 1, 0): (0, -60, 0),
2012+
('Holroyd', 0, 0, 1): (-60, 0, 0),
2013+
('Holroyd', 0, 0.5, c): (-48.590378, -40.893395, 19.106605),
2014+
('Holroyd', 0, 2, 0): (0, -126.869898, 0),
2015+
('Holroyd', 30, 1, 0): (25.658906, -56.309932, 16.102114),
2016+
('Holroyd', 30, 0, 1): (-48.590378, -40.893395, 49.106605),
2017+
('Holroyd', 30, 0.5, c): (-25.658906, -56.309932, 43.897886)}
2018+
new_elev, new_azim, new_roll = expectations[(style, roll, dx, dy)]
2019+
np.testing.assert_allclose((ax.elev, ax.azim, ax.roll),
2020+
(new_elev, new_azim, new_roll), atol=1e-6)
19732021

19742022

19752023
def test_pan():

0 commit comments

Comments
 (0)