From 511ad71aa5e3bbff6a35116a37d15cc0175987e1 Mon Sep 17 00:00:00 2001 From: MischaMegens2 <122418839+MischaMegens2@users.noreply.github.com> Date: Thu, 25 Jul 2024 13:19:36 -0700 Subject: [PATCH] Consistent azim-elev-roll order with minimal changes 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 Implement changes requested for PR #28395: - remove incohesive section from view_angles.rst - move some of the information to .mplot3d.axes3d.Axes3D.view_init - remove redundant kwargs as in .view_init(elev=elev, azim=azim, roll=roll) - cleanup test_axes3d_primary_views() - remove outdated next_whats_new item --- doc/api/toolkits/mplot3d/view_angles.rst | 4 +- galleries/examples/mplot3d/2dcollections3d.py | 2 +- galleries/examples/mplot3d/box3d.py | 2 +- .../examples/mplot3d/rotate_axes3d_sgskip.py | 8 +- galleries/examples/mplot3d/view_planes_3d.py | 20 +-- lib/mpl_toolkits/mplot3d/axes3d.py | 123 ++++++++++++------ lib/mpl_toolkits/mplot3d/tests/test_art3d.py | 4 +- lib/mpl_toolkits/mplot3d/tests/test_axes3d.py | 62 ++++----- 8 files changed, 135 insertions(+), 90 deletions(-) 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) pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy