diff --git a/doc/api/toolkits/mplot3d/view_angles.rst b/doc/api/toolkits/mplot3d/view_angles.rst index ce2c5f5698a5..9f032840450b 100644 --- a/doc/api/toolkits/mplot3d/view_angles.rst +++ b/doc/api/toolkits/mplot3d/view_angles.rst @@ -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 `_ and @@ -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. diff --git a/galleries/examples/mplot3d/2dcollections3d.py b/galleries/examples/mplot3d/2dcollections3d.py index a0155ebb0773..07a9626dff94 100644 --- a/galleries/examples/mplot3d/2dcollections3d.py +++ b/galleries/examples/mplot3d/2dcollections3d.py @@ -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() diff --git a/galleries/examples/mplot3d/box3d.py b/galleries/examples/mplot3d/box3d.py index bbe4accec183..5f5e51bc8e54 100644 --- a/galleries/examples/mplot3d/box3d.py +++ b/galleries/examples/mplot3d/box3d.py @@ -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 diff --git a/galleries/examples/mplot3d/rotate_axes3d_sgskip.py b/galleries/examples/mplot3d/rotate_axes3d_sgskip.py index 4474fab97460..3c66b269375a 100644 --- a/galleries/examples/mplot3d/rotate_axes3d_sgskip.py +++ b/galleries/examples/mplot3d/rotate_axes3d_sgskip.py @@ -33,7 +33,7 @@ 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: @@ -41,11 +41,11 @@ 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) diff --git a/galleries/examples/mplot3d/view_planes_3d.py b/galleries/examples/mplot3d/view_planes_3d.py index c4322d60fe93..822e8778ad86 100644 --- a/galleries/examples/mplot3d/view_planes_3d.py +++ b/galleries/examples/mplot3d/view_planes_3d.py @@ -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. """ @@ -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)) -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'], @@ -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'): diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index ea93d3eadf82..55ecf13986dc 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -139,7 +139,11 @@ def __init__( # 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: @@ -1094,25 +1098,66 @@ def clabel(self, *args, **kwargs): 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 + `_): + 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 + `_: + −z, +y', +x", rather than classic `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 @@ -1145,10 +1190,10 @@ def view_init(self, elev=None, azim=None, roll=None, vertical_axis="z", 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( @@ -1163,8 +1208,8 @@ def view_init(self, elev=None, azim=None, roll=None, vertical_axis="z", axes = [self] for ax in axes: - ax.elev = elev ax.azim = azim + ax.elev = elev ax.roll = roll ax._vertical_axis = vertical_axis @@ -1229,15 +1274,15 @@ def get_proj(self): # 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. @@ -1339,8 +1384,13 @@ def shareview(self, other): 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. @@ -1392,8 +1442,8 @@ def _set_view(self, view): # 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): @@ -1430,11 +1480,11 @@ def _rotation_coords(self): """ 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) 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}, " + f"elevation={norm_elev:.0f}\N{DEGREE SIGN}, " f"roll={norm_roll:.0f}\N{DEGREE SIGN}" ).replace("-", "\N{MINUS SIGN}") return coords @@ -1561,10 +1611,10 @@ def _on_move(self, event): 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) # Update quaternion - a variation on Ken Shoemake's ARCBALL current_vec = self._arcball(self._sx/w, self._sy/h) @@ -1573,18 +1623,13 @@ def _on_move(self, event): 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 @@ -3662,10 +3707,10 @@ def _extract_errs(err, data, lomask, himask): 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 @@ -4000,7 +4045,7 @@ def rotate_from_to(cls, r1, r2): 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) @@ -4027,4 +4072,4 @@ def as_cardan_angles(self): 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 diff --git a/lib/mpl_toolkits/mplot3d/tests/test_art3d.py b/lib/mpl_toolkits/mplot3d/tests/test_art3d.py index f4f7067b76bb..fcd4816c9b3b 100644 --- a/lib/mpl_toolkits/mplot3d/tests/test_art3d.py +++ b/lib/mpl_toolkits/mplot3d/tests/test_art3d.py @@ -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] diff --git a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py index c31398fb8260..24dafbdda926 100644 --- a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py +++ b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py @@ -117,22 +117,22 @@ def test_axes3d_repr(): @mpl3d_image_comparison(['axes3d_primary_views.png'], style='mpl20', tol=0.05 if platform.machine() == "arm64" else 0) def test_axes3d_primary_views(): - # (elev, azim, roll) - views = [(90, -90, 0), # XY - (0, -90, 0), # XZ + # (azim, elev, roll) + views = [(-90, 90, 0), # XY + (-90, 0, 0), # XZ (0, 0, 0), # YZ - (-90, 90, 0), # -XY - (0, 90, 0), # -XZ - (0, 180, 0)] # -YZ + (90, -90, 0), # -XY + (90, 0, 0), # -XZ + (180, 0, 0)] # -YZ # When viewing primary planes, draw the two visible axes so they intersect # at their low values fig, axs = plt.subplots(2, 3, subplot_kw={'projection': '3d'}) - for i, ax in enumerate(axs.flat): + for ax, (azim, elev, roll) in zip(axs.flat, views): ax.set_xlabel('x') ax.set_ylabel('y') ax.set_zlabel('z') ax.set_proj_type('ortho') - ax.view_init(elev=views[i][0], azim=views[i][1], roll=views[i][2]) + ax.view_init(elev, azim, roll) plt.tight_layout() @@ -168,15 +168,15 @@ def test_bar3d_shaded(): x2d, y2d = x2d.ravel(), y2d.ravel() z = x2d + y2d + 1 # Avoid triggering bug with zero-depth boxes. - views = [(30, -60, 0), (30, 30, 30), (-30, 30, -90), (300, -30, 0)] + views = [(-60, 30, 0), (30, 30, 30), (30, -30, -90), (-30, 300, 0)] fig = plt.figure(figsize=plt.figaspect(1 / len(views))) axs = fig.subplots( 1, len(views), subplot_kw=dict(projection='3d') ) - for ax, (elev, azim, roll) in zip(axs, views): + for ax, (azim, elev, roll) in zip(axs, views): ax.bar3d(x2d, y2d, x2d * 0, 1, 1, z, shade=True) - ax.view_init(elev=elev, azim=azim, roll=roll) + ax.view_init(elev, azim, roll) fig.canvas.draw() @@ -709,7 +709,7 @@ def test_surface3d_masked(): norm = mcolors.Normalize(vmax=z.max(), vmin=z.min()) colors = mpl.colormaps["plasma"](norm(z)) ax.plot_surface(x, y, z, facecolors=colors) - ax.view_init(30, -80, 0) + ax.view_init(elev=30, azim=-80, roll=0) @check_figures_equal(extensions=["png"]) @@ -749,7 +749,7 @@ def test_surface3d_masked_strides(): z = np.ma.masked_less(x * y, 2) ax.plot_surface(x, y, z, rstride=4, cstride=4) - ax.view_init(60, -45, 0) + ax.view_init(elev=60, azim=-45, roll=0) @mpl3d_image_comparison(['text3d.png'], remove_text=False, style='mpl20') @@ -1161,7 +1161,7 @@ def test_axes3d_cla(): def test_axes3d_rotated(): fig = plt.figure() ax = fig.add_subplot(1, 1, 1, projection='3d') - ax.view_init(90, 45, 0) # look down, rotated. Should be square + ax.view_init(elev=90, azim=45, roll=0) # look down, rotated. Should be square def test_plotsurface_1d_raises(): @@ -1827,16 +1827,16 @@ def test_set_zlim(): @check_figures_equal(extensions=["png"]) def test_shared_view(fig_test, fig_ref): - elev, azim, roll = 5, 20, 30 + azim, elev, roll = 20, 5, 30 ax1 = fig_test.add_subplot(131, projection="3d") ax2 = fig_test.add_subplot(132, projection="3d", shareview=ax1) ax3 = fig_test.add_subplot(133, projection="3d") ax3.shareview(ax1) - ax2.view_init(elev=elev, azim=azim, roll=roll, share=True) + ax2.view_init(elev, azim, roll, share=True) for subplot_num in (131, 132, 133): ax = fig_ref.add_subplot(subplot_num, projection="3d") - ax.view_init(elev=elev, azim=azim, roll=roll) + ax.view_init(elev, azim, roll) def test_shared_axes_retick(): @@ -1932,33 +1932,33 @@ def test_quaternion(): assert np.isclose(q.norm, 1) assert np.dot(q.vector, r1) == 0 # from_cardan_angles(), as_cardan_angles(): - for elev, azim, roll in [(0, 0, 0), + for azim, elev, roll in [(0, 0, 0), (90, 0, 0), (0, 90, 0), (0, 0, 90), (0, 30, 30), (30, 0, 30), (30, 30, 0), - (47, 11, -24)]: + (11, 47, -24)]: for mag in [1, 2]: q = Quaternion.from_cardan_angles( - np.deg2rad(elev), np.deg2rad(azim), np.deg2rad(roll)) + np.deg2rad(azim), np.deg2rad(elev), np.deg2rad(roll)) assert np.isclose(q.norm, 1) q = Quaternion(mag * q.scalar, mag * q.vector) - e, a, r = np.rad2deg(Quaternion.as_cardan_angles(q)) - assert np.isclose(e, elev) + a, e, r = np.rad2deg(Quaternion.as_cardan_angles(q)) assert np.isclose(a, azim) + assert np.isclose(e, elev) assert np.isclose(r, roll) def test_rotate(): """Test rotating using the left mouse button.""" - for roll, dx, dy, new_elev, new_azim, new_roll in [ - [0, 0.5, 0, 0, -90, 0], - [30, 0.5, 0, 30, -90, 0], - [0, 0, 0.5, -90, 0, 0], - [30, 0, 0.5, -60, -90, 90], - [0, 0.5, 0.5, -45, -90, 45], - [30, 0.5, 0.5, -15, -90, 45]]: + for roll, dx, dy, new_azim, new_elev, new_roll in [ + [0, 0.5, 0, -90, 0, 0], + [30, 0.5, 0, -90, 30, 0], + [0, 0, 0.5, 0, -90, 0], + [30, 0, 0.5, -90, -60, 90], + [0, 0.5, 0.5, -90, -45, 45], + [30, 0.5, 0.5, -90, -15, 45]]: fig = plt.figure() ax = fig.add_subplot(1, 1, 1, projection='3d') - ax.view_init(0, 0, roll) + ax.view_init(elev=0, azim=0, roll=roll) ax.figure.canvas.draw() # drag mouse to change orientation @@ -1969,8 +1969,8 @@ def test_rotate(): xdata=dx*ax._pseudo_w, ydata=dy*ax._pseudo_h)) ax.figure.canvas.draw() - assert np.isclose(ax.elev, new_elev) assert np.isclose(ax.azim, new_azim) + assert np.isclose(ax.elev, new_elev) assert np.isclose(ax.roll, new_roll)