Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 30f1c94

Browse files
Add ability to roll the camera in 3D plots (#21426)
* Add a roll angle for viewing 3D plots Add roll angle for 3d plot examples Align rotation of 3D plot by mouse with rolled axes 3D plot roll angle flake8, tests, and whats new Fix plot flipping at elev>270 Test for 3d plot with elev>270 Normalize the displayed angles for 3d plots when rotating with mouse Make elev, azim, roll ordering consistent everywhere Rename roll angle to tilt Speed up calculations for default tilt=0 Code review updates Switch tilt naming back to roll, flip rotation handedness Switch tilt naming back to roll, flip rotation handedness * Better elev/azim/roll docstrings Co-authored-by: Scott Shambaugh <[email protected]>
1 parent 392dd40 commit 30f1c94

File tree

9 files changed

+139
-68
lines changed

9 files changed

+139
-68
lines changed
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
3D plots gained a 3rd "roll" viewing angle
2+
------------------------------------------
3+
4+
3D plots can now be viewed from any orientation with the addition of a 3rd roll
5+
angle, which rotates the plot about the viewing axis. Interactive rotation
6+
using the mouse still only controls elevation and azimuth, meaning that this
7+
feature is relevant to users who create more complex camera angles
8+
programmatically. The default roll angle of 0 is backwards-compatible with
9+
existing 3D plots.
10+
11+
.. plot::
12+
:include-source: true
13+
14+
from mpl_toolkits.mplot3d import axes3d
15+
import matplotlib.pyplot as plt
16+
fig = plt.figure()
17+
ax = fig.add_subplot(projection='3d')
18+
X, Y, Z = axes3d.get_test_data(0.05)
19+
ax.plot_wireframe(X, Y, Z, rstride=10, cstride=10)
20+
ax.view_init(elev=0, azim=0, roll=30)
21+
plt.show()

examples/mplot3d/2dcollections3d.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,6 @@
4343

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

4848
plt.show()

examples/mplot3d/box3d.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@
6868
)
6969

7070
# Set distance and angle view
71-
ax.view_init(40, -30)
71+
ax.view_init(40, -30, 0)
7272
ax.dist = 11
7373

7474
# Colorbar

examples/mplot3d/rotate_axes3d_sgskip.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,6 @@
2323

2424
# rotate the axes and update
2525
for angle in range(0, 360):
26-
ax.view_init(30, angle)
26+
ax.view_init(30, angle, 0)
2727
plt.draw()
2828
plt.pause(.001)

lib/mpl_toolkits/mplot3d/axes3d.py

Lines changed: 65 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ class Axes3D(Axes):
5555

5656
def __init__(
5757
self, fig, rect=None, *args,
58-
azim=-60, elev=30, sharez=None, proj_type='persp',
58+
elev=30, azim=-60, roll=0, sharez=None, proj_type='persp',
5959
box_aspect=None, computed_zorder=True,
6060
**kwargs):
6161
"""
@@ -65,10 +65,19 @@ def __init__(
6565
The parent figure.
6666
rect : (float, float, float, float)
6767
The ``(left, bottom, width, height)`` axes position.
68-
azim : float, default: -60
69-
Azimuthal viewing angle.
7068
elev : float, default: 30
71-
Elevation viewing angle.
69+
The elevation angle in degrees rotates the camera above and below
70+
the x-y plane, with a positive angle corresponding to a location
71+
above the plane.
72+
azim : float, default: -60
73+
The azimuthal angle in degrees rotates the camera about the z axis,
74+
with a positive angle corresponding to a right-handed rotation. In
75+
other words, a positive azimuth rotates the camera about the origin
76+
from its location along the +x axis towards the +y axis.
77+
roll : float, default: 0
78+
The roll angle in degrees rotates the camera about the viewing
79+
axis. A positive angle spins the camera clockwise, causing the
80+
scene to rotate counter-clockwise.
7281
sharez : Axes3D, optional
7382
Other axes to share z-limits with.
7483
proj_type : {'persp', 'ortho'}
@@ -102,6 +111,7 @@ def __init__(
102111

103112
self.initial_azim = azim
104113
self.initial_elev = elev
114+
self.initial_roll = roll
105115
self.set_proj_type(proj_type)
106116
self.computed_zorder = computed_zorder
107117

@@ -113,7 +123,7 @@ def __init__(
113123

114124
# inhibit autoscale_view until the axes are defined
115125
# they can't be defined until Axes.__init__ has been called
116-
self.view_init(self.initial_elev, self.initial_azim)
126+
self.view_init(self.initial_elev, self.initial_azim, self.initial_roll)
117127

118128
self._sharez = sharez
119129
if sharez is not None:
@@ -983,7 +993,7 @@ def clabel(self, *args, **kwargs):
983993
"""Currently not implemented for 3D axes, and returns *None*."""
984994
return None
985995

986-
def view_init(self, elev=None, azim=None, vertical_axis="z"):
996+
def view_init(self, elev=None, azim=None, roll=None, vertical_axis="z"):
987997
"""
988998
Set the elevation and azimuth of the axes in degrees (not radians).
989999
@@ -992,12 +1002,26 @@ def view_init(self, elev=None, azim=None, vertical_axis="z"):
9921002
Parameters
9931003
----------
9941004
elev : float, default: None
995-
The elevation angle in the vertical plane in degrees.
996-
If None then the initial value as specified in the `Axes3D`
1005+
The elevation angle in degrees rotates the camera above the plane
1006+
pierced by the vertical axis, with a positive angle corresponding
1007+
to a location above that plane. For example, with the default
1008+
vertical axis of 'z', the elevation defines the angle of the camera
1009+
location above the x-y plane.
1010+
If None, then the initial value as specified in the `Axes3D`
9971011
constructor is used.
9981012
azim : float, default: None
999-
The azimuth angle in the horizontal plane in degrees.
1000-
If None then the initial value as specified in the `Axes3D`
1013+
The azimuthal angle in degrees rotates the camera about the
1014+
vertical axis, with a positive angle corresponding to a
1015+
right-handed rotation. For example, with the default vertical axis
1016+
of 'z', a positive azimuth rotates the camera about the origin from
1017+
its location along the +x axis towards the +y axis.
1018+
If None, then the initial value as specified in the `Axes3D`
1019+
constructor is used.
1020+
roll : float, default: None
1021+
The roll angle in degrees rotates the camera about the viewing
1022+
axis. A positive angle spins the camera clockwise, causing the
1023+
scene to rotate counter-clockwise.
1024+
If None, then the initial value as specified in the `Axes3D`
10011025
constructor is used.
10021026
vertical_axis : {"z", "x", "y"}, default: "z"
10031027
The axis to align vertically. *azim* rotates about this axis.
@@ -1015,6 +1039,11 @@ def view_init(self, elev=None, azim=None, vertical_axis="z"):
10151039
else:
10161040
self.azim = azim
10171041

1042+
if roll is None:
1043+
self.roll = self.initial_roll
1044+
else:
1045+
self.roll = roll
1046+
10181047
self._vertical_axis = _api.check_getitem(
10191048
dict(x=0, y=1, z=2), vertical_axis=vertical_axis
10201049
)
@@ -1053,8 +1082,10 @@ def get_proj(self):
10531082

10541083
# elev stores the elevation angle in the z plane
10551084
# azim stores the azimuth angle in the x,y plane
1056-
elev_rad = np.deg2rad(self.elev)
1057-
azim_rad = np.deg2rad(self.azim)
1085+
# roll stores the roll angle about the view axis
1086+
elev_rad = np.deg2rad(art3d._norm_angle(self.elev))
1087+
azim_rad = np.deg2rad(art3d._norm_angle(self.azim))
1088+
roll_rad = np.deg2rad(art3d._norm_angle(self.roll))
10581089

10591090
# Coordinates for a point that rotates around the box of data.
10601091
# p0, p1 corresponds to rotating the box only around the
@@ -1084,7 +1115,7 @@ def get_proj(self):
10841115
V = np.zeros(3)
10851116
V[self._vertical_axis] = -1 if abs(elev_rad) > 0.5 * np.pi else 1
10861117

1087-
viewM = proj3d.view_transformation(eye, R, V)
1118+
viewM = proj3d.view_transformation(eye, R, V, roll_rad)
10881119
projM = self._projection(-self.dist, self.dist)
10891120
M0 = np.dot(viewM, worldM)
10901121
M = np.dot(projM, M0)
@@ -1172,14 +1203,15 @@ def _button_release(self, event):
11721203
def _get_view(self):
11731204
# docstring inherited
11741205
return (self.get_xlim(), self.get_ylim(), self.get_zlim(),
1175-
self.elev, self.azim)
1206+
self.elev, self.azim, self.roll)
11761207

11771208
def _set_view(self, view):
11781209
# docstring inherited
1179-
xlim, ylim, zlim, elev, azim = view
1210+
xlim, ylim, zlim, elev, azim, roll = view
11801211
self.set(xlim=xlim, ylim=ylim, zlim=zlim)
11811212
self.elev = elev
11821213
self.azim = azim
1214+
self.roll = roll
11831215

11841216
def format_zdata(self, z):
11851217
"""
@@ -1206,8 +1238,12 @@ def format_coord(self, xd, yd):
12061238

12071239
if self.button_pressed in self._rotate_btn:
12081240
# ignore xd and yd and display angles instead
1209-
return (f"azimuth={self.azim:.0f}\N{DEGREE SIGN}, "
1210-
f"elevation={self.elev:.0f}\N{DEGREE SIGN}"
1241+
norm_elev = art3d._norm_angle(self.elev)
1242+
norm_azim = art3d._norm_angle(self.azim)
1243+
norm_roll = art3d._norm_angle(self.roll)
1244+
return (f"elevation={norm_elev:.0f}\N{DEGREE SIGN}, "
1245+
f"azimuth={norm_azim:.0f}\N{DEGREE SIGN}, "
1246+
f"roll={norm_roll:.0f}\N{DEGREE SIGN}"
12111247
).replace("-", "\N{MINUS SIGN}")
12121248

12131249
# nearest edge
@@ -1260,8 +1296,12 @@ def _on_move(self, event):
12601296
# get the x and y pixel coords
12611297
if dx == 0 and dy == 0:
12621298
return
1263-
self.elev = art3d._norm_angle(self.elev - (dy/h)*180)
1264-
self.azim = art3d._norm_angle(self.azim - (dx/w)*180)
1299+
1300+
roll = np.deg2rad(self.roll)
1301+
delev = -(dy/h)*180*np.cos(roll) + (dx/w)*180*np.sin(roll)
1302+
dazim = -(dy/h)*180*np.sin(roll) - (dx/w)*180*np.cos(roll)
1303+
self.elev = self.elev + delev
1304+
self.azim = self.azim + dazim
12651305
self.get_proj()
12661306
self.stale = True
12671307
self.figure.canvas.draw_idle()
@@ -1274,7 +1314,8 @@ def _on_move(self, event):
12741314
minx, maxx, miny, maxy, minz, maxz = self.get_w_lims()
12751315
dx = 1-((w - dx)/w)
12761316
dy = 1-((h - dy)/h)
1277-
elev, azim = np.deg2rad(self.elev), np.deg2rad(self.azim)
1317+
elev = np.deg2rad(self.elev)
1318+
azim = np.deg2rad(self.azim)
12781319
# project xv, yv, zv -> xw, yw, zw
12791320
dxx = (maxx-minx)*(dy*np.sin(elev)*np.cos(azim) + dx*np.sin(azim))
12801321
dyy = (maxy-miny)*(-dx*np.cos(azim) + dy*np.sin(elev)*np.sin(azim))
@@ -3256,11 +3297,11 @@ def _extract_errs(err, data, lomask, himask):
32563297
quiversize = np.mean(np.diff(quiversize, axis=0))
32573298
# quiversize is now in Axes coordinates, and to convert back to data
32583299
# coordinates, we need to run it through the inverse 3D transform. For
3259-
# consistency, this uses a fixed azimuth and elevation.
3260-
with cbook._setattr_cm(self, azim=0, elev=0):
3300+
# consistency, this uses a fixed elevation, azimuth, and roll.
3301+
with cbook._setattr_cm(self, elev=0, azim=0, roll=0):
32613302
invM = np.linalg.inv(self.get_proj())
3262-
# azim=elev=0 produces the Y-Z plane, so quiversize in 2D 'x' is 'y' in
3263-
# 3D, hence the 1 index.
3303+
# elev=azim=roll=0 produces the Y-Z plane, so quiversize in 2D 'x' is
3304+
# 'y' in 3D, hence the 1 index.
32643305
quiversize = np.dot(invM, np.array([quiversize, 0, 0, 0]))[1]
32653306
# Quivers use a fixed 15-degree arrow head, so scale up the length so
32663307
# that the size corresponds to the base. In other words, this constant

lib/mpl_toolkits/mplot3d/proj3d.py

Lines changed: 33 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -51,34 +51,41 @@ def world_transformation(xmin, xmax,
5151
[0, 0, 0, 1]])
5252

5353

54-
def view_transformation(E, R, V):
54+
def rotation_about_vector(v, angle):
55+
"""
56+
Produce a rotation matrix for an angle in radians about a vector.
57+
"""
58+
vx, vy, vz = v / np.linalg.norm(v)
59+
s = np.sin(angle)
60+
c = np.cos(angle)
61+
t = 2*np.sin(angle/2)**2 # more numerically stable than t = 1-c
62+
63+
R = np.array([
64+
[t*vx*vx + c, t*vx*vy - vz*s, t*vx*vz + vy*s],
65+
[t*vy*vx + vz*s, t*vy*vy + c, t*vy*vz - vx*s],
66+
[t*vz*vx - vy*s, t*vz*vy + vx*s, t*vz*vz + c]])
67+
68+
return R
69+
70+
71+
def view_transformation(E, R, V, roll):
5572
n = (E - R)
56-
## new
57-
# n /= np.linalg.norm(n)
58-
# u = np.cross(V, n)
59-
# u /= np.linalg.norm(u)
60-
# v = np.cross(n, u)
61-
# Mr = np.diag([1.] * 4)
62-
# Mt = np.diag([1.] * 4)
63-
# Mr[:3,:3] = u, v, n
64-
# Mt[:3,-1] = -E
65-
## end new
66-
67-
## old
68-
n = n / np.linalg.norm(n)
73+
n = n/np.linalg.norm(n)
6974
u = np.cross(V, n)
70-
u = u / np.linalg.norm(u)
71-
v = np.cross(n, u)
72-
Mr = [[u[0], u[1], u[2], 0],
73-
[v[0], v[1], v[2], 0],
74-
[n[0], n[1], n[2], 0],
75-
[0, 0, 0, 1]]
76-
#
77-
Mt = [[1, 0, 0, -E[0]],
78-
[0, 1, 0, -E[1]],
79-
[0, 0, 1, -E[2]],
80-
[0, 0, 0, 1]]
81-
## end old
75+
u = u/np.linalg.norm(u)
76+
v = np.cross(n, u) # Will be a unit vector
77+
78+
# Save some computation for the default roll=0
79+
if roll != 0:
80+
# A positive rotation of the camera is a negative rotation of the world
81+
Rroll = rotation_about_vector(n, -roll)
82+
u = np.dot(Rroll, u)
83+
v = np.dot(Rroll, v)
84+
85+
Mr = np.eye(4)
86+
Mt = np.eye(4)
87+
Mr[:3, :3] = [u, v, n]
88+
Mt[:3, -1] = -E
8289

8390
return np.dot(Mr, Mt)
8491

0 commit comments

Comments
 (0)