Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit e75f10d

Browse filesBrowse 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
Copy full SHA for e75f10d

File tree

Expand file treeCollapse file tree

2 files changed

+244
-31
lines changed
Filter options
Expand file treeCollapse file tree

2 files changed

+244
-31
lines changed

‎lib/mpl_toolkits/mplot3d/axes3d.py

Copy file name to clipboardExpand all lines: lib/mpl_toolkits/mplot3d/axes3d.py
+191-30Lines 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

Copy file name to clipboardExpand all lines: lib/mpl_toolkits/tests/test_mplot3d.py
+53-1Lines 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 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.