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

Skip to content

Commit 3981bca

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 cb8680f commit 3981bca

File tree

2 files changed

+243
-26
lines changed

2 files changed

+243
-26
lines changed

lib/mpl_toolkits/mplot3d/axes3d.py

Lines changed: 190 additions & 25 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)
@@ -927,15 +927,15 @@ def can_zoom(self):
927927
928928
Axes3D objects do not use the zoom box button.
929929
"""
930-
return False
930+
return True
931931

932932
def can_pan(self):
933933
"""
934934
Return whether this Axes supports the pan/zoom button functionality.
935935
936936
Axes3d objects do not use the pan/zoom button.
937937
"""
938-
return False
938+
return True
939939

940940
def cla(self):
941941
# docstring inherited.
@@ -1055,6 +1055,11 @@ def _on_move(self, event):
10551055
if not self.button_pressed:
10561056
return
10571057

1058+
if self.get_navigate_mode() is not None:
1059+
# we don't want to rotate if we are zooming/panning
1060+
# from the toolbar
1061+
return
1062+
10581063
if self.M is None:
10591064
return
10601065

@@ -1066,7 +1071,6 @@ def _on_move(self, event):
10661071
dx, dy = x - self.sx, y - self.sy
10671072
w = self._pseudo_w
10681073
h = self._pseudo_h
1069-
self.sx, self.sy = x, y
10701074

10711075
# Rotation
10721076
if self.button_pressed in self._rotate_btn:
@@ -1082,28 +1086,14 @@ def _on_move(self, event):
10821086
self.azim = self.azim + dazim
10831087
self.get_proj()
10841088
self.stale = True
1085-
self.figure.canvas.draw_idle()
10861089

10871090
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()
1091+
# Start the pan event with pixel coordinates
1092+
px, py = self.transData.transform([self.sx, self.sy])
1093+
self.start_pan(px, py, 2)
1094+
# pan view (takes pixel coordinate input)
1095+
self.drag_pan(2, None, event.x, event.y)
1096+
self.end_pan()
11071097

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

11231288
def set_zlabel(self, zlabel, fontdict=None, labelpad=None, **kwargs):
11241289
"""

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)