Skip to content

Change order of mplot3d view angles to match order of rotations #28395

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions doc/api/toolkits/mplot3d/view_angles.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ How to define the view angle
============================

The position of the viewport "camera" in a 3D plot is defined by three angles:
*elevation*, *azimuth*, and *roll*. From the resulting position, it always
*azimuth*, *elevation*, and *roll*. From the resulting position, it always
points towards the center of the plot box volume. The angle direction is a
common convention, and is shared with
`PyVista <https://docs.pyvista.org/api/core/camera.html>`_ and
Expand All @@ -32,7 +32,7 @@ as well as roll, and all three angles can be set programmatically::
Primary view planes
===================

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

Expand Down
2 changes: 1 addition & 1 deletion galleries/examples/mplot3d/2dcollections3d.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,6 @@

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

plt.show()
2 changes: 1 addition & 1 deletion galleries/examples/mplot3d/box3d.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
)

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

# Colorbar
Expand Down
8 changes: 4 additions & 4 deletions galleries/examples/mplot3d/rotate_axes3d_sgskip.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,19 +33,19 @@
angle_norm = (angle + 180) % 360 - 180

# Cycle through a full rotation of elevation, then azimuth, roll, and all
elev = azim = roll = 0
azim = elev = roll = 0
if angle <= 360:
elev = angle_norm
elif angle <= 360*2:
azim = angle_norm
elif angle <= 360*3:
roll = angle_norm
else:
elev = azim = roll = angle_norm
azim = elev = roll = angle_norm

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

plt.draw()
plt.pause(.001)
20 changes: 10 additions & 10 deletions galleries/examples/mplot3d/view_planes_3d.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
======================

This example generates an "unfolded" 3D plot that shows each of the primary 3D
view planes. The elevation, azimuth, and roll angles required for each view are
view planes. The azimuth, elevation, and roll angles required for each view are
labeled. You could print out this image and fold it into a box where each plane
forms a side of the box.
"""
Expand All @@ -16,13 +16,13 @@ def annotate_axes(ax, text, fontsize=18):
ax.text(x=0.5, y=0.5, z=0.5, s=text,
va="center", ha="center", fontsize=fontsize, color="black")

# (plane, (elev, azim, roll))
Copy link
Contributor

@scottshambaugh scottshambaugh Aug 10, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change removes the ability to copy-paste these docs into one's functions, and is confusing wrt the keyword ordering below.

views = [('XY', (90, -90, 0)),
('XZ', (0, -90, 0)),
('YZ', (0, 0, 0)),
('-XY', (-90, 90, 0)),
('-XZ', (0, 90, 0)),
('-YZ', (0, 180, 0))]
# (plane, (azim, elev, roll))
views = [('XY', (-90, 90, 0)),
('XZ', (-90, 0, 0)),
('YZ', (0, 0, 0)),
('-XY', (90, -90, 0)),
('-XZ', (90, 0, 0)),
('-YZ', (180, 0, 0))]

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

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

for plane in ('XY', '-XY'):
Expand Down
123 changes: 84 additions & 39 deletions lib/mpl_toolkits/mplot3d/axes3d.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,11 @@

# inhibit autoscale_view until the axes are defined
# they can't be defined until Axes.__init__ has been called
self.view_init(self.initial_elev, self.initial_azim, self.initial_roll)
self.view_init(
elev=self.initial_elev,
azim=self.initial_azim,
roll=self.initial_roll,
)

self._sharez = sharez
if sharez is not None:
Expand Down Expand Up @@ -1094,25 +1098,66 @@
def view_init(self, elev=None, azim=None, roll=None, vertical_axis="z",
share=False):
"""
Set the elevation and azimuth of the Axes in degrees (not radians).
Set the azimuth, elevation, and roll of the Axes, in degrees (not radians).

This can be used to rotate the Axes programmatically.

To look normal to the primary planes, the following elevation and
azimuth angles can be used. A roll angle of 0, 90, 180, or 270 deg
will rotate these views while keeping the axes at right angles.
To look normal to the primary planes, the following azimuth and
elevation angles can be used:

========== ==== ====
view plane elev azim
view plane azim elev
========== ==== ====
XY 90 -90
XZ 0 -90
YZ 0 0
-XY -90 90
-XZ 0 90
-YZ 0 180
XY -90 90
XZ -90 0
YZ 0 0
-XY 90 -90
-XZ 90 0
-YZ 180 0
========== ==== ====

A roll angle of 0, 90, 180, or 270 degrees will rotate these views
while keeping the axes at right angles.

The *azim*, *elev*, *roll* angles correspond to rotations of the scene
observed by a stationary camera, as follows (assuming a default vertical
axis of 'z'). First, a left-handed rotation about the z axis is applied
(*azim*), then a right-handed rotation about the (camera) y axis (*elev*),
then a right-handed rotation about the (camera) x axis (*roll*). Here,
the z, y, and x axis are fixed axes (not the axes that rotate together
with the original scene).

If you would like to make the connection with quaternions (because
`Euler angles are horrible
<https://github.com/moble/quaternion/wiki/Euler-angles-are-horrible>`_):
the *azim*, *elev*, *roll* angles relate to the (intrinsic) rotation of
the plot via:

*q* = exp(+roll **x̂** / 2) exp(+elev **ŷ** / 2) exp(−azim **ẑ** / 2)

(with angles given in radians instead of degrees). That is, the angles
are a kind of `Tait-Bryan angles
<https://en.wikipedia.org/wiki/Euler_angles#Tait%E2%80%93Bryan_angles>`_:
−z, +y', +x", rather than classic `Euler angles
<https://en.wikipedia.org/wiki/Euler_angles>`_.

To avoid confusion, it makes sense to provide the view angles as keyword
arguments:
``.view_init(azim=-60, elev=30, roll=0, ...)``
This specific order is consistent with the order in which the rotations
actually are applied. Moreover, this particular order appears to be most
common, see :ghissue:`28353`, and it is consistent with the ordering in
`matplotlib.colors.LightSource`.

For backwards compatibility, positional arguments in the old sequence
(first ``elev``, then ``azim``) will still be accepted; but preferably,
use keyword arguments, to avoid confusion as to which angle is which.
Unfortunately, the order of the positional arguments does not match
the actual order of the applied rotations, and it differs from that
used in other programs (``azim, elev``). It would be nice if the sensible
(keyword) ordering could take over eventually.


Parameters
----------
elev : float, default: None
Expand Down Expand Up @@ -1145,10 +1190,10 @@

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

if elev is None:
elev = self.initial_elev
if azim is None:
azim = self.initial_azim
if elev is None:
elev = self.initial_elev
if roll is None:
roll = self.initial_roll
vertical_axis = _api.check_getitem(
Expand All @@ -1163,8 +1208,8 @@
axes = [self]

for ax in axes:
ax.elev = elev
ax.azim = azim
ax.elev = elev
ax.roll = roll
ax._vertical_axis = vertical_axis

Expand Down Expand Up @@ -1229,15 +1274,15 @@
# Look into the middle of the world coordinates:
R = 0.5 * box_aspect

# elev: elevation angle in the z plane.
# azim: azimuth angle in the xy plane.
# elev: elevation angle in the z plane.
# Coordinates for a point that rotates around the box of data.
# p0, p1 corresponds to rotating the box only around the vertical axis.
# p2 corresponds to rotating the box only around the horizontal axis.
elev_rad = np.deg2rad(self.elev)
azim_rad = np.deg2rad(self.azim)
p0 = np.cos(elev_rad) * np.cos(azim_rad)
p1 = np.cos(elev_rad) * np.sin(azim_rad)
elev_rad = np.deg2rad(self.elev)
p0 = np.cos(azim_rad) * np.cos(elev_rad)
p1 = np.sin(azim_rad) * np.cos(elev_rad)
p2 = np.sin(elev_rad)

# When changing vertical axis the coordinates changes as well.
Expand Down Expand Up @@ -1339,8 +1384,13 @@
self._shared_axes["view"].join(self, other)
self._shareview = other
vertical_axis = self._axis_names[other._vertical_axis]
self.view_init(elev=other.elev, azim=other.azim, roll=other.roll,
vertical_axis=vertical_axis, share=True)
self.view_init(
elev=other.elev,
azim=other.azim,
roll=other.roll,
vertical_axis=vertical_axis,
share=True,
)

def clear(self):
# docstring inherited.
Expand Down Expand Up @@ -1392,8 +1442,8 @@
# docstring inherited
props, (elev, azim, roll) = view
self.set(**props)
self.elev = elev
self.azim = azim
self.elev = elev
self.roll = roll

def format_zdata(self, z):
Expand Down Expand Up @@ -1430,11 +1480,11 @@
"""
Return the rotation angles as a string.
"""
norm_elev = art3d._norm_angle(self.elev)
norm_azim = art3d._norm_angle(self.azim)
norm_elev = art3d._norm_angle(self.elev)

Check warning on line 1484 in lib/mpl_toolkits/mplot3d/axes3d.py

View check run for this annotation

Codecov / codecov/patch

lib/mpl_toolkits/mplot3d/axes3d.py#L1484

Added line #L1484 was not covered by tests
norm_roll = art3d._norm_angle(self.roll)
coords = (f"elevation={norm_elev:.0f}\N{DEGREE SIGN}, "
f"azimuth={norm_azim:.0f}\N{DEGREE SIGN}, "
coords = (f"azimuth={norm_azim:.0f}\N{DEGREE SIGN}, "

Check warning on line 1486 in lib/mpl_toolkits/mplot3d/axes3d.py

View check run for this annotation

Codecov / codecov/patch

lib/mpl_toolkits/mplot3d/axes3d.py#L1486

Added line #L1486 was not covered by tests
f"elevation={norm_elev:.0f}\N{DEGREE SIGN}, "
f"roll={norm_roll:.0f}\N{DEGREE SIGN}"
).replace("-", "\N{MINUS SIGN}")
return coords
Expand Down Expand Up @@ -1561,10 +1611,10 @@
return

# Convert to quaternion
elev = np.deg2rad(self.elev)
azim = np.deg2rad(self.azim)
elev = np.deg2rad(self.elev)
roll = np.deg2rad(self.roll)
q = _Quaternion.from_cardan_angles(elev, azim, roll)
q = _Quaternion.from_cardan_angles(azim, elev, roll)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not the order that the function API defines. Please be very careful what you change.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

...well actually, the order is the order that the _Quaternion API defines, and the change is as intended. Since the quaternion class has its own API, it is not bound by my previous mistake to force it to be consistent with the Axes3D class. And furthermore, _Quaternion is an internal class, so I have little hesitation to change it back to the way it was initially, with a logical progression of arguments for from_cardan_angles() and as_cardan_angles().


# Update quaternion - a variation on Ken Shoemake's ARCBALL
current_vec = self._arcball(self._sx/w, self._sy/h)
Expand All @@ -1573,18 +1623,13 @@
q = dq * q

# Convert to elev, azim, roll
elev, azim, roll = q.as_cardan_angles()
azim, elev, roll = q.as_cardan_angles()
azim = np.rad2deg(azim)
elev = np.rad2deg(elev)
roll = np.rad2deg(roll)
vertical_axis = self._axis_names[self._vertical_axis]
self.view_init(
elev=elev,
azim=azim,
roll=roll,
vertical_axis=vertical_axis,
share=True,
)
self.view_init(elev, azim, roll, vertical_axis=vertical_axis,
share=True)
self.stale = True

# Pan
Expand Down Expand Up @@ -3662,10 +3707,10 @@
quiversize = np.mean(np.diff(quiversize, axis=0))
# quiversize is now in Axes coordinates, and to convert back to data
# coordinates, we need to run it through the inverse 3D transform. For
# consistency, this uses a fixed elevation, azimuth, and roll.
# consistency, this uses a fixed azimuth, elevation, and roll.
with cbook._setattr_cm(self, elev=0, azim=0, roll=0):
invM = np.linalg.inv(self.get_proj())
# elev=azim=roll=0 produces the Y-Z plane, so quiversize in 2D 'x' is
# azim=elev=roll=0 produces the Y-Z plane, so quiversize in 2D 'x' is
# 'y' in 3D, hence the 1 index.
quiversize = np.dot(invM, [quiversize, 0, 0, 0])[1]
# Quivers use a fixed 15-degree arrow head, so scale up the length so
Expand Down Expand Up @@ -4000,7 +4045,7 @@
return q

@classmethod
def from_cardan_angles(cls, elev, azim, roll):
def from_cardan_angles(cls, azim, elev, roll):
"""
Converts the angles to a quaternion
q = exp((roll/2)*e_x)*exp((elev/2)*e_y)*exp((-azim/2)*e_z)
Expand All @@ -4027,4 +4072,4 @@
azim = np.arctan2(2*(-qw*qz+qx*qy), qw*qw+qx*qx-qy*qy-qz*qz)
elev = np.arcsin( 2*( qw*qy+qz*qx)/(qw*qw+qx*qx+qy*qy+qz*qz)) # noqa E201
roll = np.arctan2(2*( qw*qx-qy*qz), qw*qw-qx*qx-qy*qy+qz*qz) # noqa E201
return elev, azim, roll
return azim, elev, roll
4 changes: 2 additions & 2 deletions lib/mpl_toolkits/mplot3d/tests/test_art3d.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ def test_scatter_3d_projection_conservation():
fig = plt.figure()
ax = fig.add_subplot(projection='3d')
# fix axes3d projection
ax.roll = 0
ax.elev = 0
ax.azim = -45
ax.elev = 0
ax.roll = 0
ax.stale = True

x = [0, 1, 2, 3, 4]
Expand Down
Loading
Loading