diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index f9c7dc7f9416..8e6869af2807 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -157,7 +157,7 @@ def __init__( self.fmt_zdata = None self.mouse_init() - self.figure.canvas.callbacks._connect_picklable( + self._move_cid = self.figure.canvas.callbacks._connect_picklable( 'motion_notify_event', self._on_move) self.figure.canvas.callbacks._connect_picklable( 'button_press_event', self._button_press) @@ -924,18 +924,14 @@ def disable_mouse_rotation(self): def can_zoom(self): """ Return whether this Axes supports the zoom box button functionality. - - Axes3D objects do not use the zoom box button. """ - return False + return True def can_pan(self): """ - Return whether this Axes supports the pan/zoom button functionality. - - Axes3d objects do not use the pan/zoom button. + Return whether this Axes supports the pan button functionality. """ - return False + return True def cla(self): # docstring inherited. @@ -1055,6 +1051,11 @@ def _on_move(self, event): if not self.button_pressed: return + if self.get_navigate_mode() is not None: + # we don't want to rotate if we are zooming/panning + # from the toolbar + return + if self.M is None: return @@ -1066,7 +1067,6 @@ def _on_move(self, event): dx, dy = x - self.sx, y - self.sy w = self._pseudo_w h = self._pseudo_h - self.sx, self.sy = x, y # Rotation if self.button_pressed in self._rotate_btn: @@ -1082,28 +1082,14 @@ def _on_move(self, event): self.azim = self.azim + dazim self.get_proj() self.stale = True - self.figure.canvas.draw_idle() elif self.button_pressed == 2: - # pan view - # get the x and y pixel coords - if dx == 0 and dy == 0: - return - minx, maxx, miny, maxy, minz, maxz = self.get_w_lims() - dx = 1-((w - dx)/w) - dy = 1-((h - dy)/h) - elev = np.deg2rad(self.elev) - azim = np.deg2rad(self.azim) - # project xv, yv, zv -> xw, yw, zw - dxx = (maxx-minx)*(dy*np.sin(elev)*np.cos(azim) + dx*np.sin(azim)) - dyy = (maxy-miny)*(-dx*np.cos(azim) + dy*np.sin(elev)*np.sin(azim)) - dzz = (maxz-minz)*(-dy*np.cos(elev)) - # pan - self.set_xlim3d(minx + dxx, maxx + dxx) - self.set_ylim3d(miny + dyy, maxy + dyy) - self.set_zlim3d(minz + dzz, maxz + dzz) - self.get_proj() - self.figure.canvas.draw_idle() + # Start the pan event with pixel coordinates + px, py = self.transData.transform([self.sx, self.sy]) + self.start_pan(px, py, 2) + # pan view (takes pixel coordinate input) + self.drag_pan(2, None, event.x, event.y) + self.end_pan() # Zoom elif self.button_pressed in self._zoom_btn: @@ -1118,7 +1104,182 @@ def _on_move(self, event): self.set_ylim3d(miny - dy, maxy + dy) self.set_zlim3d(minz - dz, maxz + dz) self.get_proj() - self.figure.canvas.draw_idle() + + # Store the event coordinates for the next time through. + self.sx, self.sy = x, y + # Always request a draw update at the end of interaction + self.figure.canvas.draw_idle() + + def drag_pan(self, button, key, x, y): + # docstring inherited + + # Get the coordinates from the move event + p = self._pan_start + (xdata, ydata), (xdata_start, ydata_start) = p.trans_inverse.transform( + [(x, y), (p.x, p.y)]) + self.sx, self.sy = xdata, ydata + # Calling start_pan() to set the x/y of this event as the starting + # move location for the next event + self.start_pan(x, y, button) + dx, dy = xdata - xdata_start, ydata - ydata_start + if dx == 0 and dy == 0: + return + + # Now pan the view by updating the limits + w = self._pseudo_w + h = self._pseudo_h + + minx, maxx, miny, maxy, minz, maxz = self.get_w_lims() + dx = 1 - ((w - dx) / w) + dy = 1 - ((h - dy) / h) + elev = np.deg2rad(self.elev) + azim = np.deg2rad(self.azim) + # project xv, yv, zv -> xw, yw, zw + dxx = (maxx - minx) * (dy * np.sin(elev) + * np.cos(azim) + dx * np.sin(azim)) + dyy = (maxy - miny) * (-dx * np.cos(azim) + + dy * np.sin(elev) * np.sin(azim)) + dzz = (maxz - minz) * (-dy * np.cos(elev)) + # pan + self.set_xlim3d(minx + dxx, maxx + dxx) + self.set_ylim3d(miny + dyy, maxy + dyy) + self.set_zlim3d(minz + dzz, maxz + dzz) + self.get_proj() + + def _set_view_from_bbox(self, bbox, direction='in', + mode=None, twinx=False, twiny=False): + # docstring inherited + + # bbox is (start_x, start_y, event.x, event.y) in screen coords + # _prepare_view_from_bbox will give us back new *data* coords + # (in the 2D transform space, not 3D world coords) + new_xbound, new_ybound = self._prepare_view_from_bbox( + bbox, direction=direction, mode=mode, twinx=twinx, twiny=twiny) + # We need to get the Zoom bbox limits relative to the Axes limits + # 1) Axes bottom-left -> Zoom box bottom-left + # 2) Axes top-right -> Zoom box top-right + axes_to_data_trans = self.transAxes + self.transData.inverted() + axes_data_bbox = axes_to_data_trans.transform([(0, 0), (1, 1)]) + # dx, dy gives us the vector difference from the axes to the + dx1, dy1 = (axes_data_bbox[0][0] - new_xbound[0], + axes_data_bbox[0][1] - new_ybound[0]) + dx2, dy2 = (axes_data_bbox[1][0] - new_xbound[1], + axes_data_bbox[1][1] - new_ybound[1]) + + def data_2d_to_world_3d(dx, dy): + # Takes the vector (dx, dy) in transData coords and + # transforms that to each of the 3 world data coords + # (x, y, z) for calculating the offset + w = self._pseudo_w + h = self._pseudo_h + + dx = 1 - ((w - dx) / w) + dy = 1 - ((h - dy) / h) + elev = np.deg2rad(self.elev) + azim = np.deg2rad(self.azim) + # project xv, yv, zv -> xw, yw, zw + dxx = (dy * np.sin(elev) + * np.cos(azim) + dx * np.sin(azim)) + dyy = (-dx * np.cos(azim) + + dy * np.sin(elev) * np.sin(azim)) + dzz = (-dy * np.cos(elev)) + return dxx, dyy, dzz + + # These are the amounts to bring the projection in or out by from + # each side (1 left, 2 right) because we aren't necessarily zooming + # into the center of the projection. + dxx1, dyy1, dzz1 = data_2d_to_world_3d(dx1, dy1) + dxx2, dyy2, dzz2 = data_2d_to_world_3d(dx2, dy2) + # update the min and max limits of the world + minx, maxx, miny, maxy, minz, maxz = self.get_w_lims() + self.set_xlim3d(minx + dxx1 * (maxx - minx), + maxx + dxx2 * (maxx - minx)) + self.set_ylim3d(miny + dyy1 * (maxy - miny), + maxy + dyy2 * (maxy - miny)) + self.set_zlim3d(minz + dzz1 * (maxz - minz), + maxz + dzz2 * (maxz - minz)) + self.get_proj() + + def _prepare_view_from_bbox(self, bbox, direction='in', + mode=None, twinx=False, twiny=False): + """ + Helper function to prepare the new bounds from a bbox. + + This helper function returns the new x and y bounds from the zoom + bbox. This a convenience method to abstract the bbox logic + out of the base setter. + """ + if len(bbox) == 3: + xp, yp, scl = bbox # Zooming code + if scl == 0: # Should not happen + scl = 1. + if scl > 1: + direction = 'in' + else: + direction = 'out' + scl = 1 / scl + # get the limits of the axes + (xmin, ymin), (xmax, ymax) = self.transData.transform( + np.transpose([self.get_xlim(), self.get_ylim()])) + # set the range + xwidth = xmax - xmin + ywidth = ymax - ymin + xcen = (xmax + xmin) * .5 + ycen = (ymax + ymin) * .5 + xzc = (xp * (scl - 1) + xcen) / scl + yzc = (yp * (scl - 1) + ycen) / scl + bbox = [xzc - xwidth / 2. / scl, yzc - ywidth / 2. / scl, + xzc + xwidth / 2. / scl, yzc + ywidth / 2. / scl] + elif len(bbox) != 4: + # should be len 3 or 4 but nothing else + _api.warn_external( + "Warning in _set_view_from_bbox: bounding box is not a tuple " + "of length 3 or 4. Ignoring the view change.") + return + + # Original limits + # Can't use get_x/y bounds because those aren't in 2D space + pseudo_bbox = self.transLimits.inverted().transform([(0, 0), (1, 1)]) + (xmin0, ymin0), (xmax0, ymax0) = pseudo_bbox + # The zoom box in screen coords. + startx, starty, stopx, stopy = bbox + # Convert to data coords. + (startx, starty), (stopx, stopy) = self.transData.inverted().transform( + [(startx, starty), (stopx, stopy)]) + # Clip to axes limits. + xmin, xmax = np.clip(sorted([startx, stopx]), xmin0, xmax0) + ymin, ymax = np.clip(sorted([starty, stopy]), ymin0, ymax0) + # Don't double-zoom twinned axes or if zooming only the other axis. + if twinx or mode == "y": + xmin, xmax = xmin0, xmax0 + if twiny or mode == "x": + ymin, ymax = ymin0, ymax0 + + if direction == "in": + new_xbound = xmin, xmax + new_ybound = ymin, ymax + + elif direction == "out": + x_trf = self.xaxis.get_transform() + sxmin0, sxmax0, sxmin, sxmax = x_trf.transform( + [xmin0, xmax0, xmin, xmax]) # To screen space. + factor = (sxmax0 - sxmin0) / (sxmax - sxmin) # Unzoom factor. + # Move original bounds away by + # (factor) x (distance between unzoom box and Axes bbox). + sxmin1 = sxmin0 - factor * (sxmin - sxmin0) + sxmax1 = sxmax0 + factor * (sxmax0 - sxmax) + # And back to data space. + new_xbound = x_trf.inverted().transform([sxmin1, sxmax1]) + + y_trf = self.yaxis.get_transform() + symin0, symax0, symin, symax = y_trf.transform( + [ymin0, ymax0, ymin, ymax]) + factor = (symax0 - symin0) / (symax - symin) + symin1 = symin0 - factor * (symin - symin0) + symax1 = symax0 + factor * (symax0 - symax) + new_ybound = y_trf.inverted().transform([symin1, symax1]) + + return new_xbound, new_ybound def set_zlabel(self, zlabel, fontdict=None, labelpad=None, **kwargs): """ diff --git a/lib/mpl_toolkits/tests/test_mplot3d.py b/lib/mpl_toolkits/tests/test_mplot3d.py index 543816f9b9dd..f2fb5ff09cdd 100644 --- a/lib/mpl_toolkits/tests/test_mplot3d.py +++ b/lib/mpl_toolkits/tests/test_mplot3d.py @@ -5,7 +5,8 @@ from mpl_toolkits.mplot3d import Axes3D, axes3d, proj3d, art3d import matplotlib as mpl -from matplotlib.backend_bases import MouseButton +from matplotlib.backend_bases import (MouseButton, MouseEvent, + NavigationToolbar2) from matplotlib import cm from matplotlib import colors as mcolors from matplotlib.testing.decorators import image_comparison, check_figures_equal @@ -1512,6 +1513,57 @@ def convert_lim(dmin, dmax): assert z_center != pytest.approx(z_center0) +@pytest.mark.parametrize("tool,button,expected", + [("zoom", MouseButton.LEFT, # zoom in + ((-0.02, 0.06), (0, 0.06), (-0.01, 0.06))), + ("zoom", MouseButton.RIGHT, # zoom out + ((-0.13, 0.06), (-0.18, 0.06), (-0.17, 0.06))), + ("pan", MouseButton.LEFT, + ((-0.46, -0.34), (-0.66, -0.54), (-0.62, -0.5)))]) +def test_toolbar_zoom_pan(tool, button, expected): + # NOTE: The expected values are rough ballparks of moving in the view + # to make sure we are getting the right direction of motion. + # The specific values can and should change if the zoom/pan + # movement scaling factors get updated. + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + ax.scatter(0, 0, 0) + fig.canvas.draw() + + # Mouse from (0, 0) to (1, 1) + d0 = (0, 0) + d1 = (1, 1) + # Convert to screen coordinates ("s"). Events are defined only with pixel + # precision, so round the pixel values, and below, check against the + # corresponding xdata/ydata, which are close but not equal to d0/d1. + s0 = ax.transData.transform(d0).astype(int) + s1 = ax.transData.transform(d1).astype(int) + + # Set up the mouse movements + start_event = MouseEvent( + "button_press_event", fig.canvas, *s0, button) + stop_event = MouseEvent( + "button_release_event", fig.canvas, *s1, button) + + tb = NavigationToolbar2(fig.canvas) + if tool == "zoom": + tb.zoom() + tb.press_zoom(start_event) + tb.drag_zoom(stop_event) + tb.release_zoom(stop_event) + else: + tb.pan() + tb.press_pan(start_event) + tb.drag_pan(stop_event) + tb.release_pan(stop_event) + + # Should be close, but won't be exact due to screen integer resolution + xlim, ylim, zlim = expected + assert (ax.get_xlim3d()) == pytest.approx(xlim, abs=0.01) + assert (ax.get_ylim3d()) == pytest.approx(ylim, abs=0.01) + assert (ax.get_zlim3d()) == pytest.approx(zlim, abs=0.01) + + @mpl.style.context('default') @check_figures_equal(extensions=["png"]) def test_scalarmap_update(fig_test, fig_ref):
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies: