diff --git a/doc/users/next_whats_new/3d_plot_roll_angle.rst b/doc/users/next_whats_new/3d_plot_roll_angle.rst new file mode 100644 index 000000000000..5ebb38a2ff16 --- /dev/null +++ b/doc/users/next_whats_new/3d_plot_roll_angle.rst @@ -0,0 +1,21 @@ +3D plots gained a 3rd "roll" viewing angle +------------------------------------------ + +3D plots can now be viewed from any orientation with the addition of a 3rd roll +angle, which rotates the plot about the viewing axis. Interactive rotation +using the mouse still only controls elevation and azimuth, meaning that this +feature is relevant to users who create more complex camera angles +programmatically. The default roll angle of 0 is backwards-compatible with +existing 3D plots. + +.. plot:: + :include-source: true + + from mpl_toolkits.mplot3d import axes3d + import matplotlib.pyplot as plt + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + X, Y, Z = axes3d.get_test_data(0.05) + ax.plot_wireframe(X, Y, Z, rstride=10, cstride=10) + ax.view_init(elev=0, azim=0, roll=30) + plt.show() diff --git a/examples/mplot3d/2dcollections3d.py b/examples/mplot3d/2dcollections3d.py index f4d57416355d..5760d429775e 100644 --- a/examples/mplot3d/2dcollections3d.py +++ b/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) +ax.view_init(elev=20., azim=-35, roll=0) plt.show() diff --git a/examples/mplot3d/box3d.py b/examples/mplot3d/box3d.py index 7dc82248c3df..f5642e229110 100644 --- a/examples/mplot3d/box3d.py +++ b/examples/mplot3d/box3d.py @@ -68,7 +68,7 @@ ) # Set distance and angle view -ax.view_init(40, -30) +ax.view_init(40, -30, 0) ax.dist = 11 # Colorbar diff --git a/examples/mplot3d/rotate_axes3d_sgskip.py b/examples/mplot3d/rotate_axes3d_sgskip.py index be8adf3c29a0..0b6497d5118b 100644 --- a/examples/mplot3d/rotate_axes3d_sgskip.py +++ b/examples/mplot3d/rotate_axes3d_sgskip.py @@ -23,6 +23,6 @@ # rotate the axes and update for angle in range(0, 360): - ax.view_init(30, angle) + ax.view_init(30, angle, 0) plt.draw() plt.pause(.001) diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index a29b075e030d..ba8dc1ccbf3f 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -54,7 +54,7 @@ class Axes3D(Axes): def __init__( self, fig, rect=None, *args, - azim=-60, elev=30, sharez=None, proj_type='persp', + elev=30, azim=-60, roll=0, sharez=None, proj_type='persp', box_aspect=None, computed_zorder=True, **kwargs): """ @@ -64,10 +64,19 @@ def __init__( The parent figure. rect : (float, float, float, float) The ``(left, bottom, width, height)`` axes position. - azim : float, default: -60 - Azimuthal viewing angle. elev : float, default: 30 - Elevation viewing angle. + The elevation angle in degrees rotates the camera above and below + the x-y plane, with a positive angle corresponding to a location + above the plane. + azim : float, default: -60 + The azimuthal angle in degrees rotates the camera about the z axis, + with a positive angle corresponding to a right-handed rotation. In + other words, a positive azimuth rotates the camera about the origin + from its location along the +x axis towards the +y axis. + roll : float, default: 0 + The roll angle in degrees rotates the camera about the viewing + axis. A positive angle spins the camera clockwise, causing the + scene to rotate counter-clockwise. sharez : Axes3D, optional Other axes to share z-limits with. proj_type : {'persp', 'ortho'} @@ -101,6 +110,7 @@ def __init__( self.initial_azim = azim self.initial_elev = elev + self.initial_roll = roll self.set_proj_type(proj_type) self.computed_zorder = computed_zorder @@ -112,7 +122,7 @@ 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.view_init(self.initial_elev, self.initial_azim, self.initial_roll) self._sharez = sharez if sharez is not None: @@ -976,7 +986,7 @@ def clabel(self, *args, **kwargs): """Currently not implemented for 3D axes, and returns *None*.""" return None - def view_init(self, elev=None, azim=None, vertical_axis="z"): + def view_init(self, elev=None, azim=None, roll=None, vertical_axis="z"): """ Set the elevation and azimuth of the axes in degrees (not radians). @@ -985,12 +995,26 @@ def view_init(self, elev=None, azim=None, vertical_axis="z"): Parameters ---------- elev : float, default: None - The elevation angle in the vertical plane in degrees. - If None then the initial value as specified in the `Axes3D` + The elevation angle in degrees rotates the camera above the plane + pierced by the vertical axis, with a positive angle corresponding + to a location above that plane. For example, with the default + vertical axis of 'z', the elevation defines the angle of the camera + location above the x-y plane. + If None, then the initial value as specified in the `Axes3D` constructor is used. azim : float, default: None - The azimuth angle in the horizontal plane in degrees. - If None then the initial value as specified in the `Axes3D` + The azimuthal angle in degrees rotates the camera about the + vertical axis, with a positive angle corresponding to a + right-handed rotation. For example, with the default vertical axis + of 'z', a positive azimuth rotates the camera about the origin from + its location along the +x axis towards the +y axis. + If None, then the initial value as specified in the `Axes3D` + constructor is used. + roll : float, default: None + The roll angle in degrees rotates the camera about the viewing + axis. A positive angle spins the camera clockwise, causing the + scene to rotate counter-clockwise. + If None, then the initial value as specified in the `Axes3D` constructor is used. vertical_axis : {"z", "x", "y"}, default: "z" The axis to align vertically. *azim* rotates about this axis. @@ -1008,6 +1032,11 @@ def view_init(self, elev=None, azim=None, vertical_axis="z"): else: self.azim = azim + if roll is None: + self.roll = self.initial_roll + else: + self.roll = roll + self._vertical_axis = _api.check_getitem( dict(x=0, y=1, z=2), vertical_axis=vertical_axis ) @@ -1046,8 +1075,10 @@ def get_proj(self): # elev stores the elevation angle in the z plane # azim stores the azimuth angle in the x,y plane - elev_rad = np.deg2rad(self.elev) - azim_rad = np.deg2rad(self.azim) + # roll stores the roll angle about the view axis + elev_rad = np.deg2rad(art3d._norm_angle(self.elev)) + azim_rad = np.deg2rad(art3d._norm_angle(self.azim)) + roll_rad = np.deg2rad(art3d._norm_angle(self.roll)) # Coordinates for a point that rotates around the box of data. # p0, p1 corresponds to rotating the box only around the @@ -1077,7 +1108,7 @@ def get_proj(self): V = np.zeros(3) V[self._vertical_axis] = -1 if abs(elev_rad) > 0.5 * np.pi else 1 - viewM = proj3d.view_transformation(eye, R, V) + viewM = proj3d.view_transformation(eye, R, V, roll_rad) projM = self._projection(-self.dist, self.dist) M0 = np.dot(viewM, worldM) M = np.dot(projM, M0) @@ -1165,14 +1196,15 @@ def _button_release(self, event): def _get_view(self): # docstring inherited return (self.get_xlim(), self.get_ylim(), self.get_zlim(), - self.elev, self.azim) + self.elev, self.azim, self.roll) def _set_view(self, view): # docstring inherited - xlim, ylim, zlim, elev, azim = view + xlim, ylim, zlim, elev, azim, roll = view self.set(xlim=xlim, ylim=ylim, zlim=zlim) self.elev = elev self.azim = azim + self.roll = roll def format_zdata(self, z): """ @@ -1199,8 +1231,12 @@ def format_coord(self, xd, yd): if self.button_pressed in self._rotate_btn: # ignore xd and yd and display angles instead - return (f"azimuth={self.azim:.0f}\N{DEGREE SIGN}, " - f"elevation={self.elev:.0f}\N{DEGREE SIGN}" + norm_elev = art3d._norm_angle(self.elev) + norm_azim = art3d._norm_angle(self.azim) + norm_roll = art3d._norm_angle(self.roll) + return (f"elevation={norm_elev:.0f}\N{DEGREE SIGN}, " + f"azimuth={norm_azim:.0f}\N{DEGREE SIGN}, " + f"roll={norm_roll:.0f}\N{DEGREE SIGN}" ).replace("-", "\N{MINUS SIGN}") # nearest edge @@ -1253,8 +1289,12 @@ def _on_move(self, event): # get the x and y pixel coords if dx == 0 and dy == 0: return - self.elev = art3d._norm_angle(self.elev - (dy/h)*180) - self.azim = art3d._norm_angle(self.azim - (dx/w)*180) + + roll = np.deg2rad(self.roll) + delev = -(dy/h)*180*np.cos(roll) + (dx/w)*180*np.sin(roll) + dazim = -(dy/h)*180*np.sin(roll) - (dx/w)*180*np.cos(roll) + self.elev = self.elev + delev + self.azim = self.azim + dazim self.get_proj() self.stale = True self.figure.canvas.draw_idle() @@ -1267,7 +1307,8 @@ def _on_move(self, event): minx, maxx, miny, maxy, minz, maxz = self.get_w_lims() dx = 1-((w - dx)/w) dy = 1-((h - dy)/h) - elev, azim = np.deg2rad(self.elev), np.deg2rad(self.azim) + elev = np.deg2rad(self.elev) + azim = np.deg2rad(self.azim) # project xv, yv, zv -> xw, yw, zw dxx = (maxx-minx)*(dy*np.sin(elev)*np.cos(azim) + dx*np.sin(azim)) dyy = (maxy-miny)*(-dx*np.cos(azim) + dy*np.sin(elev)*np.sin(azim)) @@ -3249,11 +3290,11 @@ 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 azimuth and elevation. - with cbook._setattr_cm(self, azim=0, elev=0): + # consistency, this uses a fixed elevation, azimuth, and roll. + with cbook._setattr_cm(self, elev=0, azim=0, roll=0): invM = np.linalg.inv(self.get_proj()) - # azim=elev=0 produces the Y-Z plane, so quiversize in 2D 'x' is 'y' in - # 3D, hence the 1 index. + # elev=azim=roll=0 produces the Y-Z plane, so quiversize in 2D 'x' is + # 'y' in 3D, hence the 1 index. quiversize = np.dot(invM, np.array([quiversize, 0, 0, 0]))[1] # Quivers use a fixed 15-degree arrow head, so scale up the length so # that the size corresponds to the base. In other words, this constant diff --git a/lib/mpl_toolkits/mplot3d/proj3d.py b/lib/mpl_toolkits/mplot3d/proj3d.py index cce89c5f3554..c7c2f93230be 100644 --- a/lib/mpl_toolkits/mplot3d/proj3d.py +++ b/lib/mpl_toolkits/mplot3d/proj3d.py @@ -51,34 +51,41 @@ def world_transformation(xmin, xmax, [0, 0, 0, 1]]) -def view_transformation(E, R, V): +def rotation_about_vector(v, angle): + """ + Produce a rotation matrix for an angle in radians about a vector. + """ + vx, vy, vz = v / np.linalg.norm(v) + s = np.sin(angle) + c = np.cos(angle) + t = 2*np.sin(angle/2)**2 # more numerically stable than t = 1-c + + R = np.array([ + [t*vx*vx + c, t*vx*vy - vz*s, t*vx*vz + vy*s], + [t*vy*vx + vz*s, t*vy*vy + c, t*vy*vz - vx*s], + [t*vz*vx - vy*s, t*vz*vy + vx*s, t*vz*vz + c]]) + + return R + + +def view_transformation(E, R, V, roll): n = (E - R) - ## new -# n /= np.linalg.norm(n) -# u = np.cross(V, n) -# u /= np.linalg.norm(u) -# v = np.cross(n, u) -# Mr = np.diag([1.] * 4) -# Mt = np.diag([1.] * 4) -# Mr[:3,:3] = u, v, n -# Mt[:3,-1] = -E - ## end new - - ## old - n = n / np.linalg.norm(n) + n = n/np.linalg.norm(n) u = np.cross(V, n) - u = u / np.linalg.norm(u) - v = np.cross(n, u) - Mr = [[u[0], u[1], u[2], 0], - [v[0], v[1], v[2], 0], - [n[0], n[1], n[2], 0], - [0, 0, 0, 1]] - # - Mt = [[1, 0, 0, -E[0]], - [0, 1, 0, -E[1]], - [0, 0, 1, -E[2]], - [0, 0, 0, 1]] - ## end old + u = u/np.linalg.norm(u) + v = np.cross(n, u) # Will be a unit vector + + # Save some computation for the default roll=0 + if roll != 0: + # A positive rotation of the camera is a negative rotation of the world + Rroll = rotation_about_vector(n, -roll) + u = np.dot(Rroll, u) + v = np.dot(Rroll, v) + + Mr = np.eye(4) + Mt = np.eye(4) + Mr[:3, :3] = [u, v, n] + Mt[:3, -1] = -E return np.dot(Mr, Mt) diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/bar3d_shaded.png b/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/bar3d_shaded.png index c22f2a5671d3..39dc9997cb1d 100644 Binary files a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/bar3d_shaded.png and b/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/bar3d_shaded.png differ diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/tricontour.png b/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/tricontour.png index c05dcac7d25e..4387737e8115 100644 Binary files a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/tricontour.png and b/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/tricontour.png differ diff --git a/lib/mpl_toolkits/tests/test_mplot3d.py b/lib/mpl_toolkits/tests/test_mplot3d.py index 5fee82f51920..62042849f50f 100644 --- a/lib/mpl_toolkits/tests/test_mplot3d.py +++ b/lib/mpl_toolkits/tests/test_mplot3d.py @@ -62,15 +62,15 @@ def test_bar3d_shaded(): x2d, y2d = x2d.ravel(), y2d.ravel() z = x2d + y2d + 1 # Avoid triggering bug with zero-depth boxes. - views = [(-60, 30), (30, 30), (30, -30), (120, -30)] + views = [(30, -60, 0), (30, 30, 30), (-30, 30, -90), (300, -30, 0)] fig = plt.figure(figsize=plt.figaspect(1 / len(views))) axs = fig.subplots( 1, len(views), subplot_kw=dict(projection='3d') ) - for ax, (azim, elev) in zip(axs, views): + for ax, (elev, azim, roll) in zip(axs, views): ax.bar3d(x2d, y2d, x2d * 0, 1, 1, z, shade=True) - ax.view_init(azim=azim, elev=elev) + ax.view_init(elev=elev, azim=azim, roll=roll) fig.canvas.draw() @@ -387,10 +387,10 @@ def test_marker_draw_order_data_reversed(fig_test, fig_ref, azim): color = ['b', 'y'] ax = fig_test.add_subplot(projection='3d') ax.scatter(x, y, z, s=3500, c=color) - ax.view_init(elev=0, azim=azim) + ax.view_init(elev=0, azim=azim, roll=0) ax = fig_ref.add_subplot(projection='3d') ax.scatter(x[::-1], y[::-1], z[::-1], s=3500, c=color[::-1]) - ax.view_init(elev=0, azim=azim) + ax.view_init(elev=0, azim=azim, roll=0) @check_figures_equal(extensions=['png']) @@ -410,11 +410,11 @@ def test_marker_draw_order_view_rotated(fig_test, fig_ref): # axis are not exactly invariant under 180 degree rotation -> deactivate ax.set_axis_off() ax.scatter(x, y, z, s=3500, c=color) - ax.view_init(elev=0, azim=azim) + ax.view_init(elev=0, azim=azim, roll=0) ax = fig_ref.add_subplot(projection='3d') ax.set_axis_off() ax.scatter(x, y, z, s=3500, c=color[::-1]) # color reversed - ax.view_init(elev=0, azim=azim - 180) # view rotated by 180 degrees + ax.view_init(elev=0, azim=azim - 180, roll=0) # view rotated by 180 deg @mpl3d_image_comparison(['plot_3d_from_2d.png'], tol=0.015) @@ -483,7 +483,7 @@ def test_surface3d_masked(): norm = mcolors.Normalize(vmax=z.max(), vmin=z.min()) colors = plt.get_cmap("plasma")(norm(z)) ax.plot_surface(x, y, z, facecolors=colors) - ax.view_init(30, -80) + ax.view_init(30, -80, 0) @mpl3d_image_comparison(['surface3d_masked_strides.png']) @@ -495,7 +495,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) + ax.view_init(60, -45, 0) @mpl3d_image_comparison(['text3d.png'], remove_text=False) @@ -840,7 +840,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) # look down, rotated. Should be square + ax.view_init(90, 45, 0) # look down, rotated. Should be square def test_plotsurface_1d_raises(): @@ -860,7 +860,8 @@ def _test_proj_make_M(): E = np.array([1000, -1000, 2000]) R = np.array([100, 100, 100]) V = np.array([0, 0, 1]) - viewM = proj3d.view_transformation(E, R, V) + roll = 0 + viewM = proj3d.view_transformation(E, R, V, roll) perspM = proj3d.persp_transformation(100, -100) M = np.dot(perspM, viewM) return M @@ -925,7 +926,8 @@ def test_proj_axes_cube_ortho(): E = np.array([200, 100, 100]) R = np.array([0, 0, 0]) V = np.array([0, 0, 1]) - viewM = proj3d.view_transformation(E, R, V) + roll = 0 + viewM = proj3d.view_transformation(E, R, V, roll) orthoM = proj3d.ortho_transformation(-1, 1) M = np.dot(orthoM, viewM) @@ -1044,7 +1046,7 @@ def test_axes3d_isometric(): for s, e in combinations(np.array(list(product(r, r, r))), 2): if abs(s - e).sum() == r[1] - r[0]: ax.plot3D(*zip(s, e), c='k') - ax.view_init(elev=np.degrees(np.arctan(1. / np.sqrt(2))), azim=-45) + ax.view_init(elev=np.degrees(np.arctan(1. / np.sqrt(2))), azim=-45, roll=0) ax.grid(True) @@ -1596,7 +1598,7 @@ def test_computed_zorder(): linestyle='--', color='green', zorder=4) - ax.view_init(azim=-20, elev=20) + ax.view_init(elev=20, azim=-20, roll=0) ax.axis('off') @@ -1683,7 +1685,7 @@ def test_view_init_vertical_axis( """ rtol = 2e-06 ax = plt.subplot(1, 1, 1, projection="3d") - ax.view_init(azim=0, elev=0, vertical_axis=vertical_axis) + ax.view_init(elev=0, azim=0, roll=0, vertical_axis=vertical_axis) ax.figure.canvas.draw() # Assert the projection matrix: