@@ -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
0 commit comments