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

Skip to content

Commit e75f10d

Browse files
committed
ENH: Add pan and zoom toolbar handling to 3D Axes
1) This moves the pan logic that was already in the mouse move handler into the "drag_pan" method to make it available from the toolbar. 2) This expands upon the panning logic to enable a zoom-to-box feature. The zoom-to-box is done relative to the Axes, so it shrinks/expands the box as a fraction of each delta, from lower-left Axes to lower-left zoom-box. Thus, it tries to handle non-centered zooms, which adds more cases to handle versus the current right-click zoom only scaling from the center of the projection.
1 parent 7aed240 commit e75f10d

File tree

2 files changed

+244
-31
lines changed

2 files changed

+244
-31
lines changed

lib/mpl_toolkits/mplot3d/axes3d.py

Lines changed: 191 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ def __init__(
157157
self.fmt_zdata = None
158158

159159
self.mouse_init()
160-
self.figure.canvas.callbacks._connect_picklable(
160+
self._move_cid = self.figure.canvas.callbacks._connect_picklable(
161161
'motion_notify_event', self._on_move)
162162
self.figure.canvas.callbacks._connect_picklable(
163163
'button_press_event', self._button_press)
@@ -924,18 +924,14 @@ def disable_mouse_rotation(self):
924924
def can_zoom(self):
925925
"""
926926
Return whether this Axes supports the zoom box button functionality.
927-
928-
Axes3D objects do not use the zoom box button.
929927
"""
930-
return False
928+
return True
931929

932930
def can_pan(self):
933931
"""
934-
Return whether this Axes supports the pan/zoom button functionality.
935-
936-
Axes3d objects do not use the pan/zoom button.
932+
Return whether this Axes supports the pan button functionality.
937933
"""
938-
return False
934+
return True
939935

940936
def cla(self):
941937
# docstring inherited.
@@ -1055,6 +1051,11 @@ def _on_move(self, event):
10551051
if not self.button_pressed:
10561052
return
10571053

1054+
if self.get_navigate_mode() is not None:
1055+
# we don't want to rotate if we are zooming/panning
1056+
# from the toolbar
1057+
return
1058+
10581059
if self.M is None:
10591060
return
10601061

@@ -1066,7 +1067,6 @@ def _on_move(self, event):
10661067
dx, dy = x - self.sx, y - self.sy
10671068
w = self._pseudo_w
10681069
h = self._pseudo_h
1069-
self.sx, self.sy = x, y
10701070

10711071
# Rotation
10721072
if self.button_pressed in self._rotate_btn:
@@ -1082,28 +1082,14 @@ def _on_move(self, event):
10821082
self.azim = self.azim + dazim
10831083
self.get_proj()
10841084
self.stale = True
1085-
self.figure.canvas.draw_idle()
10861085

10871086
elif self.button_pressed == 2:
1088-
# pan view
1089-
# get the x and y pixel coords
1090-
if dx == 0 and dy == 0:
1091-
return
1092-
minx, maxx, miny, maxy, minz, maxz = self.get_w_lims()
1093-
dx = 1-((w - dx)/w)
1094-
dy = 1-((h - dy)/h)
1095-
elev = np.deg2rad(self.elev)
1096-
azim = np.deg2rad(self.azim)
1097-
# project xv, yv, zv -> xw, yw, zw
1098-
dxx = (maxx-minx)*(dy*np.sin(elev)*np.cos(azim) + dx*np.sin(azim))
1099-
dyy = (maxy-miny)*(-dx*np.cos(azim) + dy*np.sin(elev)*np.sin(azim))
1100-
dzz = (maxz-minz)*(-dy*np.cos(elev))
1101-
# pan
1102-
self.set_xlim3d(minx + dxx, maxx + dxx)
1103-
self.set_ylim3d(miny + dyy, maxy + dyy)
1104-
self.set_zlim3d(minz + dzz, maxz + dzz)
1105-
self.get_proj()
1106-
self.figure.canvas.draw_idle()
1087+
# Start the pan event with pixel coordinates
1088+
px, py = self.transData.transform([self.sx, self.sy])
1089+
self.start_pan(px, py, 2)
1090+
# pan view (takes pixel coordinate input)
1091+
self.drag_pan(2, None, event.x, event.y)
1092+
self.end_pan()
11071093

11081094
# Zoom
11091095
elif self.button_pressed in self._zoom_btn:
@@ -1118,7 +1104,182 @@ def _on_move(self, event):
11181104
self.set_ylim3d(miny - dy, maxy + dy)
11191105
self.set_zlim3d(minz - dz, maxz + dz)
11201106
self.get_proj()
1121-
self.figure.canvas.draw_idle()
1107+
1108+
# Store the event coordinates for the next time through.
1109+
self.sx, self.sy = x, y
1110+
# Always request a draw update at the end of interaction
1111+
self.figure.canvas.draw_idle()
1112+
1113+
def drag_pan(self, button, key, x, y):
1114+
# docstring inherited
1115+
1116+
# Get the coordinates from the move event
1117+
p = self._pan_start
1118+
(xdata, ydata), (xdata_start, ydata_start) = p.trans_inverse.transform(
1119+
[(x, y), (p.x, p.y)])
1120+
self.sx, self.sy = xdata, ydata
1121+
# Calling start_pan() to set the x/y of this event as the starting
1122+
# move location for the next event
1123+
self.start_pan(x, y, button)
1124+
dx, dy = xdata - xdata_start, ydata - ydata_start
1125+
if dx == 0 and dy == 0:
1126+
return
1127+
1128+
# Now pan the view by updating the limits
1129+
w = self._pseudo_w
1130+
h = self._pseudo_h
1131+
1132+
minx, maxx, miny, maxy, minz, maxz = self.get_w_lims()
1133+
dx = 1 - ((w - dx) / w)
1134+
dy = 1 - ((h - dy) / h)
1135+
elev = np.deg2rad(self.elev)
1136+
azim = np.deg2rad(self.azim)
1137+
# project xv, yv, zv -> xw, yw, zw
1138+
dxx = (maxx - minx) * (dy * np.sin(elev)
1139+
* np.cos(azim) + dx * np.sin(azim))
1140+
dyy = (maxy - miny) * (-dx * np.cos(azim)
1141+
+ dy * np.sin(elev) * np.sin(azim))
1142+
dzz = (maxz - minz) * (-dy * np.cos(elev))
1143+
# pan
1144+
self.set_xlim3d(minx + dxx, maxx + dxx)
1145+
self.set_ylim3d(miny + dyy, maxy + dyy)
1146+
self.set_zlim3d(minz + dzz, maxz + dzz)
1147+
self.get_proj()
1148+
1149+
def _set_view_from_bbox(self, bbox, direction='in',
1150+
mode=None, twinx=False, twiny=False):
1151+
# docstring inherited
1152+
1153+
# bbox is (start_x, start_y, event.x, event.y) in screen coords
1154+
# _prepare_view_from_bbox will give us back new *data* coords
1155+
# (in the 2D transform space, not 3D world coords)
1156+
new_xbound, new_ybound = self._prepare_view_from_bbox(
1157+
bbox, direction=direction, mode=mode, twinx=twinx, twiny=twiny)
1158+
# We need to get the Zoom bbox limits relative to the Axes limits
1159+
# 1) Axes bottom-left -> Zoom box bottom-left
1160+
# 2) Axes top-right -> Zoom box top-right
1161+
axes_to_data_trans = self.transAxes + self.transData.inverted()
1162+
axes_data_bbox = axes_to_data_trans.transform([(0, 0), (1, 1)])
1163+
# dx, dy gives us the vector difference from the axes to the
1164+
dx1, dy1 = (axes_data_bbox[0][0] - new_xbound[0],
1165+
axes_data_bbox[0][1] - new_ybound[0])
1166+
dx2, dy2 = (axes_data_bbox[1][0] - new_xbound[1],
1167+
axes_data_bbox[1][1] - new_ybound[1])
1168+
1169+
def data_2d_to_world_3d(dx, dy):
1170+
# Takes the vector (dx, dy) in transData coords and
1171+
# transforms that to each of the 3 world data coords
1172+
# (x, y, z) for calculating the offset
1173+
w = self._pseudo_w
1174+
h = self._pseudo_h
1175+
1176+
dx = 1 - ((w - dx) / w)
1177+
dy = 1 - ((h - dy) / h)
1178+
elev = np.deg2rad(self.elev)
1179+
azim = np.deg2rad(self.azim)
1180+
# project xv, yv, zv -> xw, yw, zw
1181+
dxx = (dy * np.sin(elev)
1182+
* np.cos(azim) + dx * np.sin(azim))
1183+
dyy = (-dx * np.cos(azim)
1184+
+ dy * np.sin(elev) * np.sin(azim))
1185+
dzz = (-dy * np.cos(elev))
1186+
return dxx, dyy, dzz
1187+
1188+
# These are the amounts to bring the projection in or out by from
1189+
# each side (1 left, 2 right) because we aren't necessarily zooming
1190+
# into the center of the projection.
1191+
dxx1, dyy1, dzz1 = data_2d_to_world_3d(dx1, dy1)
1192+
dxx2, dyy2, dzz2 = data_2d_to_world_3d(dx2, dy2)
1193+
# update the min and max limits of the world
1194+
minx, maxx, miny, maxy, minz, maxz = self.get_w_lims()
1195+
self.set_xlim3d(minx + dxx1 * (maxx - minx),
1196+
maxx + dxx2 * (maxx - minx))
1197+
self.set_ylim3d(miny + dyy1 * (maxy - miny),
1198+
maxy + dyy2 * (maxy - miny))
1199+
self.set_zlim3d(minz + dzz1 * (maxz - minz),
1200+
maxz + dzz2 * (maxz - minz))
1201+
self.get_proj()
1202+
1203+
def _prepare_view_from_bbox(self, bbox, direction='in',
1204+
mode=None, twinx=False, twiny=False):
1205+
"""
1206+
Helper function to prepare the new bounds from a bbox.
1207+
1208+
This helper function returns the new x and y bounds from the zoom
1209+
bbox. This a convenience method to abstract the bbox logic
1210+
out of the base setter.
1211+
"""
1212+
if len(bbox) == 3:
1213+
xp, yp, scl = bbox # Zooming code
1214+
if scl == 0: # Should not happen
1215+
scl = 1.
1216+
if scl > 1:
1217+
direction = 'in'
1218+
else:
1219+
direction = 'out'
1220+
scl = 1 / scl
1221+
# get the limits of the axes
1222+
(xmin, ymin), (xmax, ymax) = self.transData.transform(
1223+
np.transpose([self.get_xlim(), self.get_ylim()]))
1224+
# set the range
1225+
xwidth = xmax - xmin
1226+
ywidth = ymax - ymin
1227+
xcen = (xmax + xmin) * .5
1228+
ycen = (ymax + ymin) * .5
1229+
xzc = (xp * (scl - 1) + xcen) / scl
1230+
yzc = (yp * (scl - 1) + ycen) / scl
1231+
bbox = [xzc - xwidth / 2. / scl, yzc - ywidth / 2. / scl,
1232+
xzc + xwidth / 2. / scl, yzc + ywidth / 2. / scl]
1233+
elif len(bbox) != 4:
1234+
# should be len 3 or 4 but nothing else
1235+
_api.warn_external(
1236+
"Warning in _set_view_from_bbox: bounding box is not a tuple "
1237+
"of length 3 or 4. Ignoring the view change.")
1238+
return
1239+
1240+
# Original limits
1241+
# Can't use get_x/y bounds because those aren't in 2D space
1242+
pseudo_bbox = self.transLimits.inverted().transform([(0, 0), (1, 1)])
1243+
(xmin0, ymin0), (xmax0, ymax0) = pseudo_bbox
1244+
# The zoom box in screen coords.
1245+
startx, starty, stopx, stopy = bbox
1246+
# Convert to data coords.
1247+
(startx, starty), (stopx, stopy) = self.transData.inverted().transform(
1248+
[(startx, starty), (stopx, stopy)])
1249+
# Clip to axes limits.
1250+
xmin, xmax = np.clip(sorted([startx, stopx]), xmin0, xmax0)
1251+
ymin, ymax = np.clip(sorted([starty, stopy]), ymin0, ymax0)
1252+
# Don't double-zoom twinned axes or if zooming only the other axis.
1253+
if twinx or mode == "y":
1254+
xmin, xmax = xmin0, xmax0
1255+
if twiny or mode == "x":
1256+
ymin, ymax = ymin0, ymax0
1257+
1258+
if direction == "in":
1259+
new_xbound = xmin, xmax
1260+
new_ybound = ymin, ymax
1261+
1262+
elif direction == "out":
1263+
x_trf = self.xaxis.get_transform()
1264+
sxmin0, sxmax0, sxmin, sxmax = x_trf.transform(
1265+
[xmin0, xmax0, xmin, xmax]) # To screen space.
1266+
factor = (sxmax0 - sxmin0) / (sxmax - sxmin) # Unzoom factor.
1267+
# Move original bounds away by
1268+
# (factor) x (distance between unzoom box and Axes bbox).
1269+
sxmin1 = sxmin0 - factor * (sxmin - sxmin0)
1270+
sxmax1 = sxmax0 + factor * (sxmax0 - sxmax)
1271+
# And back to data space.
1272+
new_xbound = x_trf.inverted().transform([sxmin1, sxmax1])
1273+
1274+
y_trf = self.yaxis.get_transform()
1275+
symin0, symax0, symin, symax = y_trf.transform(
1276+
[ymin0, ymax0, ymin, ymax])
1277+
factor = (symax0 - symin0) / (symax - symin)
1278+
symin1 = symin0 - factor * (symin - symin0)
1279+
symax1 = symax0 + factor * (symax0 - symax)
1280+
new_ybound = y_trf.inverted().transform([symin1, symax1])
1281+
1282+
return new_xbound, new_ybound
11221283

11231284
def set_zlabel(self, zlabel, fontdict=None, labelpad=None, **kwargs):
11241285
"""

lib/mpl_toolkits/tests/test_mplot3d.py

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55

66
from mpl_toolkits.mplot3d import Axes3D, axes3d, proj3d, art3d
77
import matplotlib as mpl
8-
from matplotlib.backend_bases import MouseButton
8+
from matplotlib.backend_bases import (MouseButton, MouseEvent,
9+
NavigationToolbar2)
910
from matplotlib import cm
1011
from matplotlib import colors as mcolors
1112
from matplotlib.testing.decorators import image_comparison, check_figures_equal
@@ -1512,6 +1513,57 @@ def convert_lim(dmin, dmax):
15121513
assert z_center != pytest.approx(z_center0)
15131514

15141515

1516+
@pytest.mark.parametrize("tool,button,expected",
1517+
[("zoom", MouseButton.LEFT, # zoom in
1518+
((-0.02, 0.06), (0, 0.06), (-0.01, 0.06))),
1519+
("zoom", MouseButton.RIGHT, # zoom out
1520+
((-0.13, 0.06), (-0.18, 0.06), (-0.17, 0.06))),
1521+
("pan", MouseButton.LEFT,
1522+
((-0.46, -0.34), (-0.66, -0.54), (-0.62, -0.5)))])
1523+
def test_toolbar_zoom_pan(tool, button, expected):
1524+
# NOTE: The expected values are rough ballparks of moving in the view
1525+
# to make sure we are getting the right direction of motion.
1526+
# The specific values can and should change if the zoom/pan
1527+
# movement scaling factors get updated.
1528+
fig = plt.figure()
1529+
ax = fig.add_subplot(projection='3d')
1530+
ax.scatter(0, 0, 0)
1531+
fig.canvas.draw()
1532+
1533+
# Mouse from (0, 0) to (1, 1)
1534+
d0 = (0, 0)
1535+
d1 = (1, 1)
1536+
# Convert to screen coordinates ("s"). Events are defined only with pixel
1537+
# precision, so round the pixel values, and below, check against the
1538+
# corresponding xdata/ydata, which are close but not equal to d0/d1.
1539+
s0 = ax.transData.transform(d0).astype(int)
1540+
s1 = ax.transData.transform(d1).astype(int)
1541+
1542+
# Set up the mouse movements
1543+
start_event = MouseEvent(
1544+
"button_press_event", fig.canvas, *s0, button)
1545+
stop_event = MouseEvent(
1546+
"button_release_event", fig.canvas, *s1, button)
1547+
1548+
tb = NavigationToolbar2(fig.canvas)
1549+
if tool == "zoom":
1550+
tb.zoom()
1551+
tb.press_zoom(start_event)
1552+
tb.drag_zoom(stop_event)
1553+
tb.release_zoom(stop_event)
1554+
else:
1555+
tb.pan()
1556+
tb.press_pan(start_event)
1557+
tb.drag_pan(stop_event)
1558+
tb.release_pan(stop_event)
1559+
1560+
# Should be close, but won't be exact due to screen integer resolution
1561+
xlim, ylim, zlim = expected
1562+
assert (ax.get_xlim3d()) == pytest.approx(xlim, abs=0.01)
1563+
assert (ax.get_ylim3d()) == pytest.approx(ylim, abs=0.01)
1564+
assert (ax.get_zlim3d()) == pytest.approx(zlim, abs=0.01)
1565+
1566+
15151567
@mpl.style.context('default')
15161568
@check_figures_equal(extensions=["png"])
15171569
def test_scalarmap_update(fig_test, fig_ref):

0 commit comments

Comments
 (0)