Skip to content

Add ability to roll the camera in 3D plots #21426

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

Merged
merged 2 commits into from
Dec 3, 2021
Merged
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
21 changes: 21 additions & 0 deletions doc/users/next_whats_new/3d_plot_roll_angle.rst
Original file line number Diff line number Diff line change
@@ -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()
2 changes: 1 addition & 1 deletion 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)
ax.view_init(elev=20., azim=-35, roll=0)

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

# Set distance and angle view
ax.view_init(40, -30)
ax.view_init(40, -30, 0)
ax.dist = 11

# Colorbar
Expand Down
2 changes: 1 addition & 1 deletion examples/mplot3d/rotate_axes3d_sgskip.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
89 changes: 65 additions & 24 deletions lib/mpl_toolkits/mplot3d/axes3d.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand All @@ -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'}
Expand Down Expand Up @@ -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

Expand All @@ -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:
Expand Down Expand Up @@ -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).

Expand All @@ -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.
Expand All @@ -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
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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):
"""
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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))
Expand Down Expand Up @@ -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
Expand Down
59 changes: 33 additions & 26 deletions lib/mpl_toolkits/mplot3d/proj3d.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified lib/mpl_toolkits/tests/baseline_images/test_mplot3d/tricontour.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading