Skip to content

Commit 89a4d55

Browse files
committed
Change order of mplot3d view angles to match order of rotations
Change order of mplot3d view angles to azim-elev-roll, the order in which rotations occur, but keep the elev, azim positional order in view_init(), for backwards compatibility; use keyword arguments throughout, to avoid confusion: - in axes3d.py - in tests - in documentation - in examples Encourage the use of keyword arguments in the documentation for view_init() Add description of rotation to view_angles.rst (using Euler angles as well as quaternions) Add next_whats_new\view_angles_keyword_arguments.rst, encouraging keyword arguments, and explaining the view angles order (with reference)
1 parent bdbbb2d commit 89a4d55

File tree

9 files changed

+141
-80
lines changed

9 files changed

+141
-80
lines changed

doc/api/toolkits/mplot3d/view_angles.rst

+24-2
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ How to define the view angle
88
============================
99

1010
The position of the viewport "camera" in a 3D plot is defined by three angles:
11-
*elevation*, *azimuth*, and *roll*. From the resulting position, it always
11+
*azimuth*, *elevation*, and *roll*. From the resulting position, it always
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
@@ -28,11 +28,33 @@ as well as roll, and all three angles can be set programmatically::
2828
ax = plt.figure().add_subplot(projection='3d')
2929
ax.view_init(elev=30, azim=45, roll=15)
3030

31+
Rotation of the plot
32+
====================
33+
34+
The *azim*, *elev*, *roll* rotation order corresponds to rotation of the scene
35+
observed by a stationary camera. First, a left-handed rotation about the z axis is
36+
applied (*azim*), then a right-handed rotation about the (camera) y axis (*elev*), then a
37+
right-handed rotation about the (camera) x axis (*roll*). Here, the z, y, and x axis are fixed
38+
axes (not the axes that rotate together with the original scene).
39+
40+
This can also be thought of as orbiting a camera around a fixed scene, by reversing
41+
the order of operations. First the camera is rotated about the scene's +x axis
42+
(*roll*), then the +y axis (*elev*), then the −z axis (*azim*).
43+
44+
If you would like to make the connection with quaternions (because
45+
`Euler angles are horrible <https://github.com/moble/quaternion/wiki/Euler-angles-are-horrible>`_):
46+
the *azim*, *elev*, *roll* angles relate to the (intrinsic) rotation of the plot via:
47+
48+
*q* = exp( +roll **** / 2) exp( +elev **ŷ** / 2) exp( −azim **** / 2)
49+
50+
(with angles given in radians instead of degrees). That is, the angles are a kind of
51+
Tait-Bryan angles: −z, +y', +x", rather than classic Euler angles.
52+
3153

3254
Primary view planes
3355
===================
3456

35-
To look directly at the primary view planes, the required elevation, azimuth,
57+
To look directly at the primary view planes, the required azimuth, elevation,
3658
and roll angles are shown in the diagram of an "unfolded" plot below. These are
3759
further documented in the `.mplot3d.axes3d.Axes3D.view_init` API.
3860

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
Explicit view angle keyword arguments
2+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
3+
4+
It is recommended to provide the 'view angles' for three-dimensional plots
5+
(with ``mplot3d``) as keyword arguments
6+
``.view_init(azim=..., elev=..., roll=..., ...)``
7+
in this order. This is consistent with the order in which the rotations take
8+
place [1]; also, it matches the order used in other programs (first azim,
9+
then elev).
10+
For backwards compatibility, positional arguments in the old sequence
11+
(`elev, azim`) will still be accepted. But preferably, use keyword arguments
12+
to avoid confusion as to which angle is which.
13+
14+
15+
[1]: See :doc:`/api/toolkits/mplot3d/view_angles` for details. Also, this particular
16+
order appears to be most common; and it is consistent with the ordering in
17+
matplotlib's colors.py - see also :ghissue:`28353`.

galleries/examples/mplot3d/2dcollections3d.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,6 @@
4343

4444
# Customize the view angle so it's easier to see that the scatter points lie
4545
# on the plane y=0
46-
ax.view_init(elev=20., azim=-35, roll=0)
46+
ax.view_init(elev=20, azim=-35, roll=0)
4747

4848
plt.show()

galleries/examples/mplot3d/box3d.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@
6868
)
6969

7070
# Set zoom and angle view
71-
ax.view_init(40, -30, 0)
71+
ax.view_init(elev=40, azim=-30, roll=0)
7272
ax.set_box_aspect(None, zoom=0.9)
7373

7474
# Colorbar

galleries/examples/mplot3d/rotate_axes3d_sgskip.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -33,19 +33,19 @@
3333
angle_norm = (angle + 180) % 360 - 180
3434

3535
# Cycle through a full rotation of elevation, then azimuth, roll, and all
36-
elev = azim = roll = 0
36+
azim = elev = roll = 0
3737
if angle <= 360:
3838
elev = angle_norm
3939
elif angle <= 360*2:
4040
azim = angle_norm
4141
elif angle <= 360*3:
4242
roll = angle_norm
4343
else:
44-
elev = azim = roll = angle_norm
44+
azim = elev = roll = angle_norm
4545

4646
# Update the axis view and title
47-
ax.view_init(elev, azim, roll)
48-
plt.title('Elevation: %d°, Azimuth: %d°, Roll: %d°' % (elev, azim, roll))
47+
ax.view_init(elev=elev, azim=azim, roll=roll)
48+
plt.title('Azimuth: %d°, Elevation: %d°, Roll: %d°' % (azim, elev, roll))
4949

5050
plt.draw()
5151
plt.pause(.001)

galleries/examples/mplot3d/view_planes_3d.py

+12-11
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
======================
55
66
This example generates an "unfolded" 3D plot that shows each of the primary 3D
7-
view planes. The elevation, azimuth, and roll angles required for each view are
7+
view planes. The azimuth, elevation, and roll angles required for each view are
88
labeled. You could print out this image and fold it into a box where each plane
99
forms a side of the box.
1010
"""
@@ -16,13 +16,13 @@ def annotate_axes(ax, text, fontsize=18):
1616
ax.text(x=0.5, y=0.5, z=0.5, s=text,
1717
va="center", ha="center", fontsize=fontsize, color="black")
1818

19-
# (plane, (elev, azim, roll))
20-
views = [('XY', (90, -90, 0)),
21-
('XZ', (0, -90, 0)),
22-
('YZ', (0, 0, 0)),
23-
('-XY', (-90, 90, 0)),
24-
('-XZ', (0, 90, 0)),
25-
('-YZ', (0, 180, 0))]
19+
# (plane, (azim, elev, roll))
20+
views = [('XY', (-90, 90, 0)),
21+
('XZ', (-90, 0, 0)),
22+
('YZ', (0, 0, 0)),
23+
('-XY', (90, -90, 0)),
24+
('-XZ', (90, 0, 0)),
25+
('-YZ', (180, 0, 0))]
2626

2727
layout = [['XY', '.', 'L', '.'],
2828
['XZ', 'YZ', '-XZ', '-YZ'],
@@ -34,10 +34,10 @@ def annotate_axes(ax, text, fontsize=18):
3434
axd[plane].set_ylabel('y')
3535
axd[plane].set_zlabel('z')
3636
axd[plane].set_proj_type('ortho')
37-
axd[plane].view_init(elev=angles[0], azim=angles[1], roll=angles[2])
37+
axd[plane].view_init(elev=angles[1], azim=angles[0], roll=angles[2])
3838
axd[plane].set_box_aspect(None, zoom=1.25)
3939

40-
label = f'{plane}\n{angles}'
40+
label = f'{plane}\nazim={angles[0]}\nelev={angles[1]}\nroll={angles[2]}'
4141
annotate_axes(axd[plane], label, fontsize=14)
4242

4343
for plane in ('XY', '-XY'):
@@ -50,7 +50,8 @@ def annotate_axes(ax, text, fontsize=18):
5050
axd[plane].set_xticklabels([])
5151
axd[plane].set_xlabel('')
5252

53-
label = 'mplot3d primary view planes\n' + 'ax.view_init(elev, azim, roll)'
53+
label = 'mplot3d primary view planes\n' + \
54+
'ax.view_init(elev=elev, azim=azim, roll=roll)'
5455
annotate_axes(axd['L'], label, fontsize=18)
5556
axd['L'].set_axis_off()
5657

lib/mpl_toolkits/mplot3d/axes3d.py

+53-32
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,11 @@ def __init__(
139139

140140
# inhibit autoscale_view until the axes are defined
141141
# they can't be defined until Axes.__init__ has been called
142-
self.view_init(self.initial_elev, self.initial_azim, self.initial_roll)
142+
self.view_init(
143+
elev=self.initial_elev,
144+
azim=self.initial_azim,
145+
roll=self.initial_roll,
146+
)
143147

144148
self._sharez = sharez
145149
if sharez is not None:
@@ -1094,25 +1098,37 @@ def clabel(self, *args, **kwargs):
10941098
def view_init(self, elev=None, azim=None, roll=None, vertical_axis="z",
10951099
share=False):
10961100
"""
1097-
Set the elevation and azimuth of the Axes in degrees (not radians).
1101+
Set the azimuth, elevation, and roll of the Axes, in degrees (not radians).
10981102
10991103
This can be used to rotate the Axes programmatically.
11001104
1101-
To look normal to the primary planes, the following elevation and
1102-
azimuth angles can be used. A roll angle of 0, 90, 180, or 270 deg
1103-
will rotate these views while keeping the axes at right angles.
1105+
To look normal to the primary planes, the following azimuth and
1106+
elevation angles can be used:
11041107
11051108
========== ==== ====
1106-
view plane elev azim
1109+
view plane azim elev
11071110
========== ==== ====
1108-
XY 90 -90
1109-
XZ 0 -90
1110-
YZ 0 0
1111-
-XY -90 90
1112-
-XZ 0 90
1113-
-YZ 0 180
1111+
XY -90 90
1112+
XZ -90 0
1113+
YZ 0 0
1114+
-XY 90 -90
1115+
-XZ 90 0
1116+
-YZ 180 0
11141117
========== ==== ====
11151118
1119+
A roll angle of 0, 90, 180, or 270 degrees will rotate these views
1120+
while keeping the axes at right angles.
1121+
1122+
It is recommended to provide these parameter as keyword arguments:
1123+
``.view_init(azim=-60, elev=30, roll=0, ...)``
1124+
in this order (i.e., the order in which the rotations actually are
1125+
applied).
1126+
For backwards compatibility, positional arguments in the old sequence
1127+
(first elev, then azim) will still be accepted; unfortunately,
1128+
this order does not match the actual order of the applied rotations,
1129+
and it differs from that used in other programs (`azim, elev`).
1130+
It would be nice if the sensible ordering could take over eventually.
1131+
11161132
Parameters
11171133
----------
11181134
elev : float, default: None
@@ -1145,10 +1161,10 @@ def view_init(self, elev=None, azim=None, roll=None, vertical_axis="z",
11451161

11461162
self._dist = 10 # The camera distance from origin. Behaves like zoom
11471163

1148-
if elev is None:
1149-
elev = self.initial_elev
11501164
if azim is None:
11511165
azim = self.initial_azim
1166+
if elev is None:
1167+
elev = self.initial_elev
11521168
if roll is None:
11531169
roll = self.initial_roll
11541170
vertical_axis = _api.check_getitem(
@@ -1163,8 +1179,8 @@ def view_init(self, elev=None, azim=None, roll=None, vertical_axis="z",
11631179
axes = [self]
11641180

11651181
for ax in axes:
1166-
ax.elev = elev
11671182
ax.azim = azim
1183+
ax.elev = elev
11681184
ax.roll = roll
11691185
ax._vertical_axis = vertical_axis
11701186

@@ -1229,15 +1245,15 @@ def get_proj(self):
12291245
# Look into the middle of the world coordinates:
12301246
R = 0.5 * box_aspect
12311247

1232-
# elev: elevation angle in the z plane.
12331248
# azim: azimuth angle in the xy plane.
1249+
# elev: elevation angle in the z plane.
12341250
# Coordinates for a point that rotates around the box of data.
12351251
# p0, p1 corresponds to rotating the box only around the vertical axis.
12361252
# p2 corresponds to rotating the box only around the horizontal axis.
1237-
elev_rad = np.deg2rad(self.elev)
12381253
azim_rad = np.deg2rad(self.azim)
1239-
p0 = np.cos(elev_rad) * np.cos(azim_rad)
1240-
p1 = np.cos(elev_rad) * np.sin(azim_rad)
1254+
elev_rad = np.deg2rad(self.elev)
1255+
p0 = np.cos(azim_rad) * np.cos(elev_rad)
1256+
p1 = np.sin(azim_rad) * np.cos(elev_rad)
12411257
p2 = np.sin(elev_rad)
12421258

12431259
# When changing vertical axis the coordinates changes as well.
@@ -1339,8 +1355,13 @@ def shareview(self, other):
13391355
self._shared_axes["view"].join(self, other)
13401356
self._shareview = other
13411357
vertical_axis = self._axis_names[other._vertical_axis]
1342-
self.view_init(elev=other.elev, azim=other.azim, roll=other.roll,
1343-
vertical_axis=vertical_axis, share=True)
1358+
self.view_init(
1359+
elev=other.elev,
1360+
azim=other.azim,
1361+
roll=other.roll,
1362+
vertical_axis=vertical_axis,
1363+
share=True,
1364+
)
13441365

13451366
def clear(self):
13461367
# docstring inherited.
@@ -1392,8 +1413,8 @@ def _set_view(self, view):
13921413
# docstring inherited
13931414
props, (elev, azim, roll) = view
13941415
self.set(**props)
1395-
self.elev = elev
13961416
self.azim = azim
1417+
self.elev = elev
13971418
self.roll = roll
13981419

13991420
def format_zdata(self, z):
@@ -1430,11 +1451,11 @@ def _rotation_coords(self):
14301451
"""
14311452
Return the rotation angles as a string.
14321453
"""
1433-
norm_elev = art3d._norm_angle(self.elev)
14341454
norm_azim = art3d._norm_angle(self.azim)
1455+
norm_elev = art3d._norm_angle(self.elev)
14351456
norm_roll = art3d._norm_angle(self.roll)
1436-
coords = (f"elevation={norm_elev:.0f}\N{DEGREE SIGN}, "
1437-
f"azimuth={norm_azim:.0f}\N{DEGREE SIGN}, "
1457+
coords = (f"azimuth={norm_azim:.0f}\N{DEGREE SIGN}, "
1458+
f"elevation={norm_elev:.0f}\N{DEGREE SIGN}, "
14381459
f"roll={norm_roll:.0f}\N{DEGREE SIGN}"
14391460
).replace("-", "\N{MINUS SIGN}")
14401461
return coords
@@ -1561,10 +1582,10 @@ def _on_move(self, event):
15611582
return
15621583

15631584
# Convert to quaternion
1564-
elev = np.deg2rad(self.elev)
15651585
azim = np.deg2rad(self.azim)
1586+
elev = np.deg2rad(self.elev)
15661587
roll = np.deg2rad(self.roll)
1567-
q = _Quaternion.from_cardan_angles(elev, azim, roll)
1588+
q = _Quaternion.from_cardan_angles(azim, elev, roll)
15681589

15691590
# Update quaternion - a variation on Ken Shoemake's ARCBALL
15701591
current_vec = self._arcball(self._sx/w, self._sy/h)
@@ -1573,7 +1594,7 @@ def _on_move(self, event):
15731594
q = dq * q
15741595

15751596
# Convert to elev, azim, roll
1576-
elev, azim, roll = q.as_cardan_angles()
1597+
azim, elev, roll = q.as_cardan_angles()
15771598
azim = np.rad2deg(azim)
15781599
elev = np.rad2deg(elev)
15791600
roll = np.rad2deg(roll)
@@ -3662,10 +3683,10 @@ def _extract_errs(err, data, lomask, himask):
36623683
quiversize = np.mean(np.diff(quiversize, axis=0))
36633684
# quiversize is now in Axes coordinates, and to convert back to data
36643685
# coordinates, we need to run it through the inverse 3D transform. For
3665-
# consistency, this uses a fixed elevation, azimuth, and roll.
3686+
# consistency, this uses a fixed azimuth, elevation, and roll.
36663687
with cbook._setattr_cm(self, elev=0, azim=0, roll=0):
36673688
invM = np.linalg.inv(self.get_proj())
3668-
# elev=azim=roll=0 produces the Y-Z plane, so quiversize in 2D 'x' is
3689+
# azim=elev=roll=0 produces the Y-Z plane, so quiversize in 2D 'x' is
36693690
# 'y' in 3D, hence the 1 index.
36703691
quiversize = np.dot(invM, [quiversize, 0, 0, 0])[1]
36713692
# Quivers use a fixed 15-degree arrow head, so scale up the length so
@@ -4000,7 +4021,7 @@ def rotate_from_to(cls, r1, r2):
40004021
return q
40014022

40024023
@classmethod
4003-
def from_cardan_angles(cls, elev, azim, roll):
4024+
def from_cardan_angles(cls, azim, elev, roll):
40044025
"""
40054026
Converts the angles to a quaternion
40064027
q = exp((roll/2)*e_x)*exp((elev/2)*e_y)*exp((-azim/2)*e_z)
@@ -4027,4 +4048,4 @@ def as_cardan_angles(self):
40274048
azim = np.arctan2(2*(-qw*qz+qx*qy), qw*qw+qx*qx-qy*qy-qz*qz)
40284049
elev = np.arcsin( 2*( qw*qy+qz*qx)/(qw*qw+qx*qx+qy*qy+qz*qz)) # noqa E201
40294050
roll = np.arctan2(2*( qw*qx-qy*qz), qw*qw-qx*qx-qy*qy+qz*qz) # noqa E201
4030-
return elev, azim, roll
4051+
return azim, elev, roll

lib/mpl_toolkits/mplot3d/tests/test_art3d.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ def test_scatter_3d_projection_conservation():
1010
fig = plt.figure()
1111
ax = fig.add_subplot(projection='3d')
1212
# fix axes3d projection
13-
ax.roll = 0
14-
ax.elev = 0
1513
ax.azim = -45
14+
ax.elev = 0
15+
ax.roll = 0
1616
ax.stale = True
1717

1818
x = [0, 1, 2, 3, 4]

0 commit comments

Comments
 (0)