From d161de3c31fb511c47d81950614768233f6c64ca Mon Sep 17 00:00:00 2001 From: Greg Lucas Date: Wed, 2 Mar 2022 21:17:13 -0700 Subject: [PATCH 01/39] 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. --- lib/mpl_toolkits/mplot3d/axes3d.py | 221 +++++++++++++++++++++---- lib/mpl_toolkits/tests/test_mplot3d.py | 54 +++++- 2 files changed, 244 insertions(+), 31 deletions(-) diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index d50ad5235ccd..08f533094399 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -156,7 +156,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) @@ -920,18 +920,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 clear(self): # docstring inherited. @@ -1050,6 +1046,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 @@ -1061,7 +1062,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: @@ -1077,28 +1077,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: @@ -1113,7 +1099,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 13b6100a505d..962008815679 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 @@ -1530,6 +1531,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): From 9fd9c662770a200f0de4944558709683b9464667 Mon Sep 17 00:00:00 2001 From: Scott Shambaugh Date: Fri, 15 Jul 2022 12:55:32 -0500 Subject: [PATCH 02/39] Rewrite zooming with bounding box --- lib/mpl_toolkits/mplot3d/axes3d.py | 142 ++++++++--------------------- 1 file changed, 37 insertions(+), 105 deletions(-) diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index 08f533094399..8a1cf39d5550 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -1088,17 +1088,9 @@ def _on_move(self, event): # Zoom elif self.button_pressed in self._zoom_btn: - # zoom view - # hmmm..this needs some help from clipping.... - minx, maxx, miny, maxy, minz, maxz = self.get_w_lims() - df = 1-((h - dy)/h) - dx = (maxx-minx)*df - dy = (maxy-miny)*df - dz = (maxz-minz)*df - self.set_xlim3d(minx - dx, maxx + dx) - self.set_ylim3d(miny - dy, maxy + dy) - self.set_zlim3d(minz - dz, maxz + dz) - self.get_proj() + # zoom view (dragging down zooms in) + scale = h/(h - dy) + self._zoom_data_limits(scale) # Store the event coordinates for the next time through. self.sx, self.sy = x, y @@ -1143,63 +1135,31 @@ def drag_pan(self, button, key, x, y): def _set_view_from_bbox(self, bbox, direction='in', mode=None, twinx=False, twiny=False): - # docstring inherited + # Move the center of the view to the center of the bbox + (start_x, start_y, stop_x, stop_y) = self._prepare_view_from_bbox(bbox) + zoom_center_x = (start_x + stop_x)/2 + zoom_center_y = (start_y + stop_y)/2 - # 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() + ax_center_x = (self.bbox.max[0] + self.bbox.min[0])/2 + ax_center_y = (self.bbox.max[1] + self.bbox.min[1])/2 + + self.start_pan(zoom_center_x, zoom_center_y, 2) + self.drag_pan(2, None, ax_center_x, ax_center_y) + self.end_pan() + + # Calculate zoom level + scale_x = abs((start_x - stop_x)/(self.bbox.max[0] - self.bbox.min[0])) + scale_y = abs((start_y - stop_y)/(self.bbox.max[1] - self.bbox.min[1])) + scale = max(scale_x, scale_y) + if direction == 'out': + scale = 1 / scale + + self._zoom_data_limits(scale) 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. @@ -1232,49 +1192,21 @@ def _prepare_view_from_bbox(self, bbox, direction='in', "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 + return bbox + + def _zoom_data_limits(self, scale): + # hmmm..this needs some help from clipping.... + minx, maxx, miny, maxy, minz, maxz = self.get_w_lims() + cx = (maxx + minx)/2 + cy = (maxy + miny)/2 + cz = (maxz + minz)/2 + dx = (maxx - minx)*scale/2 + dy = (maxy - miny)*scale/2 + dz = (maxz - minz)*scale/2 + self.set_xlim3d(cx - dx, cx + dx) + self.set_ylim3d(cy - dy, cy + dy) + self.set_zlim3d(cz - dz, cz + dz) + self.get_proj() def set_zlabel(self, zlabel, fontdict=None, labelpad=None, **kwargs): """ From 4b1d8cfc2468d0ef130dda7ac194680f5d48bf59 Mon Sep 17 00:00:00 2001 From: Scott Shambaugh Date: Fri, 15 Jul 2022 20:20:20 -0500 Subject: [PATCH 03/39] Rewrite 3d panning to work with a roll angle --- lib/mpl_toolkits/mplot3d/axes3d.py | 52 +++++++++++++++++------------- lib/mpl_toolkits/mplot3d/proj3d.py | 6 ++-- 2 files changed, 33 insertions(+), 25 deletions(-) diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index 8a1cf39d5550..72bc820f729c 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -838,15 +838,13 @@ def get_proj(self): pb_aspect=box_aspect, ) - # Look into the middle of the new coordinates: + # Look into the middle of the world coordinates: R = 0.5 * box_aspect # elev stores the elevation angle in the z plane # azim stores the azimuth angle in the x,y plane - # roll stores the roll angle about the view axis elev_rad = np.deg2rad(art3d._norm_angle(self.elev)) azim_rad = np.deg2rad(art3d._norm_angle(self.azim)) - roll_rad = np.deg2rad(art3d._norm_angle(self.roll)) # Coordinates for a point that rotates around the box of data. # p0, p1 corresponds to rotating the box only around the @@ -864,28 +862,20 @@ def get_proj(self): # The coordinates for the eye viewing point. The eye is looking # towards the middle of the box of data from a distance: eye = R + self._dist * ps - - # TODO: Is this being used somewhere? Can it be removed? self.eye = eye - self.vvec = R - eye - self.vvec = self.vvec / np.linalg.norm(self.vvec) - - # Define which axis should be vertical. A negative value - # indicates the plot is upside down and therefore the values - # have been reversed: - V = np.zeros(3) - V[self._vertical_axis] = -1 if abs(elev_rad) > 0.5 * np.pi else 1 # Generate the view and projection transformation matrices if self._focal_length == np.inf: # Orthographic projection - viewM = proj3d.view_transformation(eye, R, V, roll_rad) + u, v, n = self._get_view_axes(eye) + viewM = proj3d.view_transformation(u, v, n, eye) projM = proj3d.ortho_transformation(-self._dist, self._dist) else: # Perspective projection # Scale the eye dist to compensate for the focal length zoom effect eye_focal = R + self._dist * ps * self._focal_length - viewM = proj3d.view_transformation(eye_focal, R, V, roll_rad) + u, v, n = self._get_view_axes(eye_focal) + viewM = proj3d.view_transformation(u, v, n, eye_focal) projM = proj3d.persp_transformation(-self._dist, self._dist, self._focal_length) @@ -1119,20 +1109,36 @@ def drag_pan(self, button, key, x, y): 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)) + dz = 0 + u, v, n = self._get_view_axes(self.eye) + + dxyz_projected = -dx*u -dy*v -dz*n + dxx = (maxx - minx) * dxyz_projected[0] + dyy = (maxy - miny) * dxyz_projected[1] + dzz = (maxz - minz) * dxyz_projected[2] + # 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 _get_view_axes(self, eye): + elev_rad = np.deg2rad(art3d._norm_angle(self.elev)) + roll_rad = np.deg2rad(art3d._norm_angle(self.roll)) + + # Look into the middle of the world coordinates + R = 0.5 * self._roll_to_vertical(self._box_aspect) + + # Define which axis should be vertical. A negative value + # indicates the plot is upside down and therefore the values + # have been reversed: + V = np.zeros(3) + V[self._vertical_axis] = -1 if abs(elev_rad) > np.pi/2 else 1 + + u, v, n = proj3d.view_axes(eye, R, V, roll_rad) + return u, v, n + def _set_view_from_bbox(self, bbox, direction='in', mode=None, twinx=False, twiny=False): # Move the center of the view to the center of the bbox diff --git a/lib/mpl_toolkits/mplot3d/proj3d.py b/lib/mpl_toolkits/mplot3d/proj3d.py index 2f23e3779b06..9cb9390c1f67 100644 --- a/lib/mpl_toolkits/mplot3d/proj3d.py +++ b/lib/mpl_toolkits/mplot3d/proj3d.py @@ -72,7 +72,7 @@ def rotation_about_vector(v, angle): return R -def view_transformation(E, R, V, roll): +def view_axes(E, R, V, roll): n = (E - R) n = n/np.linalg.norm(n) u = np.cross(V, n) @@ -85,12 +85,14 @@ def view_transformation(E, R, V, roll): Rroll = rotation_about_vector(n, -roll) u = np.dot(Rroll, u) v = np.dot(Rroll, v) + return u, v, n + +def view_transformation(u, v, n, E): Mr = np.eye(4) Mt = np.eye(4) Mr[:3, :3] = [u, v, n] Mt[:3, -1] = -E - return np.dot(Mr, Mt) From a6e4651d9483b2398f383f18e13f06bbe23c9971 Mon Sep 17 00:00:00 2001 From: Scott Shambaugh Date: Fri, 15 Jul 2022 20:31:02 -0500 Subject: [PATCH 04/39] Whats new for zoom and pan buttons --- doc/users/next_whats_new/3d_plot_pan_zoom.rst | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 doc/users/next_whats_new/3d_plot_pan_zoom.rst diff --git a/doc/users/next_whats_new/3d_plot_pan_zoom.rst b/doc/users/next_whats_new/3d_plot_pan_zoom.rst new file mode 100644 index 000000000000..89b0a019bf59 --- /dev/null +++ b/doc/users/next_whats_new/3d_plot_pan_zoom.rst @@ -0,0 +1,7 @@ +3D plot pan and zoom buttons +---------------------------- + +The pan and zoom buttons in the toolbar of 3D plots are now enabled. +Deselect both to rotate the plot. When the zoom button is pressed, +zoom in by using the left mouse button to draw a bounding box, and +out by using the right mouse button to draw the box. From d7a3d6cbee0edd437eff71207b1dbf3fc950508f Mon Sep 17 00:00:00 2001 From: Scott Shambaugh Date: Fri, 15 Jul 2022 21:47:57 -0500 Subject: [PATCH 05/39] Make pan button configurable --- lib/mpl_toolkits/mplot3d/axes3d.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index 72bc820f729c..c999588ce274 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -885,7 +885,7 @@ def get_proj(self): M = np.dot(projM, M0) return M - def mouse_init(self, rotate_btn=1, zoom_btn=3): + def mouse_init(self, rotate_btn=1, pan_btn=2, zoom_btn=3): """ Set the mouse buttons for 3D rotation and zooming. @@ -893,6 +893,8 @@ def mouse_init(self, rotate_btn=1, zoom_btn=3): ---------- rotate_btn : int or list of int, default: 1 The mouse button or buttons to use for 3D rotation of the axes. + pan_btn : int or list of int, default: 2 + The mouse button or buttons to use to pan the 3D axes. zoom_btn : int or list of int, default: 3 The mouse button or buttons to use to zoom the 3D axes. """ @@ -901,11 +903,12 @@ def mouse_init(self, rotate_btn=1, zoom_btn=3): # a regular list to avoid comparisons against None # which breaks in recent versions of numpy. self._rotate_btn = np.atleast_1d(rotate_btn).tolist() + self._pan_btn = np.atleast_1d(pan_btn).tolist() self._zoom_btn = np.atleast_1d(zoom_btn).tolist() def disable_mouse_rotation(self): - """Disable mouse buttons for 3D rotation and zooming.""" - self.mouse_init(rotate_btn=[], zoom_btn=[]) + """Disable mouse buttons for 3D rotation, panning, and zooming.""" + self.mouse_init(rotate_btn=[], pan_btn=[], zoom_btn=[]) def can_zoom(self): """ @@ -1029,8 +1032,8 @@ def _on_move(self, event): """ Mouse moving. - By default, button-1 rotates and button-3 zooms; these buttons can be - modified via `mouse_init`. + By default, button-1 rotates, button-2 pans, and button-3 zooms; + these buttons can be modified via `mouse_init`. """ if not self.button_pressed: @@ -1068,7 +1071,7 @@ def _on_move(self, event): self.get_proj() self.stale = True - elif self.button_pressed == 2: + elif self.button_pressed in self._pan_btn: # Start the pan event with pixel coordinates px, py = self.transData.transform([self.sx, self.sy]) self.start_pan(px, py, 2) From acbec0c8cf00ce9eeec24d488a907bfd69bc376d Mon Sep 17 00:00:00 2001 From: Scott Shambaugh Date: Tue, 19 Jul 2022 13:15:52 -0700 Subject: [PATCH 06/39] Do not jump when zooming and mouse goes over other subplot --- lib/mpl_toolkits/mplot3d/axes3d.py | 38 +++++++++++++++--------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index c999588ce274..449a8dd00948 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -1049,7 +1049,7 @@ def _on_move(self, event): x, y = event.xdata, event.ydata # In case the mouse is out of bounds. - if x is None: + if x is None or event.inaxes != self: return dx, dy = x - self.sx, y - self.sy @@ -1083,7 +1083,7 @@ def _on_move(self, event): elif self.button_pressed in self._zoom_btn: # zoom view (dragging down zooms in) scale = h/(h - dy) - self._zoom_data_limits(scale) + self._zoom_data_limits(scale, scale) # Store the event coordinates for the next time through. self.sx, self.sy = x, y @@ -1104,23 +1104,20 @@ def drag_pan(self, button, key, x, y): 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) dz = 0 + + # Transform the pan into view-projected coordinates u, v, n = self._get_view_axes(self.eye) + U, V, N = -np.array([u, v, n]) / self._box_aspect * self._dist + dxyz_projected = dx*U + dy*V + dz*N - dxyz_projected = -dx*u -dy*v -dz*n + # Calculate pan distance + minx, maxx, miny, maxy, minz, maxz = self.get_w_lims() dxx = (maxx - minx) * dxyz_projected[0] dyy = (maxy - miny) * dxyz_projected[1] dzz = (maxz - minz) * dxyz_projected[2] - # pan + # Set the new axis limits self.set_xlim3d(minx + dxx, maxx + dxx) self.set_ylim3d(miny + dyy, maxy + dyy) self.set_zlim3d(minz + dzz, maxz + dzz) @@ -1159,11 +1156,11 @@ def _set_view_from_bbox(self, bbox, direction='in', # Calculate zoom level scale_x = abs((start_x - stop_x)/(self.bbox.max[0] - self.bbox.min[0])) scale_y = abs((start_y - stop_y)/(self.bbox.max[1] - self.bbox.min[1])) - scale = max(scale_x, scale_y) if direction == 'out': - scale = 1 / scale + scale_x = 1 / scale_x + scale_y = 1 / scale_y - self._zoom_data_limits(scale) + self._zoom_data_limits(scale_x, scale_y) def _prepare_view_from_bbox(self, bbox, direction='in', mode=None, twinx=False, twiny=False): @@ -1203,15 +1200,18 @@ def _prepare_view_from_bbox(self, bbox, direction='in', return bbox - def _zoom_data_limits(self, scale): + def _zoom_data_limits(self, scale_x, scale_y, scale_z=1): # hmmm..this needs some help from clipping.... + u, v, n = self._get_view_axes(self.eye) + scale = np.abs(scale_x*u + scale_y*v + scale_z*n) + minx, maxx, miny, maxy, minz, maxz = self.get_w_lims() cx = (maxx + minx)/2 cy = (maxy + miny)/2 cz = (maxz + minz)/2 - dx = (maxx - minx)*scale/2 - dy = (maxy - miny)*scale/2 - dz = (maxz - minz)*scale/2 + dx = (maxx - minx)*scale[0]/2 + dy = (maxy - miny)*scale[1]/2 + dz = (maxz - minz)*scale[2]/2 self.set_xlim3d(cx - dx, cx + dx) self.set_ylim3d(cy - dy, cy + dy) self.set_zlim3d(cz - dz, cz + dz) From bb95903b88038a83089fe0656fe2ee3a77bd9c96 Mon Sep 17 00:00:00 2001 From: Scott Shambaugh Date: Tue, 19 Jul 2022 13:18:46 -0700 Subject: [PATCH 07/39] Rework zooming for 3d plots --- lib/mpl_toolkits/mplot3d/axes3d.py | 31 +++++++++++++++--------------- lib/mpl_toolkits/mplot3d/proj3d.py | 6 ++++++ 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index 449a8dd00948..99674999b270 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -1068,7 +1068,6 @@ def _on_move(self, event): dazim = -(dy/h)*180*np.sin(roll) - (dx/w)*180*np.cos(roll) self.elev = self.elev + delev self.azim = self.azim + dazim - self.get_proj() self.stale = True elif self.button_pressed in self._pan_btn: @@ -1083,7 +1082,7 @@ def _on_move(self, event): elif self.button_pressed in self._zoom_btn: # zoom view (dragging down zooms in) scale = h/(h - dy) - self._zoom_data_limits(scale, scale) + self._zoom_data_limits(scale, scale, scale) # Store the event coordinates for the next time through. self.sx, self.sy = x, y @@ -1106,7 +1105,7 @@ def drag_pan(self, button, key, x, y): return dz = 0 - # Transform the pan into view-projected coordinates + # Transform the pan from the view axes to the data axees u, v, n = self._get_view_axes(self.eye) U, V, N = -np.array([u, v, n]) / self._box_aspect * self._dist dxyz_projected = dx*U + dy*V + dz*N @@ -1121,7 +1120,6 @@ def drag_pan(self, button, key, x, y): 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 _get_view_axes(self, eye): elev_rad = np.deg2rad(art3d._norm_angle(self.elev)) @@ -1154,8 +1152,8 @@ def _set_view_from_bbox(self, bbox, direction='in', self.end_pan() # Calculate zoom level - scale_x = abs((start_x - stop_x)/(self.bbox.max[0] - self.bbox.min[0])) - scale_y = abs((start_y - stop_y)/(self.bbox.max[1] - self.bbox.min[1])) + scale_x = abs(start_x - stop_x) / (self.bbox.max[0] - self.bbox.min[0]) + scale_y = abs(start_y - stop_y) / (self.bbox.max[1] - self.bbox.min[1]) if direction == 'out': scale_x = 1 / scale_x scale_y = 1 / scale_y @@ -1201,21 +1199,24 @@ def _prepare_view_from_bbox(self, bbox, direction='in', return bbox def _zoom_data_limits(self, scale_x, scale_y, scale_z=1): - # hmmm..this needs some help from clipping.... + # Convert from the scale factors in the view frame to the data frame u, v, n = self._get_view_axes(self.eye) - scale = np.abs(scale_x*u + scale_y*v + scale_z*n) + R = np.array([u, v, n]) + S = np.array([scale_x, scale_y, scale_z]) + scale = np.linalg.norm(R.T@(np.eye(3)*S), axis=1) + # Scale the data range minx, maxx, miny, maxy, minz, maxz = self.get_w_lims() + dxyz = np.array([maxx - minx, maxy - miny, maxz - minz]) + dxyz_scaled = dxyz*scale + + # Set the axis limits cx = (maxx + minx)/2 cy = (maxy + miny)/2 cz = (maxz + minz)/2 - dx = (maxx - minx)*scale[0]/2 - dy = (maxy - miny)*scale[1]/2 - dz = (maxz - minz)*scale[2]/2 - self.set_xlim3d(cx - dx, cx + dx) - self.set_ylim3d(cy - dy, cy + dy) - self.set_zlim3d(cz - dz, cz + dz) - self.get_proj() + self.set_xlim3d(cx - dxyz_scaled[0]/2, cx + dxyz_scaled[0]/2) + self.set_ylim3d(cy - dxyz_scaled[1]/2, cy + dxyz_scaled[1]/2) + self.set_zlim3d(cz - dxyz_scaled[2]/2, cz + dxyz_scaled[2]/2) def set_zlabel(self, zlabel, fontdict=None, labelpad=None, **kwargs): """ diff --git a/lib/mpl_toolkits/mplot3d/proj3d.py b/lib/mpl_toolkits/mplot3d/proj3d.py index 9cb9390c1f67..3243465bab8d 100644 --- a/lib/mpl_toolkits/mplot3d/proj3d.py +++ b/lib/mpl_toolkits/mplot3d/proj3d.py @@ -73,6 +73,12 @@ def rotation_about_vector(v, angle): def view_axes(E, R, V, roll): + ''' + u, v, and n are the view axes + u is towards the right + v is towards the top + n is the vector out of the screen + ''' n = (E - R) n = n/np.linalg.norm(n) u = np.cross(V, n) From 88d616f6ccaa5b25fe127a73aeb7f77c7d1d16e9 Mon Sep 17 00:00:00 2001 From: Scott Shambaugh Date: Tue, 19 Jul 2022 13:24:59 -0700 Subject: [PATCH 08/39] Handle x/y lock when zooming and panning --- lib/mpl_toolkits/mplot3d/axes3d.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index 99674999b270..dd543f37d183 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -1101,9 +1101,13 @@ def drag_pan(self, button, key, x, y): # move location for the next event self.start_pan(x, y, button) dx, dy = xdata - xdata_start, ydata - ydata_start + dz = 0 + if key == 'x': + dy = 0 + elif key == 'y': + dx = 0 if dx == 0 and dy == 0: return - dz = 0 # Transform the pan from the view axes to the data axees u, v, n = self._get_view_axes(self.eye) @@ -1139,8 +1143,15 @@ def _get_view_axes(self, eye): def _set_view_from_bbox(self, bbox, direction='in', mode=None, twinx=False, twiny=False): - # Move the center of the view to the center of the bbox (start_x, start_y, stop_x, stop_y) = self._prepare_view_from_bbox(bbox) + if mode == 'x': + start_y = self.bbox.min[1] + stop_y = self.bbox.max[1] + elif mode == 'y': + start_x = self.bbox.min[0] + stop_x = self.bbox.max[0] + + # Move the center of the view to the center of the bbox zoom_center_x = (start_x + stop_x)/2 zoom_center_y = (start_y + stop_y)/2 From 5833512e3b327bd4079f783ca1a514da0f66f117 Mon Sep 17 00:00:00 2001 From: Scott Shambaugh Date: Tue, 19 Jul 2022 15:00:00 -0700 Subject: [PATCH 09/39] Update tests --- lib/mpl_toolkits/mplot3d/proj3d.py | 4 ++-- lib/mpl_toolkits/tests/test_mplot3d.py | 24 +++++++++++++----------- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/lib/mpl_toolkits/mplot3d/proj3d.py b/lib/mpl_toolkits/mplot3d/proj3d.py index 3243465bab8d..86ce2e74f92e 100644 --- a/lib/mpl_toolkits/mplot3d/proj3d.py +++ b/lib/mpl_toolkits/mplot3d/proj3d.py @@ -73,12 +73,12 @@ def rotation_about_vector(v, angle): def view_axes(E, R, V, roll): - ''' + """ u, v, and n are the view axes u is towards the right v is towards the top n is the vector out of the screen - ''' + """ n = (E - R) n = n/np.linalg.norm(n) u = np.cross(V, n) diff --git a/lib/mpl_toolkits/tests/test_mplot3d.py b/lib/mpl_toolkits/tests/test_mplot3d.py index 962008815679..2c9229bbde60 100644 --- a/lib/mpl_toolkits/tests/test_mplot3d.py +++ b/lib/mpl_toolkits/tests/test_mplot3d.py @@ -876,7 +876,8 @@ def _test_proj_make_M(): R = np.array([100, 100, 100]) V = np.array([0, 0, 1]) roll = 0 - viewM = proj3d.view_transformation(E, R, V, roll) + u, v, n = proj3d.view_axes(E, R, V, roll) + viewM = proj3d.view_transformation(u, v, n, E) perspM = proj3d.persp_transformation(100, -100, 1) M = np.dot(perspM, viewM) return M @@ -942,7 +943,8 @@ def test_proj_axes_cube_ortho(): R = np.array([0, 0, 0]) V = np.array([0, 0, 1]) roll = 0 - viewM = proj3d.view_transformation(E, R, V, roll) + u, v, n = proj3d.view_axes(E, R, V, roll) + viewM = proj3d.view_transformation(u, v, n, E) orthoM = proj3d.ortho_transformation(-1, 1) M = np.dot(orthoM, viewM) @@ -1533,16 +1535,16 @@ def convert_lim(dmin, dmax): @pytest.mark.parametrize("tool,button,expected", [("zoom", MouseButton.LEFT, # zoom in - ((-0.02, 0.06), (0, 0.06), (-0.01, 0.06))), + ((0.03, 0.61), (0.27, 0.71), (0.32, 0.89))), ("zoom", MouseButton.RIGHT, # zoom out - ((-0.13, 0.06), (-0.18, 0.06), (-0.17, 0.06))), + ((0.29, 0.35), (0.44, 0.53), (0.57, 0.64))), ("pan", MouseButton.LEFT, - ((-0.46, -0.34), (-0.66, -0.54), (-0.62, -0.5)))]) + ((-0.70, -0.58), (-1.03, -0.91), (-1.27, -1.15)))]) def test_toolbar_zoom_pan(tool, button, expected): - # NOTE: The expected values are rough ballparks of moving in the view + # NOTE: The expected zoom 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. + # The specific values can and should change if the zoom movement + # scaling factor gets updated. fig = plt.figure() ax = fig.add_subplot(projection='3d') ax.scatter(0, 0, 0) @@ -1577,9 +1579,9 @@ def test_toolbar_zoom_pan(tool, button, expected): # 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) + 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') From b6fe8b0b5f9eb66001a61291b69c17161219aa26 Mon Sep 17 00:00:00 2001 From: Scott Shambaugh Date: Tue, 19 Jul 2022 15:43:34 -0700 Subject: [PATCH 10/39] Docstrings --- lib/mpl_toolkits/mplot3d/axes3d.py | 22 ++++++++++++++++++++-- lib/mpl_toolkits/mplot3d/proj3d.py | 8 ++++---- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index dd543f37d183..053f0facb10a 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -1111,8 +1111,8 @@ def drag_pan(self, button, key, x, y): # Transform the pan from the view axes to the data axees u, v, n = self._get_view_axes(self.eye) - U, V, N = -np.array([u, v, n]) / self._box_aspect * self._dist - dxyz_projected = dx*U + dy*V + dz*N + R = -np.array([u, v, n]) / self._box_aspect * self._dist + dxyz_projected = R.T @ np.array([dx, dy, dz]) # Calculate pan distance minx, maxx, miny, maxy, minz, maxz = self.get_w_lims() @@ -1126,6 +1126,12 @@ def drag_pan(self, button, key, x, y): self.set_zlim3d(minz + dzz, maxz + dzz) def _get_view_axes(self, eye): + """ + Get the unit viewing axes in data coordinates. + `u` is towards the right of the screen + `v` is towards the top of the screen + `n` is out of the screen + """ elev_rad = np.deg2rad(art3d._norm_angle(self.elev)) roll_rad = np.deg2rad(art3d._norm_angle(self.roll)) @@ -1143,6 +1149,11 @@ def _get_view_axes(self, eye): def _set_view_from_bbox(self, bbox, direction='in', mode=None, twinx=False, twiny=False): + """ + Zoom in or out of the bounding box. + Will center the view on the center of the bounding box, and zoom by + the ratio of the size of the bounding box to the size of the Axes3D. + """ (start_x, start_y, stop_x, stop_y) = self._prepare_view_from_bbox(bbox) if mode == 'x': start_y = self.bbox.min[1] @@ -1210,6 +1221,13 @@ def _prepare_view_from_bbox(self, bbox, direction='in', return bbox def _zoom_data_limits(self, scale_x, scale_y, scale_z=1): + """ + Zoom in or out of a 3D plot. + Will scale the data limits by the scale factors, where scale_x, + scale_y, and scale_z refer to the scale factors for the viewing axes. + These will be transformed to the data axes based on the current view + angles. A scale factor > 1 zooms out and a scale factor < 1 zooms in. + """ # Convert from the scale factors in the view frame to the data frame u, v, n = self._get_view_axes(self.eye) R = np.array([u, v, n]) diff --git a/lib/mpl_toolkits/mplot3d/proj3d.py b/lib/mpl_toolkits/mplot3d/proj3d.py index 86ce2e74f92e..c4a54bea876e 100644 --- a/lib/mpl_toolkits/mplot3d/proj3d.py +++ b/lib/mpl_toolkits/mplot3d/proj3d.py @@ -74,10 +74,10 @@ def rotation_about_vector(v, angle): def view_axes(E, R, V, roll): """ - u, v, and n are the view axes - u is towards the right - v is towards the top - n is the vector out of the screen + Get the unit viewing axes in data coordinates. + `u` is towards the right of the screen + `v` is towards the top of the screen + `n` is out of the screen """ n = (E - R) n = n/np.linalg.norm(n) From 8a1e16659b464432f10d67f5cc88187c8bc01ab6 Mon Sep 17 00:00:00 2001 From: Scott Shambaugh Date: Tue, 19 Jul 2022 16:32:43 -0700 Subject: [PATCH 11/39] Dont assume a scale_z --- lib/mpl_toolkits/mplot3d/axes3d.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index 053f0facb10a..fe9ca4783876 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -1174,13 +1174,16 @@ def _set_view_from_bbox(self, bbox, direction='in', self.end_pan() # Calculate zoom level - scale_x = abs(start_x - stop_x) / (self.bbox.max[0] - self.bbox.min[0]) - scale_y = abs(start_y - stop_y) / (self.bbox.max[1] - self.bbox.min[1]) + dx = abs(start_x - stop_x) + dy = abs(start_y - stop_y) + scale_x = dx / (self.bbox.max[0] - self.bbox.min[0]) + scale_y = dy / (self.bbox.max[1] - self.bbox.min[1]) + scale_z = 1 if direction == 'out': scale_x = 1 / scale_x scale_y = 1 / scale_y - self._zoom_data_limits(scale_x, scale_y) + self._zoom_data_limits(scale_x, scale_y, scale_z) def _prepare_view_from_bbox(self, bbox, direction='in', mode=None, twinx=False, twiny=False): @@ -1220,7 +1223,7 @@ def _prepare_view_from_bbox(self, bbox, direction='in', return bbox - def _zoom_data_limits(self, scale_x, scale_y, scale_z=1): + def _zoom_data_limits(self, scale_x, scale_y, scale_z): """ Zoom in or out of a 3D plot. Will scale the data limits by the scale factors, where scale_x, @@ -1397,7 +1400,7 @@ def plot(self, xs, ys, *args, zdir='z', **kwargs): z coordinates of vertices; either one for all points or one for each point. zdir : {'x', 'y', 'z'}, default: 'z' - When plotting 2D data, the direction to use as z ('x', 'y' or 'z'). + When plotting 3D data, the direction to use as z ('x', 'y' or 'z'). **kwargs Other arguments are forwarded to `matplotlib.axes.Axes.plot`. """ From 8f434d69be678722c6490acc404d601f046af26e Mon Sep 17 00:00:00 2001 From: Scott Shambaugh Date: Tue, 19 Jul 2022 18:31:19 -0700 Subject: [PATCH 12/39] Limit zoom box --- lib/mpl_toolkits/mplot3d/axes3d.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index fe9ca4783876..c92b403c6577 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -1179,6 +1179,11 @@ def _set_view_from_bbox(self, bbox, direction='in', scale_x = dx / (self.bbox.max[0] - self.bbox.min[0]) scale_y = dy / (self.bbox.max[1] - self.bbox.min[1]) scale_z = 1 + + # Limit box zoom to reasonable range, protect for divide by zero below + scale_x = np.clip(scale_x, 1e-2, 1e2) + scale_y = np.clip(scale_y, 1e-2, 1e2) + if direction == 'out': scale_x = 1 / scale_x scale_y = 1 / scale_y From da4fccc521aede76adf2e9f2ee33d5eb6e5472ee Mon Sep 17 00:00:00 2001 From: Scott Shambaugh Date: Tue, 19 Jul 2022 18:46:24 -0700 Subject: [PATCH 13/39] Test zoom pan key modifiers --- lib/mpl_toolkits/tests/test_mplot3d.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/lib/mpl_toolkits/tests/test_mplot3d.py b/lib/mpl_toolkits/tests/test_mplot3d.py index 2c9229bbde60..100368868288 100644 --- a/lib/mpl_toolkits/tests/test_mplot3d.py +++ b/lib/mpl_toolkits/tests/test_mplot3d.py @@ -1533,14 +1533,22 @@ def convert_lim(dmin, dmax): assert z_center != pytest.approx(z_center0) -@pytest.mark.parametrize("tool,button,expected", - [("zoom", MouseButton.LEFT, # zoom in +@pytest.mark.parametrize("tool,button,key,expected", + [("zoom", MouseButton.LEFT, None, # zoom in ((0.03, 0.61), (0.27, 0.71), (0.32, 0.89))), - ("zoom", MouseButton.RIGHT, # zoom out + ("zoom", MouseButton.LEFT, 'x', # zoom in + ((0.17, 0.73), (0.09, 0.43), (-0.06, 0.06))), + ("zoom", MouseButton.LEFT, 'y', # zoom in + ((-0.23, -0.03), (0.07, 0.37), (0.32, 0.89))), + ("zoom", MouseButton.RIGHT, None, # zoom out ((0.29, 0.35), (0.44, 0.53), (0.57, 0.64))), - ("pan", MouseButton.LEFT, - ((-0.70, -0.58), (-1.03, -0.91), (-1.27, -1.15)))]) -def test_toolbar_zoom_pan(tool, button, expected): + ("pan", MouseButton.LEFT, None, + ((-0.70, -0.58), (-1.03, -0.91), (-1.27, -1.15))), + ("pan", MouseButton.LEFT, 'x', + ((-0.96, -0.84), (-0.58, -0.46), (-0.06, 0.06))), + ("pan", MouseButton.LEFT, 'y', + ((0.20, 0.32), (-0.51, -0.39), (-1.27, -1.15)))]) +def test_toolbar_zoom_pan(tool, button, key, expected): # NOTE: The expected zoom 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 movement @@ -1561,9 +1569,9 @@ def test_toolbar_zoom_pan(tool, button, expected): # Set up the mouse movements start_event = MouseEvent( - "button_press_event", fig.canvas, *s0, button) + "button_press_event", fig.canvas, *s0, button, key=key) stop_event = MouseEvent( - "button_release_event", fig.canvas, *s1, button) + "button_release_event", fig.canvas, *s1, button, key=key) tb = NavigationToolbar2(fig.canvas) if tool == "zoom": From 695fd5048435ef8d416f79ac803cfb7fe2a816b1 Mon Sep 17 00:00:00 2001 From: Scott Shambaugh Date: Wed, 20 Jul 2022 00:49:51 -0600 Subject: [PATCH 14/39] Save some calculation by saving view axes --- lib/mpl_toolkits/mplot3d/axes3d.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index c92b403c6577..fec8d44ffa9b 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -864,17 +864,21 @@ def get_proj(self): eye = R + self._dist * ps self.eye = eye + # Calculate the viewing axes for the eye position + u, v, n = self._calc_view_axes(eye) + self._view_u = u + self._view_v = v + self._view_n = n + # Generate the view and projection transformation matrices if self._focal_length == np.inf: # Orthographic projection - u, v, n = self._get_view_axes(eye) viewM = proj3d.view_transformation(u, v, n, eye) projM = proj3d.ortho_transformation(-self._dist, self._dist) else: # Perspective projection # Scale the eye dist to compensate for the focal length zoom effect eye_focal = R + self._dist * ps * self._focal_length - u, v, n = self._get_view_axes(eye_focal) viewM = proj3d.view_transformation(u, v, n, eye_focal) projM = proj3d.persp_transformation(-self._dist, self._dist, @@ -1110,8 +1114,8 @@ def drag_pan(self, button, key, x, y): return # Transform the pan from the view axes to the data axees - u, v, n = self._get_view_axes(self.eye) - R = -np.array([u, v, n]) / self._box_aspect * self._dist + R = np.array([self._view_u, self._view_v, self._view_n]) + R = -R / self._box_aspect * self._dist dxyz_projected = R.T @ np.array([dx, dy, dz]) # Calculate pan distance @@ -1125,7 +1129,7 @@ def drag_pan(self, button, key, x, y): self.set_ylim3d(miny + dyy, maxy + dyy) self.set_zlim3d(minz + dzz, maxz + dzz) - def _get_view_axes(self, eye): + def _calc_view_axes(self, eye): """ Get the unit viewing axes in data coordinates. `u` is towards the right of the screen @@ -1237,8 +1241,7 @@ def _zoom_data_limits(self, scale_x, scale_y, scale_z): angles. A scale factor > 1 zooms out and a scale factor < 1 zooms in. """ # Convert from the scale factors in the view frame to the data frame - u, v, n = self._get_view_axes(self.eye) - R = np.array([u, v, n]) + R = np.array([self._view_u, self._view_v, self._view_n]) S = np.array([scale_x, scale_y, scale_z]) scale = np.linalg.norm(R.T@(np.eye(3)*S), axis=1) From 86f4f640f8623cb120519b79d6b7a015bdb713fd Mon Sep 17 00:00:00 2001 From: Scott Shambaugh Date: Thu, 21 Jul 2022 16:39:05 -0600 Subject: [PATCH 15/39] Deprecation warnings for Axes3D.eye, .vvec --- lib/mpl_toolkits/mplot3d/axes3d.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index fec8d44ffa9b..0e511ebec365 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -52,6 +52,8 @@ class Axes3D(Axes): Axes._shared_axes["z"] = cbook.Grouper() dist = _api.deprecate_privatize_attribute("3.6") + vvec = _api.deprecate_privatize_attribute("3.6") + eye = _api.deprecate_privatize_attribute("3.6") def __init__( self, fig, rect=None, *args, @@ -862,7 +864,11 @@ def get_proj(self): # The coordinates for the eye viewing point. The eye is looking # towards the middle of the box of data from a distance: eye = R + self._dist * ps - self.eye = eye + + # vvec, self._vvec and self._eye are unused, remove when deprecated + vvec = R - eye + self._eye = eye + self._vvec = vvec / np.linalg.norm(vvec) # Calculate the viewing axes for the eye position u, v, n = self._calc_view_axes(eye) From 47f700097f8bc61520672889563e51a7d0d93711 Mon Sep 17 00:00:00 2001 From: Scott Shambaugh Date: Thu, 21 Jul 2022 21:36:17 -0600 Subject: [PATCH 16/39] Remove Axes3D._prepare_view_from_bbox for now --- lib/mpl_toolkits/mplot3d/axes3d.py | 40 +----------------------------- 1 file changed, 1 insertion(+), 39 deletions(-) diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index 0e511ebec365..38aab74c50a4 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -1164,7 +1164,7 @@ def _set_view_from_bbox(self, bbox, direction='in', Will center the view on the center of the bounding box, and zoom by the ratio of the size of the bounding box to the size of the Axes3D. """ - (start_x, start_y, stop_x, stop_y) = self._prepare_view_from_bbox(bbox) + (start_x, start_y, stop_x, stop_y) = bbox if mode == 'x': start_y = self.bbox.min[1] stop_y = self.bbox.max[1] @@ -1200,44 +1200,6 @@ def _set_view_from_bbox(self, bbox, direction='in', self._zoom_data_limits(scale_x, scale_y, scale_z) - 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 - - return bbox - def _zoom_data_limits(self, scale_x, scale_y, scale_z): """ Zoom in or out of a 3D plot. From 1eb74b6d53e07cd07f812d5f158d48d63f73f1f2 Mon Sep 17 00:00:00 2001 From: Scott Shambaugh Date: Thu, 21 Jul 2022 21:49:48 -0600 Subject: [PATCH 17/39] Comments and docstrings --- lib/mpl_toolkits/mplot3d/axes3d.py | 65 +++++++++++++++--------------- 1 file changed, 33 insertions(+), 32 deletions(-) diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index 38aab74c50a4..627f7e218534 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -872,9 +872,9 @@ def get_proj(self): # Calculate the viewing axes for the eye position u, v, n = self._calc_view_axes(eye) - self._view_u = u - self._view_v = v - self._view_n = n + self._view_u = u # _view_u is towards the right of the screen + self._view_v = v # _view_v is towards the top of the screen + self._view_n = n # _view_n is out of the screen # Generate the view and projection transformation matrices if self._focal_length == np.inf: @@ -1110,34 +1110,34 @@ def drag_pan(self, button, key, x, y): # 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 - dz = 0 + du, dv = xdata - xdata_start, ydata - ydata_start + dn = 0 if key == 'x': - dy = 0 + dv = 0 elif key == 'y': - dx = 0 - if dx == 0 and dy == 0: + du = 0 + if du == 0 and dv == 0: return # Transform the pan from the view axes to the data axees R = np.array([self._view_u, self._view_v, self._view_n]) R = -R / self._box_aspect * self._dist - dxyz_projected = R.T @ np.array([dx, dy, dz]) + duvn_projected = R.T @ np.array([du, dv, dn]) # Calculate pan distance minx, maxx, miny, maxy, minz, maxz = self.get_w_lims() - dxx = (maxx - minx) * dxyz_projected[0] - dyy = (maxy - miny) * dxyz_projected[1] - dzz = (maxz - minz) * dxyz_projected[2] + dx = (maxx - minx) * duvn_projected[0] + dy = (maxy - miny) * duvn_projected[1] + dz = (maxz - minz) * duvn_projected[2] # Set the new axis limits - self.set_xlim3d(minx + dxx, maxx + dxx) - self.set_ylim3d(miny + dyy, maxy + dyy) - self.set_zlim3d(minz + dzz, maxz + dzz) + self.set_xlim3d(minx + dx, maxx + dx) + self.set_ylim3d(miny + dy, maxy + dy) + self.set_zlim3d(minz + dz, maxz + dz) def _calc_view_axes(self, eye): """ - Get the unit viewing axes in data coordinates. + Get the unit vectors for the viewing axes in data coordinates. `u` is towards the right of the screen `v` is towards the top of the screen `n` is out of the screen @@ -1186,32 +1186,33 @@ def _set_view_from_bbox(self, bbox, direction='in', # Calculate zoom level dx = abs(start_x - stop_x) dy = abs(start_y - stop_y) - scale_x = dx / (self.bbox.max[0] - self.bbox.min[0]) - scale_y = dy / (self.bbox.max[1] - self.bbox.min[1]) - scale_z = 1 + scale_u = dx / (self.bbox.max[0] - self.bbox.min[0]) + scale_v = dy / (self.bbox.max[1] - self.bbox.min[1]) + scale_n = 1 # Limit box zoom to reasonable range, protect for divide by zero below - scale_x = np.clip(scale_x, 1e-2, 1e2) - scale_y = np.clip(scale_y, 1e-2, 1e2) + scale_u = np.clip(scale_u, 1e-2, 1e2) + scale_v = np.clip(scale_v, 1e-2, 1e2) if direction == 'out': - scale_x = 1 / scale_x - scale_y = 1 / scale_y + scale_u = 1 / scale_u + scale_v = 1 / scale_v - self._zoom_data_limits(scale_x, scale_y, scale_z) + self._zoom_data_limits(scale_u, scale_v, scale_n) - def _zoom_data_limits(self, scale_x, scale_y, scale_z): + def _zoom_data_limits(self, scale_u, scale_v, scale_n): """ Zoom in or out of a 3D plot. - Will scale the data limits by the scale factors, where scale_x, - scale_y, and scale_z refer to the scale factors for the viewing axes. - These will be transformed to the data axes based on the current view - angles. A scale factor > 1 zooms out and a scale factor < 1 zooms in. + Will scale the data limits by the scale factors, where scale_u, + scale_v, and scale_n refer to the scale factors for the viewing axes. + These will be transformed to the x, y, z data axes based on the current + view angles. A scale factor > 1 zooms out and a scale factor < 1 zooms + in. """ # Convert from the scale factors in the view frame to the data frame R = np.array([self._view_u, self._view_v, self._view_n]) - S = np.array([scale_x, scale_y, scale_z]) - scale = np.linalg.norm(R.T@(np.eye(3)*S), axis=1) + S = np.array([scale_u, scale_v, scale_n]) * np.eye(3) + scale = np.linalg.norm(R.T @ S, axis=1) # Scale the data range minx, maxx, miny, maxy, minz, maxz = self.get_w_lims() @@ -1376,7 +1377,7 @@ def plot(self, xs, ys, *args, zdir='z', **kwargs): z coordinates of vertices; either one for all points or one for each point. zdir : {'x', 'y', 'z'}, default: 'z' - When plotting 3D data, the direction to use as z ('x', 'y' or 'z'). + When plotting 2D data, the direction to use as z ('x', 'y' or 'z'). **kwargs Other arguments are forwarded to `matplotlib.axes.Axes.plot`. """ From 4a6020b2b4cbb8fc3681d10d1f118d8474c32973 Mon Sep 17 00:00:00 2001 From: Scott Shambaugh Date: Fri, 22 Jul 2022 09:51:05 -0600 Subject: [PATCH 18/39] Switch from uvn to uvw --- lib/mpl_toolkits/mplot3d/axes3d.py | 38 +++++++++++++------------- lib/mpl_toolkits/mplot3d/proj3d.py | 18 ++++++------ lib/mpl_toolkits/tests/test_mplot3d.py | 8 +++--- 3 files changed, 32 insertions(+), 32 deletions(-) diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index 627f7e218534..71bf6a34fc53 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -871,21 +871,21 @@ def get_proj(self): self._vvec = vvec / np.linalg.norm(vvec) # Calculate the viewing axes for the eye position - u, v, n = self._calc_view_axes(eye) + u, v, w = self._calc_view_axes(eye) self._view_u = u # _view_u is towards the right of the screen self._view_v = v # _view_v is towards the top of the screen - self._view_n = n # _view_n is out of the screen + self._view_w = w # _view_w is out of the screen # Generate the view and projection transformation matrices if self._focal_length == np.inf: # Orthographic projection - viewM = proj3d.view_transformation(u, v, n, eye) + viewM = proj3d.view_transformation(u, v, w, eye) projM = proj3d.ortho_transformation(-self._dist, self._dist) else: # Perspective projection # Scale the eye dist to compensate for the focal length zoom effect eye_focal = R + self._dist * ps * self._focal_length - viewM = proj3d.view_transformation(u, v, n, eye_focal) + viewM = proj3d.view_transformation(u, v, w, eye_focal) projM = proj3d.persp_transformation(-self._dist, self._dist, self._focal_length) @@ -1111,7 +1111,7 @@ def drag_pan(self, button, key, x, y): # move location for the next event self.start_pan(x, y, button) du, dv = xdata - xdata_start, ydata - ydata_start - dn = 0 + dw = 0 if key == 'x': dv = 0 elif key == 'y': @@ -1120,15 +1120,15 @@ def drag_pan(self, button, key, x, y): return # Transform the pan from the view axes to the data axees - R = np.array([self._view_u, self._view_v, self._view_n]) + R = np.array([self._view_u, self._view_v, self._view_w]) R = -R / self._box_aspect * self._dist - duvn_projected = R.T @ np.array([du, dv, dn]) + duvw_projected = R.T @ np.array([du, dv, dw]) # Calculate pan distance minx, maxx, miny, maxy, minz, maxz = self.get_w_lims() - dx = (maxx - minx) * duvn_projected[0] - dy = (maxy - miny) * duvn_projected[1] - dz = (maxz - minz) * duvn_projected[2] + dx = (maxx - minx) * duvw_projected[0] + dy = (maxy - miny) * duvw_projected[1] + dz = (maxz - minz) * duvw_projected[2] # Set the new axis limits self.set_xlim3d(minx + dx, maxx + dx) @@ -1140,7 +1140,7 @@ def _calc_view_axes(self, eye): Get the unit vectors for the viewing axes in data coordinates. `u` is towards the right of the screen `v` is towards the top of the screen - `n` is out of the screen + `w` is out of the screen """ elev_rad = np.deg2rad(art3d._norm_angle(self.elev)) roll_rad = np.deg2rad(art3d._norm_angle(self.roll)) @@ -1154,8 +1154,8 @@ def _calc_view_axes(self, eye): V = np.zeros(3) V[self._vertical_axis] = -1 if abs(elev_rad) > np.pi/2 else 1 - u, v, n = proj3d.view_axes(eye, R, V, roll_rad) - return u, v, n + u, v, w = proj3d.view_axes(eye, R, V, roll_rad) + return u, v, w def _set_view_from_bbox(self, bbox, direction='in', mode=None, twinx=False, twiny=False): @@ -1188,7 +1188,7 @@ def _set_view_from_bbox(self, bbox, direction='in', dy = abs(start_y - stop_y) scale_u = dx / (self.bbox.max[0] - self.bbox.min[0]) scale_v = dy / (self.bbox.max[1] - self.bbox.min[1]) - scale_n = 1 + scale_w = 1 # Limit box zoom to reasonable range, protect for divide by zero below scale_u = np.clip(scale_u, 1e-2, 1e2) @@ -1198,20 +1198,20 @@ def _set_view_from_bbox(self, bbox, direction='in', scale_u = 1 / scale_u scale_v = 1 / scale_v - self._zoom_data_limits(scale_u, scale_v, scale_n) + self._zoom_data_limits(scale_u, scale_v, scale_w) - def _zoom_data_limits(self, scale_u, scale_v, scale_n): + def _zoom_data_limits(self, scale_u, scale_v, scale_w): """ Zoom in or out of a 3D plot. Will scale the data limits by the scale factors, where scale_u, - scale_v, and scale_n refer to the scale factors for the viewing axes. + scale_v, and scale_w refer to the scale factors for the viewing axes. These will be transformed to the x, y, z data axes based on the current view angles. A scale factor > 1 zooms out and a scale factor < 1 zooms in. """ # Convert from the scale factors in the view frame to the data frame - R = np.array([self._view_u, self._view_v, self._view_n]) - S = np.array([scale_u, scale_v, scale_n]) * np.eye(3) + R = np.array([self._view_u, self._view_v, self._view_w]) + S = np.array([scale_u, scale_v, scale_w]) * np.eye(3) scale = np.linalg.norm(R.T @ S, axis=1) # Scale the data range diff --git a/lib/mpl_toolkits/mplot3d/proj3d.py b/lib/mpl_toolkits/mplot3d/proj3d.py index c4a54bea876e..39c50cb18640 100644 --- a/lib/mpl_toolkits/mplot3d/proj3d.py +++ b/lib/mpl_toolkits/mplot3d/proj3d.py @@ -77,27 +77,27 @@ def view_axes(E, R, V, roll): Get the unit viewing axes in data coordinates. `u` is towards the right of the screen `v` is towards the top of the screen - `n` is out of the screen + `w` is out of the screen """ - n = (E - R) - n = n/np.linalg.norm(n) - u = np.cross(V, n) + w = (E - R) + w = w/np.linalg.norm(w) + u = np.cross(V, w) u = u/np.linalg.norm(u) - v = np.cross(n, u) # Will be a unit vector + v = np.cross(w, u) # Will be a unit vector # Save some computation for the default roll=0 if roll != 0: # A positive rotation of the camera is a negative rotation of the world - Rroll = rotation_about_vector(n, -roll) + Rroll = rotation_about_vector(w, -roll) u = np.dot(Rroll, u) v = np.dot(Rroll, v) - return u, v, n + return u, v, w -def view_transformation(u, v, n, E): +def view_transformation(u, v, w, E): Mr = np.eye(4) Mt = np.eye(4) - Mr[:3, :3] = [u, v, n] + Mr[:3, :3] = [u, v, w] Mt[:3, -1] = -E return np.dot(Mr, Mt) diff --git a/lib/mpl_toolkits/tests/test_mplot3d.py b/lib/mpl_toolkits/tests/test_mplot3d.py index 100368868288..87c7c5dadc76 100644 --- a/lib/mpl_toolkits/tests/test_mplot3d.py +++ b/lib/mpl_toolkits/tests/test_mplot3d.py @@ -876,8 +876,8 @@ def _test_proj_make_M(): R = np.array([100, 100, 100]) V = np.array([0, 0, 1]) roll = 0 - u, v, n = proj3d.view_axes(E, R, V, roll) - viewM = proj3d.view_transformation(u, v, n, E) + u, v, w = proj3d.view_axes(E, R, V, roll) + viewM = proj3d.view_transformation(u, v, w, E) perspM = proj3d.persp_transformation(100, -100, 1) M = np.dot(perspM, viewM) return M @@ -943,8 +943,8 @@ def test_proj_axes_cube_ortho(): R = np.array([0, 0, 0]) V = np.array([0, 0, 1]) roll = 0 - u, v, n = proj3d.view_axes(E, R, V, roll) - viewM = proj3d.view_transformation(u, v, n, E) + u, v, w = proj3d.view_axes(E, R, V, roll) + viewM = proj3d.view_transformation(u, v, w, E) orthoM = proj3d.ortho_transformation(-1, 1) M = np.dot(orthoM, viewM) From 11f6625d64ee5baf7127b2911c1f4c4c34ba0156 Mon Sep 17 00:00:00 2001 From: Scott Shambaugh Date: Tue, 2 Aug 2022 21:17:35 -0600 Subject: [PATCH 19/39] Save aspect to axes --- lib/mpl_toolkits/mplot3d/axes3d.py | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index a0ebecd332ab..5319e38ff011 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -322,6 +322,7 @@ def set_aspect(self, aspect, adjustable=None, anchor=None, share=False): aspect=aspect) super().set_aspect( aspect='auto', adjustable=adjustable, anchor=anchor, share=share) + self._aspect = aspect if aspect in ('equal', 'equalxy', 'equalxz', 'equalyz'): if aspect == 'equal': From 4a54404ab350983fc1c25a7969ed69d9d72d85fc Mon Sep 17 00:00:00 2001 From: Scott Shambaugh Date: Tue, 2 Aug 2022 22:09:28 -0600 Subject: [PATCH 20/39] Constrain zooming with mouse when one of the equal aspect ratios is set --- lib/mpl_toolkits/mplot3d/axes3d.py | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index 0a94ab85b30e..ca5fc61d3676 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -1112,7 +1112,7 @@ def _on_move(self, event): elif self.button_pressed in self._zoom_btn: # zoom view (dragging down zooms in) scale = h/(h - dy) - self._zoom_data_limits(scale, scale, scale) + self._scale_axis_limits(scale, scale, scale) # Store the event coordinates for the next time through. self.sx, self.sy = x, y @@ -1228,16 +1228,37 @@ def _zoom_data_limits(self, scale_u, scale_v, scale_w): These will be transformed to the x, y, z data axes based on the current view angles. A scale factor > 1 zooms out and a scale factor < 1 zooms in. + For an axes that has had its aspect ratio set to 'equal', 'equalxy', + 'equalyz', or 'equalxz', the relevant axes are constrained to zoom + equally. """ # Convert from the scale factors in the view frame to the data frame R = np.array([self._view_u, self._view_v, self._view_w]) S = np.array([scale_u, scale_v, scale_w]) * np.eye(3) scale = np.linalg.norm(R.T @ S, axis=1) + # Set the constrained scale factors to the factor closest to 1 + if self._aspect == 'equal': + scale = np.ones(3) * scale[np.argmin(np.abs(scale - 1))] + elif self._aspect == 'equalxy': + scale[[0, 1]] = scale[[0, 1]][np.argmin(np.abs(scale[[0, 1]] - 1))] + elif self._aspect == 'equalyz': + scale[[1, 2]] = scale[[1, 2]][np.argmin(np.abs(scale[[1, 2]] - 1))] + elif self._aspect == 'equalxz': + scale[[0, 2]] = scale[[0, 2]][np.argmin(np.abs(scale[[0, 2]] - 1))] + + self._scale_axis_limits(scale[0], scale[1], scale[2]) + + def _scale_axis_limits(self, scale_x, scale_y, scale_z): + """ + Keeping the center of the x, y, and z data axes fixed, scale their + limits by scale factors. A scale factor > 1 zooms out and a scale + factor < 1 zooms in. + """ # Scale the data range minx, maxx, miny, maxy, minz, maxz = self.get_w_lims() dxyz = np.array([maxx - minx, maxy - miny, maxz - minz]) - dxyz_scaled = dxyz*scale + dxyz_scaled = dxyz*np.array([scale_x, scale_y, scale_z]) # Set the axis limits cx = (maxx + minx)/2 From e1f43f37cd0c36571759e1f3203309443d398a0e Mon Sep 17 00:00:00 2001 From: Scott Shambaugh Date: Tue, 2 Aug 2022 22:27:57 -0600 Subject: [PATCH 21/39] Cleanup --- lib/mpl_toolkits/mplot3d/axes3d.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index ca5fc61d3676..3070cae75b86 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -1255,18 +1255,21 @@ def _scale_axis_limits(self, scale_x, scale_y, scale_z): limits by scale factors. A scale factor > 1 zooms out and a scale factor < 1 zooms in. """ - # Scale the data range + # Get the axis limits and centers minx, maxx, miny, maxy, minz, maxz = self.get_w_lims() - dxyz = np.array([maxx - minx, maxy - miny, maxz - minz]) - dxyz_scaled = dxyz*np.array([scale_x, scale_y, scale_z]) - - # Set the axis limits cx = (maxx + minx)/2 cy = (maxy + miny)/2 cz = (maxz + minz)/2 - self.set_xlim3d(cx - dxyz_scaled[0]/2, cx + dxyz_scaled[0]/2) - self.set_ylim3d(cy - dxyz_scaled[1]/2, cy + dxyz_scaled[1]/2) - self.set_zlim3d(cz - dxyz_scaled[2]/2, cz + dxyz_scaled[2]/2) + + # Scale the data range + dx = (maxx - minx)*scale_x + dy = (maxy - miny)*scale_y + dz = (maxz - minz)*scale_z + + # Set the scaled axis limits + self.set_xlim3d(cx - dx/2, cx + dx/2) + self.set_ylim3d(cy - dy/2, cy + dy/2) + self.set_zlim3d(cz - dz/2, cz + dz/2) def set_zlabel(self, zlabel, fontdict=None, labelpad=None, **kwargs): """ From 423cec8ed75a356f962dc9000fed873f4476e2b5 Mon Sep 17 00:00:00 2001 From: Scott Shambaugh Date: Wed, 3 Aug 2022 08:35:20 -0600 Subject: [PATCH 22/39] Cleanup --- lib/mpl_toolkits/mplot3d/axes3d.py | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index aff75f21f2ab..fadc72597535 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -328,27 +328,27 @@ def set_aspect(self, aspect, adjustable=None, anchor=None, share=False): if aspect in ('equal', 'equalxy', 'equalxz', 'equalyz'): if aspect == 'equal': - ax_indices = [0, 1, 2] + ax_idx = [0, 1, 2] elif aspect == 'equalxy': - ax_indices = [0, 1] + ax_idx = [0, 1] elif aspect == 'equalxz': - ax_indices = [0, 2] + ax_idx = [0, 2] elif aspect == 'equalyz': - ax_indices = [1, 2] + ax_idx = [1, 2] view_intervals = np.array([self.xaxis.get_view_interval(), self.yaxis.get_view_interval(), self.zaxis.get_view_interval()]) mean = np.mean(view_intervals, axis=1) ptp = np.ptp(view_intervals, axis=1) - delta = max(ptp[ax_indices]) + delta = max(ptp[ax_idx]) scale = self._box_aspect[ptp == delta][0] deltas = delta * self._box_aspect / scale for i, set_lim in enumerate((self.set_xlim3d, self.set_ylim3d, self.set_zlim3d)): - if i in ax_indices: + if i in ax_idx: set_lim(mean[i] - deltas[i]/2., mean[i] + deltas[i]/2.) def set_box_aspect(self, aspect, *, zoom=1): @@ -1240,14 +1240,16 @@ def _zoom_data_limits(self, scale_u, scale_v, scale_w): scale = np.linalg.norm(R.T @ S, axis=1) # Set the constrained scale factors to the factor closest to 1 - if self._aspect == 'equal': - scale = np.ones(3) * scale[np.argmin(np.abs(scale - 1))] - elif self._aspect == 'equalxy': - scale[[0, 1]] = scale[[0, 1]][np.argmin(np.abs(scale[[0, 1]] - 1))] - elif self._aspect == 'equalyz': - scale[[1, 2]] = scale[[1, 2]][np.argmin(np.abs(scale[[1, 2]] - 1))] - elif self._aspect == 'equalxz': - scale[[0, 2]] = scale[[0, 2]][np.argmin(np.abs(scale[[0, 2]] - 1))] + if self._aspect in ('equal', 'equalxy', 'equalxz', 'equalyz'): + if self._aspect == 'equal': + ax_idx = [0, 1, 2] + elif self._aspect == 'equalxy': + ax_idx = [0, 1] + elif self._aspect == 'equalxz': + ax_idx = [0, 2] + elif self._aspect == 'equalyz': + ax_idx = [1, 2] + scale[ax_idx] = scale[ax_idx][np.argmin(np.abs(scale[ax_idx] - 1))] self._scale_axis_limits(scale[0], scale[1], scale[2]) From aa04186f4f33e5bcb3bbd29010863d3982b396a6 Mon Sep 17 00:00:00 2001 From: Scott Shambaugh <14363975+scottshambaugh@users.noreply.github.com> Date: Wed, 3 Aug 2022 16:01:28 -0600 Subject: [PATCH 23/39] Consolidate finding equal aspect axis indices --- lib/mpl_toolkits/mplot3d/axes3d.py | 39 ++++++++++++++++++------------ 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index fadc72597535..1e849875ec37 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -327,14 +327,7 @@ def set_aspect(self, aspect, adjustable=None, anchor=None, share=False): self._aspect = aspect if aspect in ('equal', 'equalxy', 'equalxz', 'equalyz'): - if aspect == 'equal': - ax_idx = [0, 1, 2] - elif aspect == 'equalxy': - ax_idx = [0, 1] - elif aspect == 'equalxz': - ax_idx = [0, 2] - elif aspect == 'equalyz': - ax_idx = [1, 2] + ax_idx = self._equal_aspect_axis_indices(aspect) view_intervals = np.array([self.xaxis.get_view_interval(), self.yaxis.get_view_interval(), @@ -351,6 +344,27 @@ def set_aspect(self, aspect, adjustable=None, anchor=None, share=False): if i in ax_idx: set_lim(mean[i] - deltas[i]/2., mean[i] + deltas[i]/2.) + def _equal_aspect_axis_indices(self, aspect): + """ + Get the indices for which of the x, y, z axes are constrained to have + equal aspect ratios. + + Parameters + ---------- + aspect : {'auto', 'equal', 'equalxy', 'equalxz', 'equalyz'} + See descriptions in docstring for `.set_aspect()`. + """ + ax_indices = [] # aspect == 'auto' + if aspect == 'equal': + ax_indices = [0, 1, 2] + elif aspect == 'equalxy': + ax_indices = [0, 1] + elif aspect == 'equalxz': + ax_indices = [0, 2] + elif aspect == 'equalyz': + ax_indices = [1, 2] + return ax_indices + def set_box_aspect(self, aspect, *, zoom=1): """ Set the Axes box aspect. @@ -1241,14 +1255,7 @@ def _zoom_data_limits(self, scale_u, scale_v, scale_w): # Set the constrained scale factors to the factor closest to 1 if self._aspect in ('equal', 'equalxy', 'equalxz', 'equalyz'): - if self._aspect == 'equal': - ax_idx = [0, 1, 2] - elif self._aspect == 'equalxy': - ax_idx = [0, 1] - elif self._aspect == 'equalxz': - ax_idx = [0, 2] - elif self._aspect == 'equalyz': - ax_idx = [1, 2] + ax_idx = self._equal_aspect_axis_indices(self._aspect) scale[ax_idx] = scale[ax_idx][np.argmin(np.abs(scale[ax_idx] - 1))] self._scale_axis_limits(scale[0], scale[1], scale[2]) From 9e50954c617463aab2ef683dfd0db019139ee885 Mon Sep 17 00:00:00 2001 From: Scott Shambaugh <14363975+scottshambaugh@users.noreply.github.com> Date: Wed, 3 Aug 2022 16:04:21 -0600 Subject: [PATCH 24/39] linting --- lib/mpl_toolkits/mplot3d/axes3d.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index 9b2cecd39bd3..fb9124189c43 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -353,7 +353,7 @@ def _equal_aspect_axis_indices(self, aspect): aspect : {'auto', 'equal', 'equalxy', 'equalxz', 'equalyz'} See descriptions in docstring for `.set_aspect()`. """ - ax_indices = [] # aspect == 'auto' + ax_indices = [] # aspect == 'auto' if aspect == 'equal': ax_indices = [0, 1, 2] elif aspect == 'equalxy': From d55706c1456111aff0a2a4b65a6e8f6b38cd2a8b Mon Sep 17 00:00:00 2001 From: Scott Shambaugh Date: Sat, 27 Aug 2022 13:16:48 -0600 Subject: [PATCH 25/39] More intuitive scaling --- lib/mpl_toolkits/mplot3d/axes3d.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index b8936bdcf9db..a5e5fc550fa8 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -1213,7 +1213,7 @@ def _set_view_from_bbox(self, bbox, direction='in', dy = abs(start_y - stop_y) scale_u = dx / (self.bbox.max[0] - self.bbox.min[0]) scale_v = dy / (self.bbox.max[1] - self.bbox.min[1]) - scale_w = 1 + scale_w = max(scale_u, scale_v) # Limit box zoom to reasonable range, protect for divide by zero below scale_u = np.clip(scale_u, 1e-2, 1e2) From 0008c02b9264a8539233b7b83f97cfa12a8e5bd9 Mon Sep 17 00:00:00 2001 From: Scott Shambaugh Date: Sat, 27 Aug 2022 13:30:29 -0600 Subject: [PATCH 26/39] Box zoom keeps existing aspect ratios --- doc/users/next_whats_new/3d_plot_pan_zoom.rst | 3 +- lib/mpl_toolkits/mplot3d/axes3d.py | 40 +++++++++++-------- lib/mpl_toolkits/tests/test_mplot3d.py | 8 ++-- 3 files changed, 29 insertions(+), 22 deletions(-) diff --git a/doc/users/next_whats_new/3d_plot_pan_zoom.rst b/doc/users/next_whats_new/3d_plot_pan_zoom.rst index 89b0a019bf59..98df0b11de5b 100644 --- a/doc/users/next_whats_new/3d_plot_pan_zoom.rst +++ b/doc/users/next_whats_new/3d_plot_pan_zoom.rst @@ -4,4 +4,5 @@ The pan and zoom buttons in the toolbar of 3D plots are now enabled. Deselect both to rotate the plot. When the zoom button is pressed, zoom in by using the left mouse button to draw a bounding box, and -out by using the right mouse button to draw the box. +out by using the right mouse button to draw the box. When zooming a +3D plot, the current view aspect ratios are kept fixed. diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index a5e5fc550fa8..f7b8f0c12286 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -1213,19 +1213,21 @@ def _set_view_from_bbox(self, bbox, direction='in', dy = abs(start_y - stop_y) scale_u = dx / (self.bbox.max[0] - self.bbox.min[0]) scale_v = dy / (self.bbox.max[1] - self.bbox.min[1]) - scale_w = max(scale_u, scale_v) # Limit box zoom to reasonable range, protect for divide by zero below - scale_u = np.clip(scale_u, 1e-2, 1e2) - scale_v = np.clip(scale_v, 1e-2, 1e2) + scale_u = np.clip(scale_u, 1e-2, 1) + scale_v = np.clip(scale_v, 1e-2, 1) + # Keep aspect ratios equal + scale = max(scale_u, scale_v) + + # Zoom out if direction == 'out': - scale_u = 1 / scale_u - scale_v = 1 / scale_v + scale = 1 / scale - self._zoom_data_limits(scale_u, scale_v, scale_w) + self.zoom_data_limits(scale, scale, scale) - def _zoom_data_limits(self, scale_u, scale_v, scale_w): + def zoom_data_limits(self, scale_u, scale_v, scale_w): """ Zoom in or out of a 3D plot. Will scale the data limits by the scale factors, where scale_u, @@ -1237,19 +1239,23 @@ def _zoom_data_limits(self, scale_u, scale_v, scale_w): 'equalyz', or 'equalxz', the relevant axes are constrained to zoom equally. """ - # Convert from the scale factors in the view frame to the data frame - R = np.array([self._view_u, self._view_v, self._view_w]) - S = np.array([scale_u, scale_v, scale_w]) * np.eye(3) - scale = np.linalg.norm(R.T @ S, axis=1) + scale = np.array([scale_u, scale_v, scale_w]) + + # Only perform frame conversion if unequal scale factors + if not np.allclose(scale, scale_u): + # Convert from the scale factors in the view frame to the data frame + R = np.array([self._view_u, self._view_v, self._view_w]) + S = scale * np.eye(3) + scale = np.linalg.norm(R.T @ S, axis=1) - # Set the constrained scale factors to the factor closest to 1 - if self._aspect in ('equal', 'equalxy', 'equalxz', 'equalyz'): - ax_idx = self._equal_aspect_axis_indices(self._aspect) - scale[ax_idx] = scale[ax_idx][np.argmin(np.abs(scale[ax_idx] - 1))] + # Set the constrained scale factors to the factor closest to 1 + if self._aspect in ('equal', 'equalxy', 'equalxz', 'equalyz'): + ax_idx = self._equal_aspect_axis_indices(self._aspect) + scale[ax_idx] = scale[ax_idx][np.argmin(np.abs(scale[ax_idx] - 1))] - self._scale_axis_limits(scale[0], scale[1], scale[2]) + self.scale_axis_limits(scale[0], scale[1], scale[2]) - def _scale_axis_limits(self, scale_x, scale_y, scale_z): + def scale_axis_limits(self, scale_x, scale_y, scale_z): """ Keeping the center of the x, y, and z data axes fixed, scale their limits by scale factors. A scale factor > 1 zooms out and a scale diff --git a/lib/mpl_toolkits/tests/test_mplot3d.py b/lib/mpl_toolkits/tests/test_mplot3d.py index 1ed40734563f..9b58f312deb8 100644 --- a/lib/mpl_toolkits/tests/test_mplot3d.py +++ b/lib/mpl_toolkits/tests/test_mplot3d.py @@ -1622,13 +1622,13 @@ def convert_lim(dmin, dmax): @pytest.mark.parametrize("tool,button,key,expected", [("zoom", MouseButton.LEFT, None, # zoom in - ((0.03, 0.61), (0.27, 0.71), (0.32, 0.89))), + ((0.26, 0.38), (0.43, 0.55), (0.54, 0.66))), ("zoom", MouseButton.LEFT, 'x', # zoom in - ((0.17, 0.73), (0.09, 0.43), (-0.06, 0.06))), + ((0.39, 0.51), (0.20, 0.32), (-0.06, 0.06))), ("zoom", MouseButton.LEFT, 'y', # zoom in - ((-0.23, -0.03), (0.07, 0.37), (0.32, 0.89))), + ((-0.19, -0.07), (0.16, 0.28), (0.54, 0.66))), ("zoom", MouseButton.RIGHT, None, # zoom out - ((0.29, 0.35), (0.44, 0.53), (0.57, 0.64))), + ((0.26, 0.38), (0.43, 0.55), (0.54, 0.66))), ("pan", MouseButton.LEFT, None, ((-0.70, -0.58), (-1.03, -0.91), (-1.27, -1.15))), ("pan", MouseButton.LEFT, 'x', From caeff1ba3d28fb67a635fd9bd543a257f99a520a Mon Sep 17 00:00:00 2001 From: Scott Shambaugh Date: Sat, 27 Aug 2022 13:47:57 -0600 Subject: [PATCH 27/39] Linting --- lib/mpl_toolkits/mplot3d/axes3d.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index f7b8f0c12286..384a912b8bae 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -1243,15 +1243,16 @@ def zoom_data_limits(self, scale_u, scale_v, scale_w): # Only perform frame conversion if unequal scale factors if not np.allclose(scale, scale_u): - # Convert from the scale factors in the view frame to the data frame + # Convert the scale factors from the view frame to the data frame R = np.array([self._view_u, self._view_v, self._view_w]) S = scale * np.eye(3) scale = np.linalg.norm(R.T @ S, axis=1) # Set the constrained scale factors to the factor closest to 1 if self._aspect in ('equal', 'equalxy', 'equalxz', 'equalyz'): - ax_idx = self._equal_aspect_axis_indices(self._aspect) - scale[ax_idx] = scale[ax_idx][np.argmin(np.abs(scale[ax_idx] - 1))] + ax_idxs = self._equal_aspect_axis_indices(self._aspect) + min_ax_idxs = np.argmin(np.abs(scale[ax_idxs] - 1)) + scale[ax_idxs] = scale[ax_idxs][min_ax_idxs] self.scale_axis_limits(scale[0], scale[1], scale[2]) From 772b5119947f134fea1b1d39a7c6ff14322d0e31 Mon Sep 17 00:00:00 2001 From: Scott Shambaugh Date: Mon, 29 Aug 2022 22:51:04 -0600 Subject: [PATCH 28/39] Code review comments --- lib/mpl_toolkits/mplot3d/axes3d.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index 384a912b8bae..ae9f6ee1e291 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -51,9 +51,11 @@ class Axes3D(Axes): _axis_names = ("x", "y", "z") Axes._shared_axes["z"] = cbook.Grouper() - dist = _api.deprecate_privatize_attribute("3.6") - vvec = _api.deprecate_privatize_attribute("3.6") - eye = _api.deprecate_privatize_attribute("3.6") + dist = _api.deprecate_privatize_attribute("3.7") + vvec = _api.deprecate_privatize_attribute("3.7") + eye = _api.deprecate_privatize_attribute("3.7") + sx = _api.deprecate_privatize_attribute("3.7") + sy = _api.deprecate_privatize_attribute("3.7") def __init__( self, fig, rect=None, *args, @@ -158,8 +160,6 @@ def __init__( self.fmt_zdata = None self.mouse_init() - 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) self.figure.canvas.callbacks._connect_picklable( @@ -327,21 +327,21 @@ def set_aspect(self, aspect, adjustable=None, anchor=None, share=False): self._aspect = aspect if aspect in ('equal', 'equalxy', 'equalxz', 'equalyz'): - ax_idx = self._equal_aspect_axis_indices(aspect) + ax_indices = self._equal_aspect_axis_indices(aspect) view_intervals = np.array([self.xaxis.get_view_interval(), self.yaxis.get_view_interval(), self.zaxis.get_view_interval()]) mean = np.mean(view_intervals, axis=1) ptp = np.ptp(view_intervals, axis=1) - delta = max(ptp[ax_idx]) + delta = max(ptp[ax_indices]) scale = self._box_aspect[ptp == delta][0] deltas = delta * self._box_aspect / scale for i, set_lim in enumerate((self.set_xlim3d, self.set_ylim3d, self.set_zlim3d)): - if i in ax_idx: + if i in ax_indices: set_lim(mean[i] - deltas[i]/2., mean[i] + deltas[i]/2.) def _equal_aspect_axis_indices(self, aspect): @@ -986,7 +986,7 @@ def clear(self): def _button_press(self, event): if event.inaxes == self: self.button_pressed = event.button - self.sx, self.sy = event.xdata, event.ydata + self._sx, self._sy = event.xdata, event.ydata toolbar = getattr(self.figure.canvas, "toolbar") if toolbar and toolbar._nav_stack() is None: self.figure.canvas.toolbar.push_current() @@ -1087,7 +1087,7 @@ def _on_move(self, event): if x is None or event.inaxes != self: return - dx, dy = x - self.sx, y - self.sy + dx, dy = x - self._sx, y - self._sy w = self._pseudo_w h = self._pseudo_h @@ -1107,7 +1107,7 @@ def _on_move(self, event): elif self.button_pressed in self._pan_btn: # Start the pan event with pixel coordinates - px, py = self.transData.transform([self.sx, self.sy]) + 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) @@ -1120,7 +1120,7 @@ def _on_move(self, event): self._scale_axis_limits(scale, scale, scale) # Store the event coordinates for the next time through. - self.sx, self.sy = x, y + self._sx, self._sy = x, y # Always request a draw update at the end of interaction self.figure.canvas.draw_idle() @@ -1131,7 +1131,7 @@ def drag_pan(self, button, key, x, y): 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 + 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) From 039b97579c7241345f8f537ff7349fd534e60f88 Mon Sep 17 00:00:00 2001 From: Scott Shambaugh Date: Mon, 5 Sep 2022 21:25:04 -0600 Subject: [PATCH 29/39] Revert parameters for view_transformation --- lib/mpl_toolkits/mplot3d/axes3d.py | 35 +++++++++++---- lib/mpl_toolkits/mplot3d/proj3d.py | 62 +++++++++++++++++++++++--- lib/mpl_toolkits/tests/test_mplot3d.py | 6 +-- 3 files changed, 86 insertions(+), 17 deletions(-) diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index 3af14474fd0d..33e8793338d0 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -51,7 +51,7 @@ class Axes3D(Axes): _axis_names = ("x", "y", "z") Axes._shared_axes["z"] = cbook.Grouper() - dist = _api.deprecate_privatize_attribute("3.7") + dist = _api.deprecate_privatize_attribute("3.6") vvec = _api.deprecate_privatize_attribute("3.7") eye = _api.deprecate_privatize_attribute("3.7") sx = _api.deprecate_privatize_attribute("3.7") @@ -160,6 +160,8 @@ def __init__( self.fmt_zdata = None self.mouse_init() + self.figure.canvas.callbacks._connect_picklable( + 'motion_notify_event', self._on_move) self.figure.canvas.callbacks._connect_picklable( 'button_press_event', self._button_press) self.figure.canvas.callbacks._connect_picklable( @@ -917,13 +919,13 @@ def get_proj(self): # Generate the view and projection transformation matrices if self._focal_length == np.inf: # Orthographic projection - viewM = proj3d.view_transformation(u, v, w, eye) + viewM = proj3d.view_transformation_uvw(u, v, w, eye) projM = proj3d.ortho_transformation(-self._dist, self._dist) else: # Perspective projection # Scale the eye dist to compensate for the focal length zoom effect eye_focal = R + self._dist * ps * self._focal_length - viewM = proj3d.view_transformation(u, v, w, eye_focal) + viewM = proj3d.view_transformation_uvw(u, v, w, eye_focal) projM = proj3d.persp_transformation(-self._dist, self._dist, self._focal_length) @@ -1245,14 +1247,22 @@ def _set_view_from_bbox(self, bbox, direction='in', def zoom_data_limits(self, scale_u, scale_v, scale_w): """ Zoom in or out of a 3D plot. - Will scale the data limits by the scale factors, where scale_u, - scale_v, and scale_w refer to the scale factors for the viewing axes. - These will be transformed to the x, y, z data axes based on the current - view angles. A scale factor > 1 zooms out and a scale factor < 1 zooms - in. + Will scale the data limits by the scale factors. These will be + transformed to the x, y, z data axes based on the current view angles. + A scale factor > 1 zooms out and a scale factor < 1 zooms in. + For an axes that has had its aspect ratio set to 'equal', 'equalxy', 'equalyz', or 'equalxz', the relevant axes are constrained to zoom equally. + + Parameters + ---------- + scale_u : float + Scale factor for the u view axis (view screen horizontal). + scale_v : float + Scale factor for the v view axis (view screen vertical). + scale_w : float + Scale factor for the w view axis (view screen depth). """ scale = np.array([scale_u, scale_v, scale_w]) @@ -1276,6 +1286,15 @@ def scale_axis_limits(self, scale_x, scale_y, scale_z): Keeping the center of the x, y, and z data axes fixed, scale their limits by scale factors. A scale factor > 1 zooms out and a scale factor < 1 zooms in. + + Parameters + ---------- + scale_x : float + Scale factor for the x data axis. + scale_y : float + Scale factor for the y data axis. + scale_z : float + Scale factor for the z data axis. """ # Get the axis limits and centers minx, maxx, miny, maxy, minz, maxz = self.get_w_lims() diff --git a/lib/mpl_toolkits/mplot3d/proj3d.py b/lib/mpl_toolkits/mplot3d/proj3d.py index 39c50cb18640..67036e2e6eb3 100644 --- a/lib/mpl_toolkits/mplot3d/proj3d.py +++ b/lib/mpl_toolkits/mplot3d/proj3d.py @@ -75,9 +75,26 @@ def rotation_about_vector(v, angle): def view_axes(E, R, V, roll): """ Get the unit viewing axes in data coordinates. - `u` is towards the right of the screen - `v` is towards the top of the screen - `w` is out of the screen + + Parameters + ---------- + E : 3-element numpy array + The coordinates of the eye/camera. + R : 3-element numpy array + The coordinates of the center of the view box. + V : 3-element numpy array + Unit vector in the direction of the vertical axis. + roll : float + The roll angle in radians. + + Returns + ------- + u : 3-element numpy array + Unit vector pointing towards the right of the screen. + v : 3-element numpy array + Unit vector pointing towards the top of the screen. + w : 3-element numpy array + Unit vector pointing out of the screen. """ w = (E - R) w = w/np.linalg.norm(w) @@ -94,12 +111,47 @@ def view_axes(E, R, V, roll): return u, v, w -def view_transformation(u, v, w, E): +def view_transformation_uvw(u, v, w, E): + """ + Return the view transformation matrix. + + Parameters + ---------- + u : 3-element numpy array + Unit vector pointing towards the right of the screen. + v : 3-element numpy array + Unit vector pointing towards the top of the screen. + w : 3-element numpy array + Unit vector pointing out of the screen. + E : 3-element numpy array + The coordinates of the eye/camera. + """ Mr = np.eye(4) Mt = np.eye(4) Mr[:3, :3] = [u, v, w] Mt[:3, -1] = -E - return np.dot(Mr, Mt) + M = np.dot(Mr, Mt) + return M + + +def view_transformation(E, R, V, roll): + """ + Return the view transformation matrix. + + Parameters + ---------- + E : 3-element numpy array + The coordinates of the eye/camera. + R : 3-element numpy array + The coordinates of the center of the view box. + V : 3-element numpy array + Unit vector in the direction of the vertical axis. + roll : float + The roll angle in radians. + """ + u, v, w = view_axes(E, R, V, roll) + M = view_transformation_uvw(u, v, w, E) + return M def persp_transformation(zfront, zback, focal_length): diff --git a/lib/mpl_toolkits/tests/test_mplot3d.py b/lib/mpl_toolkits/tests/test_mplot3d.py index 321babffe098..313f1ea4ff93 100644 --- a/lib/mpl_toolkits/tests/test_mplot3d.py +++ b/lib/mpl_toolkits/tests/test_mplot3d.py @@ -976,8 +976,7 @@ def _test_proj_make_M(): R = np.array([100, 100, 100]) V = np.array([0, 0, 1]) roll = 0 - u, v, w = proj3d.view_axes(E, R, V, roll) - viewM = proj3d.view_transformation(u, v, w, E) + viewM = proj3d.view_transformation(E, R, V, roll) perspM = proj3d.persp_transformation(100, -100, 1) M = np.dot(perspM, viewM) return M @@ -1043,8 +1042,7 @@ def test_proj_axes_cube_ortho(): R = np.array([0, 0, 0]) V = np.array([0, 0, 1]) roll = 0 - u, v, w = proj3d.view_axes(E, R, V, roll) - viewM = proj3d.view_transformation(u, v, w, E) + viewM = proj3d.view_transformation(E, R, V, roll) orthoM = proj3d.ortho_transformation(-1, 1) M = np.dot(orthoM, viewM) From bb47e26d60bb5068b14096aec0da2d18ea04d933 Mon Sep 17 00:00:00 2001 From: Scott Shambaugh Date: Mon, 5 Sep 2022 22:07:50 -0600 Subject: [PATCH 30/39] Fix new 3d pan/zoom view going on view stack twice --- lib/mpl_toolkits/mplot3d/axes3d.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index 33e8793338d0..29bd5321e6b0 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -31,6 +31,7 @@ import matplotlib.transforms as mtransforms from matplotlib.axes import Axes from matplotlib.axes._base import _axis_method_wrapper, _process_plot_format +from matplotlib.backend_bases import _Mode from matplotlib.transforms import Bbox from matplotlib.tri.triangulation import Triangulation @@ -1011,7 +1012,7 @@ def _button_press(self, event): def _button_release(self, event): self.button_pressed = None toolbar = getattr(self.figure.canvas, "toolbar") - if toolbar: + if toolbar and toolbar.mode not in (_Mode.PAN, _Mode.ZOOM): self.figure.canvas.toolbar.push_current() def _get_view(self): @@ -1231,10 +1232,6 @@ def _set_view_from_bbox(self, bbox, direction='in', scale_u = dx / (self.bbox.max[0] - self.bbox.min[0]) scale_v = dy / (self.bbox.max[1] - self.bbox.min[1]) - # Limit box zoom to reasonable range, protect for divide by zero below - scale_u = np.clip(scale_u, 1e-2, 1) - scale_v = np.clip(scale_v, 1e-2, 1) - # Keep aspect ratios equal scale = max(scale_u, scale_v) From f6a3d2e5dced54ae953c6bc0127af3736cb18028 Mon Sep 17 00:00:00 2001 From: Scott Shambaugh Date: Mon, 5 Sep 2022 22:45:21 -0600 Subject: [PATCH 31/39] Better clipping --- lib/mpl_toolkits/mplot3d/axes3d.py | 6 ++++++ lib/mpl_toolkits/tests/test_mplot3d.py | 8 ++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index 29bd5321e6b0..2690ba5fee3c 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -1215,6 +1215,12 @@ def _set_view_from_bbox(self, bbox, direction='in', start_x = self.bbox.min[0] stop_x = self.bbox.max[0] + # Clip to bounding box limits + start_x, stop_x = np.clip(sorted([start_x, stop_x]), + self.bbox.min[0], self.bbox.max[0]) + start_y, stop_y = np.clip(sorted([start_y, stop_y]), + self.bbox.min[1], self.bbox.max[1]) + # Move the center of the view to the center of the bbox zoom_center_x = (start_x + stop_x)/2 zoom_center_y = (start_y + stop_y)/2 diff --git a/lib/mpl_toolkits/tests/test_mplot3d.py b/lib/mpl_toolkits/tests/test_mplot3d.py index 313f1ea4ff93..f4851f825ed4 100644 --- a/lib/mpl_toolkits/tests/test_mplot3d.py +++ b/lib/mpl_toolkits/tests/test_mplot3d.py @@ -1638,13 +1638,13 @@ def convert_lim(dmin, dmax): @pytest.mark.parametrize("tool,button,key,expected", [("zoom", MouseButton.LEFT, None, # zoom in - ((0.26, 0.38), (0.43, 0.55), (0.54, 0.66))), + ((0.00, 0.06), (0.01, 0.07), (0.02, 0.08))), ("zoom", MouseButton.LEFT, 'x', # zoom in - ((0.39, 0.51), (0.20, 0.32), (-0.06, 0.06))), + ((-0.01, 0.10), (-0.03, 0.08), (-0.06, 0.06))), ("zoom", MouseButton.LEFT, 'y', # zoom in - ((-0.19, -0.07), (0.16, 0.28), (0.54, 0.66))), + ((-0.07, 0.04), (-0.03, 0.08), (0.00, 0.11))), ("zoom", MouseButton.RIGHT, None, # zoom out - ((0.26, 0.38), (0.43, 0.55), (0.54, 0.66))), + ((-0.09, 0.15), (-0.07, 0.17), (-0.06, 0.18))), ("pan", MouseButton.LEFT, None, ((-0.70, -0.58), (-1.03, -0.91), (-1.27, -1.15))), ("pan", MouseButton.LEFT, 'x', From 80b08a389990c56d0b49fe7e4b03a7461eb1254a Mon Sep 17 00:00:00 2001 From: Scott Shambaugh Date: Mon, 5 Sep 2022 22:45:59 -0600 Subject: [PATCH 32/39] Test 3d toolbar navigation --- lib/mpl_toolkits/tests/test_mplot3d.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/lib/mpl_toolkits/tests/test_mplot3d.py b/lib/mpl_toolkits/tests/test_mplot3d.py index f4851f825ed4..8eee2bcbe7a1 100644 --- a/lib/mpl_toolkits/tests/test_mplot3d.py +++ b/lib/mpl_toolkits/tests/test_mplot3d.py @@ -1660,6 +1660,7 @@ def test_toolbar_zoom_pan(tool, button, key, expected): ax = fig.add_subplot(projection='3d') ax.scatter(0, 0, 0) fig.canvas.draw() + xlim0, ylim0, zlim0 = ax.get_xlim3d(), ax.get_ylim3d(), ax.get_zlim3d() # Mouse from (0, 0) to (1, 1) d0 = (0, 0) @@ -1694,6 +1695,22 @@ def test_toolbar_zoom_pan(tool, button, key, expected): assert ax.get_ylim3d() == pytest.approx(ylim, abs=0.01) assert ax.get_zlim3d() == pytest.approx(zlim, abs=0.01) + # Ensure that back, forward, and home buttons work + tb.back() + assert ax.get_xlim3d() == pytest.approx(xlim0, abs=0.0001) + assert ax.get_ylim3d() == pytest.approx(ylim0, abs=0.0001) + assert ax.get_zlim3d() == pytest.approx(zlim0, abs=0.0001) + + tb.forward() + 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) + + tb.home() + assert ax.get_xlim3d() == pytest.approx(xlim0, abs=0.0001) + assert ax.get_ylim3d() == pytest.approx(ylim0, abs=0.0001) + assert ax.get_zlim3d() == pytest.approx(zlim0, abs=0.0001) + @mpl.style.context('default') @check_figures_equal(extensions=["png"]) From 50defe5c0cfc90b4ca90284bd64400272d97486c Mon Sep 17 00:00:00 2001 From: Scott Shambaugh Date: Mon, 5 Sep 2022 22:48:36 -0600 Subject: [PATCH 33/39] Privatize helper functions --- lib/mpl_toolkits/mplot3d/axes3d.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index 2690ba5fee3c..c98110786c63 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -1245,9 +1245,9 @@ def _set_view_from_bbox(self, bbox, direction='in', if direction == 'out': scale = 1 / scale - self.zoom_data_limits(scale, scale, scale) + self._zoom_data_limits(scale, scale, scale) - def zoom_data_limits(self, scale_u, scale_v, scale_w): + def _zoom_data_limits(self, scale_u, scale_v, scale_w): """ Zoom in or out of a 3D plot. Will scale the data limits by the scale factors. These will be @@ -1282,9 +1282,9 @@ def zoom_data_limits(self, scale_u, scale_v, scale_w): min_ax_idxs = np.argmin(np.abs(scale[ax_idxs] - 1)) scale[ax_idxs] = scale[ax_idxs][min_ax_idxs] - self.scale_axis_limits(scale[0], scale[1], scale[2]) + self._scale_axis_limits(scale[0], scale[1], scale[2]) - def scale_axis_limits(self, scale_x, scale_y, scale_z): + def _scale_axis_limits(self, scale_x, scale_y, scale_z): """ Keeping the center of the x, y, and z data axes fixed, scale their limits by scale factors. A scale factor > 1 zooms out and a scale From 9e60e624fc6b2c2a5c1dd8f681a8cf30684c7533 Mon Sep 17 00:00:00 2001 From: Scott Shambaugh Date: Tue, 6 Sep 2022 08:55:00 -0600 Subject: [PATCH 34/39] Deprecations --- lib/mpl_toolkits/mplot3d/axes3d.py | 6 +++--- lib/mpl_toolkits/mplot3d/proj3d.py | 10 ++++++---- lib/mpl_toolkits/tests/test_mplot3d.py | 18 ++++++++++-------- 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index c98110786c63..dd691b3bf562 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -920,13 +920,13 @@ def get_proj(self): # Generate the view and projection transformation matrices if self._focal_length == np.inf: # Orthographic projection - viewM = proj3d.view_transformation_uvw(u, v, w, eye) + viewM = proj3d._view_transformation_uvw(u, v, w, eye) projM = proj3d.ortho_transformation(-self._dist, self._dist) else: # Perspective projection # Scale the eye dist to compensate for the focal length zoom effect eye_focal = R + self._dist * ps * self._focal_length - viewM = proj3d.view_transformation_uvw(u, v, w, eye_focal) + viewM = proj3d._view_transformation_uvw(u, v, w, eye_focal) projM = proj3d.persp_transformation(-self._dist, self._dist, self._focal_length) @@ -1197,7 +1197,7 @@ def _calc_view_axes(self, eye): V = np.zeros(3) V[self._vertical_axis] = -1 if abs(elev_rad) > np.pi/2 else 1 - u, v, w = proj3d.view_axes(eye, R, V, roll_rad) + u, v, w = proj3d._view_axes(eye, R, V, roll_rad) return u, v, w def _set_view_from_bbox(self, bbox, direction='in', diff --git a/lib/mpl_toolkits/mplot3d/proj3d.py b/lib/mpl_toolkits/mplot3d/proj3d.py index 67036e2e6eb3..06f9d98a96f5 100644 --- a/lib/mpl_toolkits/mplot3d/proj3d.py +++ b/lib/mpl_toolkits/mplot3d/proj3d.py @@ -4,6 +4,7 @@ import numpy as np import numpy.linalg as linalg +from matplotlib import _api def _line2d_seg_dist(p1, p2, p0): @@ -72,7 +73,7 @@ def rotation_about_vector(v, angle): return R -def view_axes(E, R, V, roll): +def _view_axes(E, R, V, roll): """ Get the unit viewing axes in data coordinates. @@ -111,7 +112,7 @@ def view_axes(E, R, V, roll): return u, v, w -def view_transformation_uvw(u, v, w, E): +def _view_transformation_uvw(u, v, w, E): """ Return the view transformation matrix. @@ -134,6 +135,7 @@ def view_transformation_uvw(u, v, w, E): return M +@_api.deprecated("3.7") def view_transformation(E, R, V, roll): """ Return the view transformation matrix. @@ -149,8 +151,8 @@ def view_transformation(E, R, V, roll): roll : float The roll angle in radians. """ - u, v, w = view_axes(E, R, V, roll) - M = view_transformation_uvw(u, v, w, E) + u, v, w = _view_axes(E, R, V, roll) + M = _view_transformation_uvw(u, v, w, E) return M diff --git a/lib/mpl_toolkits/tests/test_mplot3d.py b/lib/mpl_toolkits/tests/test_mplot3d.py index 8eee2bcbe7a1..a840b25430e5 100644 --- a/lib/mpl_toolkits/tests/test_mplot3d.py +++ b/lib/mpl_toolkits/tests/test_mplot3d.py @@ -976,7 +976,8 @@ def _test_proj_make_M(): R = np.array([100, 100, 100]) V = np.array([0, 0, 1]) roll = 0 - viewM = proj3d.view_transformation(E, R, V, roll) + u, v, w = proj3d._view_axes(E, R, V, roll) + viewM = proj3d._view_transformation_uvw(u, v, w, E) perspM = proj3d.persp_transformation(100, -100, 1) M = np.dot(perspM, viewM) return M @@ -1042,7 +1043,8 @@ def test_proj_axes_cube_ortho(): R = np.array([0, 0, 0]) V = np.array([0, 0, 1]) roll = 0 - viewM = proj3d.view_transformation(E, R, V, roll) + u, v, w = proj3d._view_axes(E, R, V, roll) + viewM = proj3d._view_transformation_uvw(u, v, w, E) orthoM = proj3d.ortho_transformation(-1, 1) M = np.dot(orthoM, viewM) @@ -1697,9 +1699,9 @@ def test_toolbar_zoom_pan(tool, button, key, expected): # Ensure that back, forward, and home buttons work tb.back() - assert ax.get_xlim3d() == pytest.approx(xlim0, abs=0.0001) - assert ax.get_ylim3d() == pytest.approx(ylim0, abs=0.0001) - assert ax.get_zlim3d() == pytest.approx(zlim0, abs=0.0001) + assert ax.get_xlim3d() == pytest.approx(xlim0) + assert ax.get_ylim3d() == pytest.approx(ylim0) + assert ax.get_zlim3d() == pytest.approx(zlim0) tb.forward() assert ax.get_xlim3d() == pytest.approx(xlim, abs=0.01) @@ -1707,9 +1709,9 @@ def test_toolbar_zoom_pan(tool, button, key, expected): assert ax.get_zlim3d() == pytest.approx(zlim, abs=0.01) tb.home() - assert ax.get_xlim3d() == pytest.approx(xlim0, abs=0.0001) - assert ax.get_ylim3d() == pytest.approx(ylim0, abs=0.0001) - assert ax.get_zlim3d() == pytest.approx(zlim0, abs=0.0001) + assert ax.get_xlim3d() == pytest.approx(xlim0) + assert ax.get_ylim3d() == pytest.approx(ylim0) + assert ax.get_zlim3d() == pytest.approx(zlim0) @mpl.style.context('default') From 44a6d28305c0337838be211609f477c0400ad289 Mon Sep 17 00:00:00 2001 From: Scott Shambaugh Date: Wed, 7 Sep 2022 20:47:41 -0600 Subject: [PATCH 35/39] Code review changes --- doc/users/next_whats_new/3d_plot_pan_zoom.rst | 2 +- lib/mpl_toolkits/mplot3d/axes3d.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/doc/users/next_whats_new/3d_plot_pan_zoom.rst b/doc/users/next_whats_new/3d_plot_pan_zoom.rst index 98df0b11de5b..a94dfe4c207d 100644 --- a/doc/users/next_whats_new/3d_plot_pan_zoom.rst +++ b/doc/users/next_whats_new/3d_plot_pan_zoom.rst @@ -2,7 +2,7 @@ ---------------------------- The pan and zoom buttons in the toolbar of 3D plots are now enabled. -Deselect both to rotate the plot. When the zoom button is pressed, +Unselect both to rotate the plot. When the zoom button is pressed, zoom in by using the left mouse button to draw a bounding box, and out by using the right mouse button to draw the box. When zooming a 3D plot, the current view aspect ratios are kept fixed. diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index dd691b3bf562..e9b57f63476f 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -31,7 +31,6 @@ import matplotlib.transforms as mtransforms from matplotlib.axes import Axes from matplotlib.axes._base import _axis_method_wrapper, _process_plot_format -from matplotlib.backend_bases import _Mode from matplotlib.transforms import Bbox from matplotlib.tri.triangulation import Triangulation @@ -1012,7 +1011,9 @@ def _button_press(self, event): def _button_release(self, event): self.button_pressed = None toolbar = getattr(self.figure.canvas, "toolbar") - if toolbar and toolbar.mode not in (_Mode.PAN, _Mode.ZOOM): + # backend_bases.release_zoom and backend_bases.release_pan call + # push_current, so check the navigation mode so we don't call it twice + if toolbar and self.get_navigate_mode() is None: self.figure.canvas.toolbar.push_current() def _get_view(self): @@ -1162,7 +1163,7 @@ def drag_pan(self, button, key, x, y): if du == 0 and dv == 0: return - # Transform the pan from the view axes to the data axees + # Transform the pan from the view axes to the data axes R = np.array([self._view_u, self._view_v, self._view_w]) R = -R / self._box_aspect * self._dist duvw_projected = R.T @ np.array([du, dv, dw]) From 7ac9f0aa7a07c107920591ad8c9d8e982d39cb3e Mon Sep 17 00:00:00 2001 From: Scott Shambaugh Date: Tue, 4 Oct 2022 13:53:03 -0600 Subject: [PATCH 36/39] Deprecation note --- doc/api/next_api_changes/deprecations/23449-SS.rst | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 doc/api/next_api_changes/deprecations/23449-SS.rst diff --git a/doc/api/next_api_changes/deprecations/23449-SS.rst b/doc/api/next_api_changes/deprecations/23449-SS.rst new file mode 100644 index 000000000000..14e9196133f4 --- /dev/null +++ b/doc/api/next_api_changes/deprecations/23449-SS.rst @@ -0,0 +1,5 @@ +``plot3d.view_transformation`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +... is deprecated. The private functions `plot3d._view_transformation_uvw` and +`plot3d._view_axes` can be used instead, though with no guarantee of API +stability. From a045f36af7815670b8e4d92060951c04fe322fc2 Mon Sep 17 00:00:00 2001 From: Scott Shambaugh <14363975+scottshambaugh@users.noreply.github.com> Date: Wed, 5 Oct 2022 09:40:27 -0600 Subject: [PATCH 37/39] Undeprecate proj3d.view_transformation --- doc/api/next_api_changes/deprecations/23449-SS.rst | 8 +++----- lib/mpl_toolkits/mplot3d/proj3d.py | 2 -- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/doc/api/next_api_changes/deprecations/23449-SS.rst b/doc/api/next_api_changes/deprecations/23449-SS.rst index 14e9196133f4..3f2d6d92b4a6 100644 --- a/doc/api/next_api_changes/deprecations/23449-SS.rst +++ b/doc/api/next_api_changes/deprecations/23449-SS.rst @@ -1,5 +1,3 @@ -``plot3d.view_transformation`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -... is deprecated. The private functions `plot3d._view_transformation_uvw` and -`plot3d._view_axes` can be used instead, though with no guarantee of API -stability. +In ``axes3d``, the variables `vvec`, `eye`, `sx`, and `sy` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +... are deprecated without replacement. diff --git a/lib/mpl_toolkits/mplot3d/proj3d.py b/lib/mpl_toolkits/mplot3d/proj3d.py index 06f9d98a96f5..c9659456f3be 100644 --- a/lib/mpl_toolkits/mplot3d/proj3d.py +++ b/lib/mpl_toolkits/mplot3d/proj3d.py @@ -4,7 +4,6 @@ import numpy as np import numpy.linalg as linalg -from matplotlib import _api def _line2d_seg_dist(p1, p2, p0): @@ -135,7 +134,6 @@ def _view_transformation_uvw(u, v, w, E): return M -@_api.deprecated("3.7") def view_transformation(E, R, V, roll): """ Return the view transformation matrix. From e759fbad60dba45494b3f7a21ce95a56ee460a4c Mon Sep 17 00:00:00 2001 From: Scott Shambaugh <14363975+scottshambaugh@users.noreply.github.com> Date: Wed, 5 Oct 2022 09:40:27 -0600 Subject: [PATCH 38/39] Undeprecate proj3d.view_transformation --- doc/api/next_api_changes/deprecations/23449-SS.rst | 8 +++----- lib/mpl_toolkits/mplot3d/proj3d.py | 2 -- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/doc/api/next_api_changes/deprecations/23449-SS.rst b/doc/api/next_api_changes/deprecations/23449-SS.rst index 14e9196133f4..aab38463873a 100644 --- a/doc/api/next_api_changes/deprecations/23449-SS.rst +++ b/doc/api/next_api_changes/deprecations/23449-SS.rst @@ -1,5 +1,3 @@ -``plot3d.view_transformation`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -... is deprecated. The private functions `plot3d._view_transformation_uvw` and -`plot3d._view_axes` can be used instead, though with no guarantee of API -stability. +``axes3d`` +~~~~~~~~~~ +The variables `vvec`, `eye`, `sx`, and `sy` are deprecated without replacement. diff --git a/lib/mpl_toolkits/mplot3d/proj3d.py b/lib/mpl_toolkits/mplot3d/proj3d.py index 06f9d98a96f5..c9659456f3be 100644 --- a/lib/mpl_toolkits/mplot3d/proj3d.py +++ b/lib/mpl_toolkits/mplot3d/proj3d.py @@ -4,7 +4,6 @@ import numpy as np import numpy.linalg as linalg -from matplotlib import _api def _line2d_seg_dist(p1, p2, p0): @@ -135,7 +134,6 @@ def _view_transformation_uvw(u, v, w, E): return M -@_api.deprecated("3.7") def view_transformation(E, R, V, roll): """ Return the view transformation matrix. From ae8f6a1c896faeff35b9f62a089392844f337e1c Mon Sep 17 00:00:00 2001 From: Scott Shambaugh <14363975+scottshambaugh@users.noreply.github.com> Date: Thu, 6 Oct 2022 08:26:22 -0600 Subject: [PATCH 39/39] Update doc/api/next_api_changes/deprecations/23449-SS.rst Co-authored-by: Oscar Gustafsson --- doc/api/next_api_changes/deprecations/23449-SS.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/api/next_api_changes/deprecations/23449-SS.rst b/doc/api/next_api_changes/deprecations/23449-SS.rst index 3f2d6d92b4a6..75b76d55c05c 100644 --- a/doc/api/next_api_changes/deprecations/23449-SS.rst +++ b/doc/api/next_api_changes/deprecations/23449-SS.rst @@ -1,3 +1,3 @@ -In ``axes3d``, the variables `vvec`, `eye`, `sx`, and `sy` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +In ``axes3d``, the attributes `vvec`, `eye`, `sx`, and `sy` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ... are deprecated without replacement.