Repository: matplotlib/matplotlib
Pull request: Add pan and zoom toolbar handling to 3D Axes (Replaces PR#22614)
## PR Summary
Starting with @greglucas's work in https://github.com/matplotlib/matplotlib/pull/22614, I got panning and zooming working again with the toolbar buttons. Closes https://github.com/matplotlib/matplotlib/issues/110 (the oldest open issue!)

**Changes in this PR:**
* Pan and zoom buttons enabled for 3D plots
* Panning 3D plots reworked in general. Previously there was a 'scaling factor' in how much the plot moved with mouse movement. This has been updated so that if you are using an orthographic projection, the point directly under the mouse stays directly under the mouse. (This is not possible with a perspective projection)
* [bugfix] Panning with a nonzero roll angle was broken, works now.
* Made panning button configurable, like the zoom and rotate buttons are
* Added in 'x' and 'y' key locking for 3d plot panning and zooming
* [bugfix] Fixed mouse zoom jumping if hovering over a separate subplot
* Refactor calculating the u, v, w view axes, add these as Axes3D private variables `._view_u`, `._view_v`, `._view_w`
* Privatize and add deprecation warnings for Axes3D `.eye`, `.vvec`, `.sx`, `.sy`, and `proj3d.view_transformation()`
* Tests for panning and zooming

If this is too much packed in a single PR, I can separate it out into the panning and the zooming features.

**Zooming behavior:**
- Edit: All axes now zoom equally.
- ~~With right mouse button: All axes are zoomed equally.~~
- ~~With the bounding box and an aspect ratio of `'auto'`: The scale factors set by the bounding box extents zoom the data axes such that the data is stretched in the `u`, `v` plane parallel to the screen but not in the `w` axis into/out of the screen. In other words, this zooms in 2D, projected onto the 3D data axes. Aspect ratio is set by the aspect of the bounding box. Note that the bounding box scale factors are calculated with respect to the whole canvas rather than the (shrunk) 3D axis limits.~~
- ~~With the bounding box and an aspect ratio of `'equal'`, `'equalzy'`, `'equalyz'`, or `'equalxz'`: Similar to the above, but the constrained data axes are kept equal, which may induce scaling in the `w` axis. `'equal'` zooms all axes equally.~~

Please play around with it and see if the behavior is as expected!

Here's my test plot code which you can use:
```python
from mpl_toolkits.mplot3d import Axes3D
import matplotlib.pyplot as plt
import numpy as np
from itertools import product, combinations

fig, ax = plt.subplots(1, 1, subplot_kw={'projection': '3d'})

# Draw rectangular cuboid with side lengths [1, 1, 2]
r = [0, 1]
scale = np.array([1, 1, 2])
for start, end in combinations(np.array(list(product(r, r, r))), 2):
    if np.sum(np.abs(start - end)) == r[1] - r[0]:
        ax.plot3D(*zip(start*scale, end*scale))

ax.view_init(elev=45, azim=45, roll=0)

ax.set_proj_type('ortho')
ax.set_xlabel('x')
ax.set_ylabel('y')
ax.set_zlabel('z')

plt.show(block=True)
```

## PR Checklist

<!-- Please mark any checkboxes that do not apply to this PR as [N/A]. -->
**Tests and Styling**
- [x] Has pytest style unit tests (and `pytest` passes).
- [x] Is [Flake 8](https://flake8.pycqa.org/en/latest/) compliant (install `flake8-docstrings` and run `flake8 --docstring-convention=all`).

**Documentation**
- [x] New features are documented, with examples if plot related.
- [x] New features have an entry in `doc/users/next_whats_new/` (follow instructions in README.rst there).
- [n/a] API changes documented in `doc/api/next_api_changes/` (follow instructions in README.rst there).
- [x] Documentation is sphinx and numpydoc compliant (the docs should [build](https://matplotlib.org/devel/documenting_mpl.html#building-the-docs) without error).

<!--
Thank you so much for your PR!  To help us review your contribution, please
consider the following points:

- A development guide is available at https://matplotlib.org/devdocs/devel/index.html.

- Help with git and github is available at
  https://matplotlib.org/devel/gitwash/development_workflow.html.

- Do not create the PR out of main, but out of a separate branch.

- The PR title should summarize the changes, for example "Raise ValueError on
  non-numeric input to set_xlim".  Avoid non-descriptive titles such as
  "Addresses issue #8576".

- The summary should provide at least 1-2 sentences describing the pull request
  in detail (Why is this change required?  What problem does it solve?) and
  link to any relevant issues.

- If you are contributing fixes to docstrings, please pay attention to
  http://matplotlib.org/devel/documenting_mpl.html#formatting.  In particular,
  note the difference between using single backquotes, double backquotes, and
  asterisks in the markup.

We understand that PRs can sometimes be overwhelming, especially as the
reviews start coming in.  Please let us know if the reviews are unclear or
the recommended next step seems overly demanding, if you would like help in
addressing a reviewer's comments, or if you have been waiting too long to hear
back on your PR.
-->


Problem statement:
pan and zoom are broken for mplot3d
Chances are, this will probably fall on me to address, but I am documenting it here in case someone else realizes the issue.

When creating 3d axes in the old manner (ie, ax = Axes3D(fig)), the panning and zooming features are completely useless and often produces incomprehensible results.  Axes created in the newer manner (ie, ax = fig.gca(projection='3d')) has somewhat better behavior, but still quite useless.

While pan and zoom has never really been properly supported in mplot3d, the current behavior is significantly worse than before.  I suspect that there might be some interactions with a variety of changes.  First, there were some changes to figure object itself, which may have not been properly tested with non-subplotted axes (which would explain differences in behavior between the old style and new style).  Second, I suspect that some changes to the clipping logic for Agg might be causing some of the issues with zooming and panning.

I think I will temporarily address this issue by disabling pan and zoom for Axes3D objects.  I also want to make the Home button capable of returning the figure view back to normal.  It does not appear to do this right now.

Hints from issue discussion:
This is partially resolved by disabling the zoom feature (through the button).  Note that zooming is still possible by right-clicking the figure and dragging the mouse up and down.

Disabling the Pan feature is trickier...

I am going to declare this issue closed with the current work-around in place in master.

Pan and home button still don't work - any progress on this?
Especially the pan feature is important for me. I am analyzing 3d data which contains noise / outliers, that scale away the interesting part of the figure. It's not enough only to zoom to the center of the data. I need to pan/move the data/camera in order to zoom into an interesting region.

This is likely to be addressed in version 2.1 as a part of the toolbar refactor. We will be able to dynamically add toolbar buttons with the new architecture, which would be required for this to work properly, I think. The timeline for version 2.1 will be next april, unfortunately.

In the meantime, you might be interested in the vispy project which has made great strides in interactive visualizations that matplotlib cannot currently do, particularly for 3D. The long-term plan is to eventually integrate matplotlib and vispy together in some form, but that is a few years away.

Thanks for the news!
Unfortunately I need a solution within the next three months.

Didn't know about vispy (but mayavi), which looks promising. Though it lacks axes that are necessary for proper analysis and scientific publication. I found that they are scheduled for the next version update end of September, but there is no info how they will behave.

Until now I found no tool that is capable of plotting 3d data for analysis and publication, meaning with
- an interactive GUI, that supports
  - (intuitive) pan/move, zoom (like mayavi)
  - preserving plot properties like line width etc. on zoom (not like mayavi where lines grow bigger on zoom)
  - axes that properly handle pan/zoom interaction (like mplot3d or gnuplot, not even matlab deals with this correctly)
  - axes that maintain a given ratio
- nice formatting (I know this is very vague)
- file export in vector image formats

Looks like I need to use separate tools for those two tasks.

Hello guys,

I strongly disagree, that moving in figure makes no sense! General overview of your plotted data is not enough, espetially if you want to check some tiny details on the corner of your figure!

On the other hand its true, that determine way to move with mouse make no sense, because of differences in 2D mouse and 3D graph (is 2D mouse close to myself, or inside of data yet?).

I spent nearly 16 hours with solving issue, how to move in graph. (Where is list of Matplotlib keys? Why given examples wont work? Etc., etc. Documentation is complicated, unclear, and completely not friendly to use!!!) 

I found this tricky solution, maybe it someone found usefull. It works both for 2D and 3D, therefore there is added variable to determine, which view you actually use. The code is fairly straightforward, I am pretty sure, that you will be able to adjust it to your own without any issues.

**If you move data, in which you are interested in, in center of figure, then you will be able to zoom to them and view exactly them in details.** (Note below).

```python
    def move_view(event):
            ax.autoscale(enable=False, axis='both')  # I have no idea, it this line have some effect at all
            ## Set nearly similar speed of motion in dependency on zoom
            if view_2D:
                koef = 4.  ## Speed for 2D should be higher
            else:
                koef = 8.  ## speed for 3D should be lower
                zkoef = (ax.get_zbound()[0] - ax.get_zbound()[1]) / koef

            xkoef = (ax.get_xbound()[0] - ax.get_xbound()[1]) / koef
            ykoef = (ax.get_ybound()[0] - ax.get_ybound()[1]) / koef

            ## Map an motion to keyboard shortcuts
            if event.key == "ctrl+down":
                ax.set_ybound(ax.get_ybound()[0] + xkoef, ax.get_ybound()[1] + xkoef)
            if event.key == "ctrl+up":
                ax.set_ybound(ax.get_ybound()[0] - xkoef, ax.get_ybound()[1] - xkoef)
            if event.key == "ctrl+right":
                ax.set_xbound(ax.get_xbound()[0] + ykoef, ax.get_xbound()[1] + ykoef)
            if event.key == "ctrl+left":
                ax.set_xbound(ax.get_xbound()[0] - ykoef, ax.get_xbound()[1] - ykoef)
            if not view_2D:
                if event.key == "down":
                    ax.set_zbound(ax.get_zbound()[0] - zkoef, ax.get_zbound()[1] - zkoef)
                if event.key == "up":
                    ax.set_zbound(ax.get_zbound()[0] + zkoef, ax.get_zbound()[1] + zkoef)
            # print event.key

        fig.canvas.mpl_connect("key_press_event", move_view)
```

Like that its mapped to arrows on keyboard. I wasnt able to override default Matplotlib shortcuts through key_press_event, so its mapped to "ctrl", which is not comfortable, but I dont know better solution for now. If you want to change keyboard shortcut and you dont know the name for it, just uncomment last line. After run your script, you can see proper name for pressed key (or key combination), by pressing key on the (focused) figure.

Note, that if you want to change the direction of motion, it is necessary to change both of signs before each koef. Otherwise it zooms, instead of move.

It is obvious, that move direction will be still in the same axes, independently on your view.

This code should be placed anywhere before plt.show()

**Note below:** To determine a center of graph I tried an callback function to draw an cross in the middle of graph, but it behave a bit strange in Z-axes, I am unsure, why. Function is below. Maybe is good idea to map it to key_release_event, if in Matplotlib exists any.

```python
self.midptcurs = None ## it should be OUT of function scope, 
    ##because we need to persist pointer to previous position cross
        def showmidp(event):
            if view_2D:
                midpt = [(ax.get_xbound()[1] + ax.get_xbound()[0]) / 2., (ax.get_ybound()[1] + ax.get_ybound()[0]) / 2.]
            else:
                midpt = [(ax.get_xbound()[1] + ax.get_xbound()[0]) / 2.,
                              (ax.get_ybound()[1] + ax.get_ybound()[0]) / 2.,
                              (ax.get_zbound()[1] + ax.get_zbound()[0]) / 2.,]

            try:
                self.midptcurs.remove()
            except AttributeError:  ## To prevent raise error, when first position is not known
                pass

            if view_2D:
                self.midptcurs = ax.scatter(midpt[0], midpt[1], marker="+", s=30, c="black")
            else:
                self.midptcurs = ax.scatter(midpt[0], midpt[1], midpt[2], marker="+", s=30, c="black")
```

Hope it helps and at least temporarily solve an issue.

(And forgive me eventually code spaces errors, I am here completely newbie. This issue makes me so angry, that I decide to share solution and therefore I make my account here... :) )
> 
> 
> This is partially resolved by disabling the zoom feature (through the button). Note that zooming is still possible by right-clicking the figure and dragging the mouse up and down.
> 
> Disabling the Pan feature is trickier...

I think the fix from fcd9fd899f1bff24ba0a5f7c523594c030345ed2 is no longer valid as I can zoom but not pan. So perhaps the `False` returns from `ax.can_pan()` and `ax.can_zoom()` should be changed. I am using Matplotlib 3.1.0 with python 3.7.3.

I came across this issue while searching for "pan matplotlib 3d" in google. I am trying to pan a 3d plot, but I cannot do it from the plot window. I also found [this](https://stackoverflow.com/questions/25575729/how-to-zoom-with-axes3d-in-matplotlib) stackoverflow question about someone who can pan but cannot zoom.
I confirm that one can zoom using the right mouse button. However it's not possible to zoom using the regular "draw a rectangle" method. Not sure if and how we should handle this.
I confirm the same issue @sleepyhollo is having. I can zoom but I cannot pan. Using WebAgg backend. Matplotlib 3.1.1.

I also confirm @timhoffm issue, none of the zoom/pan buttons seem to work. They all maintain the default zoom behavior.
> Hello guys,
> 
> I strongly disagree, that moving in figure makes no sense! General overview of your plotted data is not enough, espetially if you want to check some tiny details on the corner of your figure!
> 
> On the other hand its true, that determine way to move with mouse make no sense, because of differences in 2D mouse and 3D graph (is 2D mouse close to myself, or inside of data yet?).
> 
> I spent nearly 16 hours with solving issue, how to move in graph. (Where is list of Matplotlib keys? Why given examples wont work? Etc., etc. Documentation is complicated, unclear, and completely not friendly to use!!!)
> 
> I found this tricky solution, maybe it someone found usefull. It works both for 2D and 3D, therefore there is added variable to determine, which view you actually use. The code is fairly straightforward, I am pretty sure, that you will be able to adjust it to your own without any issues.
> 
> **If you move data, in which you are interested in, in center of figure, then you will be able to zoom to them and view exactly them in details.** (Note below).
> 
> ```python
>     def move_view(event):
>             ax.autoscale(enable=False, axis='both')  # I have no idea, it this line have some effect at all
>             ## Set nearly similar speed of motion in dependency on zoom
>             if view_2D:
>                 koef = 4.  ## Speed for 2D should be higher
>             else:
>                 koef = 8.  ## speed for 3D should be lower
>                 zkoef = (ax.get_zbound()[0] - ax.get_zbound()[1]) / koef
> 
>             xkoef = (ax.get_xbound()[0] - ax.get_xbound()[1]) / koef
>             ykoef = (ax.get_ybound()[0] - ax.get_ybound()[1]) / koef
> 
>             ## Map an motion to keyboard shortcuts
>             if event.key == "ctrl+down":
>                 ax.set_ybound(ax.get_ybound()[0] + xkoef, ax.get_ybound()[1] + xkoef)
>             if event.key == "ctrl+up":
>                 ax.set_ybound(ax.get_ybound()[0] - xkoef, ax.get_ybound()[1] - xkoef)
>             if event.key == "ctrl+right":
>                 ax.set_xbound(ax.get_xbound()[0] + ykoef, ax.get_xbound()[1] + ykoef)
>             if event.key == "ctrl+left":
>                 ax.set_xbound(ax.get_xbound()[0] - ykoef, ax.get_xbound()[1] - ykoef)
>             if not view_2D:
>                 if event.key == "down":
>                     ax.set_zbound(ax.get_zbound()[0] - zkoef, ax.get_zbound()[1] - zkoef)
>                 if event.key == "up":
>                     ax.set_zbound(ax.get_zbound()[0] + zkoef, ax.get_zbound()[1] + zkoef)
>             # print event.key
> 
>         fig.canvas.mpl_connect("key_press_event", move_view)
> ```
> 
> Like that its mapped to arrows on keyboard. I wasnt able to override default Matplotlib shortcuts through key_press_event, so its mapped to "ctrl", which is not comfortable, but I dont know better solution for now. If you want to change keyboard shortcut and you dont know the name for it, just uncomment last line. After run your script, you can see proper name for pressed key (or key combination), by pressing key on the (focused) figure.
> 
> Note, that if you want to change the direction of motion, it is necessary to change both of signs before each koef. Otherwise it zooms, instead of move.
> 
> It is obvious, that move direction will be still in the same axes, independently on your view.
> 
> This code should be placed anywhere before plt.show()
> 
> **Note below:** To determine a center of graph I tried an callback function to draw an cross in the middle of graph, but it behave a bit strange in Z-axes, I am unsure, why. Function is below. Maybe is good idea to map it to key_release_event, if in Matplotlib exists any.
> 
> ```python
> self.midptcurs = None ## it should be OUT of function scope, 
>     ##because we need to persist pointer to previous position cross
>         def showmidp(event):
>             if view_2D:
>                 midpt = [(ax.get_xbound()[1] + ax.get_xbound()[0]) / 2., (ax.get_ybound()[1] + ax.get_ybound()[0]) / 2.]
>             else:
>                 midpt = [(ax.get_xbound()[1] + ax.get_xbound()[0]) / 2.,
>                               (ax.get_ybound()[1] + ax.get_ybound()[0]) / 2.,
>                               (ax.get_zbound()[1] + ax.get_zbound()[0]) / 2.,]
> 
>             try:
>                 self.midptcurs.remove()
>             except AttributeError:  ## To prevent raise error, when first position is not known
>                 pass
> 
>             if view_2D:
>                 self.midptcurs = ax.scatter(midpt[0], midpt[1], marker="+", s=30, c="black")
>             else:
>                 self.midptcurs = ax.scatter(midpt[0], midpt[1], midpt[2], marker="+", s=30, c="black")
> ```
> 
> Hope it helps and at least temporarily solve an issue.
> 
> (And forgive me eventually code spaces errors, I am here completely newbie. This issue makes me so angry, that I decide to share solution and therefore I make my account here... :) )

This was of great help, thank you!

EDIT:
I made the following changes to your event, since I'm not using 2D views:
I also added the the redraw function at the end, as to see the changes when the even is called.
 ```python
>     def move_view(event):
>             ax.autoscale(enable=False, axis='both')  # I have no idea, it this line have some effect at all
>             ## Set nearly similar speed of motion in dependency on zoom
>             koef = 8.  ## speed for 3D should be lower
>             zkoef = (ax.get_zbound()[0] - ax.get_zbound()[1]) / koef
> 
>             xkoef = (ax.get_xbound()[0] - ax.get_xbound()[1]) / koef
>             ykoef = (ax.get_ybound()[0] - ax.get_ybound()[1]) / koef
> 
>             ## Map an motion to keyboard shortcuts
>             if event.key == "ctrl+down":
>                 ax.set_ybound(ax.get_ybound()[0] + xkoef, ax.get_ybound()[1] + xkoef)
>             if event.key == "ctrl+up":
>                 ax.set_ybound(ax.get_ybound()[0] - xkoef, ax.get_ybound()[1] - xkoef)
>             if event.key == "ctrl+right":
>                 ax.set_xbound(ax.get_xbound()[0] + ykoef, ax.get_xbound()[1] + ykoef)
>             if event.key == "ctrl+left":
>                 ax.set_xbound(ax.get_xbound()[0] - ykoef, ax.get_xbound()[1] - ykoef)
>             if event.key == "down":
>                 ax.set_zbound(ax.get_zbound()[0] - zkoef, ax.get_zbound()[1] - zkoef)
>             if event.key == "up":
>                 ax.set_zbound(ax.get_zbound()[0] + zkoef, ax.get_zbound()[1] + zkoef)
>             
>              ax.figure.canvas.draw()
>             # print event.key
> ```

Thanks again, you are a life saver!

Commit message: Docstrings

Patch to review:
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.
diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py
index d50ad5235ccd..053f0facb10a 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)
@@ -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)
@@ -895,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.
 
@@ -903,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.
         """
@@ -911,27 +903,24 @@ 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):
         """
         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.
@@ -1043,25 +1032,29 @@ 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:
             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
 
         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
         w = self._pseudo_w
         h = self._pseudo_h
-        self.sx, self.sy = x, y
 
         # Rotation
         if self.button_pressed in self._rotate_btn:
@@ -1075,45 +1068,184 @@ 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
-            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()
+        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)
+            # 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:
-            # 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()
-            self.figure.canvas.draw_idle()
+            # zoom view (dragging down zooms in)
+            scale = h/(h - dy)
+            self._zoom_data_limits(scale, scale, scale)
+
+        # 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
+        dz = 0
+        if key == 'x':
+            dy = 0
+        elif key == 'y':
+            dx = 0
+        if dx == 0 and dy == 0:
+            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
+        dxyz_projected = R.T @ np.array([dx, dy, dz])
+
+        # 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]
+
+        # 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)
+
+    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))
+
+        # 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):
+        """
+        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]
+            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
+
+        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])
+        if direction == 'out':
+            scale_x = 1 / scale_x
+            scale_y = 1 / scale_y
+
+        self._zoom_data_limits(scale_x, scale_y)
+
+    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=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])
+        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
+        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 2f23e3779b06..c4a54bea876e 100644
--- a/lib/mpl_toolkits/mplot3d/proj3d.py
+++ b/lib/mpl_toolkits/mplot3d/proj3d.py
@@ -72,7 +72,13 @@ def rotation_about_vector(v, angle):
     return R
 
 
-def view_transformation(E, R, V, roll):
+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
+    """
     n = (E - R)
     n = n/np.linalg.norm(n)
     u = np.cross(V, n)
@@ -85,12 +91,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)
 
 
diff --git a/lib/mpl_toolkits/tests/test_mplot3d.py b/lib/mpl_toolkits/tests/test_mplot3d.py
index 13b6100a505d..2c9229bbde60 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
@@ -875,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
@@ -941,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)
 
@@ -1530,6 +1533,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.03, 0.61), (0.27, 0.71), (0.32, 0.89))),
+                          ("zoom", MouseButton.RIGHT,  # 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):
+    # 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
+    #       scaling factor gets 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):


Code review comments:
diff_hunk: @@ -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)
path: lib/mpl_toolkits/mplot3d/axes3d.py
text: @author:
self.vvec was not being used anywhere. Good to remove or does this need a deprecation warning?

@user1:
It probably should be deprecated, along with `eye`?

@user2:
Here is a bit of, sort of, context: https://github.com/matplotlib/matplotlib/blob/main/doc/api/next_api_changes/removals/00001-DS.rst

(wrong PR number btw).

Seems like the render-version of it was deprecated in 3.4 https://github.com/matplotlib/matplotlib/blob/main/doc/api/prev_api_changes/api_changes_3.4.0/deprecations.rst

@author:
Added deprecation warnings

@author:
I can also move those to a separate PR if that makes more sense