@@ -54,7 +54,7 @@ class Axes3D(Axes):
5454
5555 def __init__ (
5656 self , fig , rect = None , * args ,
57- azim = - 60 , elev = 30 , sharez = None , proj_type = 'persp' ,
57+ elev = 30 , azim = - 60 , roll = 0 , sharez = None , proj_type = 'persp' ,
5858 box_aspect = None , computed_zorder = True ,
5959 ** kwargs ):
6060 """
@@ -64,10 +64,12 @@ def __init__(
6464 The parent figure.
6565 rect : (float, float, float, float)
6666 The ``(left, bottom, width, height)`` axes position.
67- azim : float, default: -60
68- Azimuthal viewing angle.
6967 elev : float, default: 30
7068 Elevation viewing angle.
69+ azim : float, default: -60
70+ Azimuthal viewing angle.
71+ roll : float, default: 0
72+ Roll viewing angle.
7173 sharez : Axes3D, optional
7274 Other axes to share z-limits with.
7375 proj_type : {'persp', 'ortho'}
@@ -101,6 +103,7 @@ def __init__(
101103
102104 self .initial_azim = azim
103105 self .initial_elev = elev
106+ self .initial_roll = roll
104107 self .set_proj_type (proj_type )
105108 self .computed_zorder = computed_zorder
106109
@@ -112,7 +115,7 @@ def __init__(
112115
113116 # inhibit autoscale_view until the axes are defined
114117 # they can't be defined until Axes.__init__ has been called
115- self .view_init (self .initial_elev , self .initial_azim )
118+ self .view_init (self .initial_elev , self .initial_azim , self . initial_roll )
116119
117120 self ._sharez = sharez
118121 if sharez is not None :
@@ -976,7 +979,7 @@ def clabel(self, *args, **kwargs):
976979 """Currently not implemented for 3D axes, and returns *None*."""
977980 return None
978981
979- def view_init (self , elev = None , azim = None , vertical_axis = "z" ):
982+ def view_init (self , elev = None , azim = None , roll = None , vertical_axis = "z" ):
980983 """
981984 Set the elevation and azimuth of the axes in degrees (not radians).
982985
@@ -992,6 +995,10 @@ def view_init(self, elev=None, azim=None, vertical_axis="z"):
992995 The azimuth angle in the horizontal plane in degrees.
993996 If None then the initial value as specified in the `Axes3D`
994997 constructor is used.
998+ roll : float, default: None
999+ The roll angle about the viewing direction in degrees.
1000+ If None then the initial value as specified in the `Axes3D`
1001+ constructor is used.
9951002 vertical_axis : {"z", "x", "y"}, default: "z"
9961003 The axis to align vertically. *azim* rotates about this axis.
9971004 """
@@ -1008,6 +1015,11 @@ def view_init(self, elev=None, azim=None, vertical_axis="z"):
10081015 else :
10091016 self .azim = azim
10101017
1018+ if roll is None :
1019+ self .roll = self .initial_roll
1020+ else :
1021+ self .roll = roll
1022+
10111023 self ._vertical_axis = _api .check_getitem (
10121024 dict (x = 0 , y = 1 , z = 2 ), vertical_axis = vertical_axis
10131025 )
@@ -1046,8 +1058,10 @@ def get_proj(self):
10461058
10471059 # elev stores the elevation angle in the z plane
10481060 # azim stores the azimuth angle in the x,y plane
1049- elev_rad = np .deg2rad (self .elev )
1050- azim_rad = np .deg2rad (self .azim )
1061+ # roll stores the roll angle about the view axis
1062+ elev_rad = np .deg2rad (art3d ._norm_angle (self .elev ))
1063+ azim_rad = np .deg2rad (art3d ._norm_angle (self .azim ))
1064+ roll_rad = np .deg2rad (art3d ._norm_angle (self .roll ))
10511065
10521066 # Coordinates for a point that rotates around the box of data.
10531067 # p0, p1 corresponds to rotating the box only around the
@@ -1077,7 +1091,7 @@ def get_proj(self):
10771091 V = np .zeros (3 )
10781092 V [self ._vertical_axis ] = - 1 if abs (elev_rad ) > 0.5 * np .pi else 1
10791093
1080- viewM = proj3d .view_transformation (eye , R , V )
1094+ viewM = proj3d .view_transformation (eye , R , V , roll_rad )
10811095 projM = self ._projection (- self .dist , self .dist )
10821096 M0 = np .dot (viewM , worldM )
10831097 M = np .dot (projM , M0 )
@@ -1165,14 +1179,15 @@ def _button_release(self, event):
11651179 def _get_view (self ):
11661180 # docstring inherited
11671181 return (self .get_xlim (), self .get_ylim (), self .get_zlim (),
1168- self .elev , self .azim )
1182+ self .elev , self .azim , self . roll )
11691183
11701184 def _set_view (self , view ):
11711185 # docstring inherited
1172- xlim , ylim , zlim , elev , azim = view
1186+ xlim , ylim , zlim , elev , azim , roll = view
11731187 self .set (xlim = xlim , ylim = ylim , zlim = zlim )
11741188 self .elev = elev
11751189 self .azim = azim
1190+ self .roll = roll
11761191
11771192 def format_zdata (self , z ):
11781193 """
@@ -1199,8 +1214,12 @@ def format_coord(self, xd, yd):
11991214
12001215 if self .button_pressed in self ._rotate_btn :
12011216 # ignore xd and yd and display angles instead
1202- return (f"azimuth={ self .azim :.0f} \N{DEGREE SIGN} , "
1203- f"elevation={ self .elev :.0f} \N{DEGREE SIGN} "
1217+ norm_elev = art3d ._norm_angle (self .elev )
1218+ norm_azim = art3d ._norm_angle (self .azim )
1219+ norm_roll = art3d ._norm_angle (self .roll )
1220+ return (f"elevation={ norm_elev :.0f} \N{DEGREE SIGN} , "
1221+ f"azimuth={ norm_azim :.0f} \N{DEGREE SIGN} , "
1222+ f"roll={ norm_roll :.0f} \N{DEGREE SIGN} "
12041223 ).replace ("-" , "\N{MINUS SIGN} " )
12051224
12061225 # nearest edge
@@ -1253,8 +1272,12 @@ def _on_move(self, event):
12531272 # get the x and y pixel coords
12541273 if dx == 0 and dy == 0 :
12551274 return
1256- self .elev = art3d ._norm_angle (self .elev - (dy / h )* 180 )
1257- self .azim = art3d ._norm_angle (self .azim - (dx / w )* 180 )
1275+
1276+ roll = np .deg2rad (self .roll )
1277+ delev = - (dy / h )* 180 * np .cos (roll ) + (dx / w )* 180 * np .sin (roll )
1278+ dazim = - (dy / h )* 180 * np .sin (roll ) - (dx / w )* 180 * np .cos (roll )
1279+ self .elev = self .elev + delev
1280+ self .azim = self .azim + dazim
12581281 self .get_proj ()
12591282 self .stale = True
12601283 self .figure .canvas .draw_idle ()
@@ -1267,7 +1290,8 @@ def _on_move(self, event):
12671290 minx , maxx , miny , maxy , minz , maxz = self .get_w_lims ()
12681291 dx = 1 - ((w - dx )/ w )
12691292 dy = 1 - ((h - dy )/ h )
1270- elev , azim = np .deg2rad (self .elev ), np .deg2rad (self .azim )
1293+ elev = np .deg2rad (self .elev )
1294+ azim = np .deg2rad (self .azim )
12711295 # project xv, yv, zv -> xw, yw, zw
12721296 dxx = (maxx - minx )* (dy * np .sin (elev )* np .cos (azim ) + dx * np .sin (azim ))
12731297 dyy = (maxy - miny )* (- dx * np .cos (azim ) + dy * np .sin (elev )* np .sin (azim ))
@@ -3249,11 +3273,11 @@ def _extract_errs(err, data, lomask, himask):
32493273 quiversize = np .mean (np .diff (quiversize , axis = 0 ))
32503274 # quiversize is now in Axes coordinates, and to convert back to data
32513275 # coordinates, we need to run it through the inverse 3D transform. For
3252- # consistency, this uses a fixed azimuth and elevation .
3253- with cbook ._setattr_cm (self , azim = 0 , elev = 0 ):
3276+ # consistency, this uses a fixed elevation, azimuth, and roll .
3277+ with cbook ._setattr_cm (self , elev = 0 , azim = 0 , roll = 0 ):
32543278 invM = np .linalg .inv (self .get_proj ())
3255- # azim=elev =0 produces the Y-Z plane, so quiversize in 2D 'x' is 'y' in
3256- # 3D, hence the 1 index.
3279+ # elev= azim=roll =0 produces the Y-Z plane, so quiversize in 2D 'x' is
3280+ # 'y' in 3D, hence the 1 index.
32573281 quiversize = np .dot (invM , np .array ([quiversize , 0 , 0 , 0 ]))[1 ]
32583282 # Quivers use a fixed 15-degree arrow head, so scale up the length so
32593283 # that the size corresponds to the base. In other words, this constant
0 commit comments