Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
d161de3
ENH: Add pan and zoom toolbar handling to 3D Axes
greglucas Mar 3, 2022
9fd9c66
Rewrite zooming with bounding box
scottshambaugh Jul 15, 2022
4b1d8cf
Rewrite 3d panning to work with a roll angle
scottshambaugh Jul 16, 2022
a6e4651
Whats new for zoom and pan buttons
scottshambaugh Jul 16, 2022
d7a3d6c
Make pan button configurable
scottshambaugh Jul 16, 2022
acbec0c
Do not jump when zooming and mouse goes over other subplot
scottshambaugh Jul 19, 2022
bb95903
Rework zooming for 3d plots
scottshambaugh Jul 19, 2022
88d616f
Handle x/y lock when zooming and panning
scottshambaugh Jul 19, 2022
5833512
Update tests
scottshambaugh Jul 19, 2022
b6fe8b0
Docstrings
scottshambaugh Jul 19, 2022
8a1e166
Dont assume a scale_z
scottshambaugh Jul 19, 2022
8f434d6
Limit zoom box
scottshambaugh Jul 20, 2022
da4fccc
Test zoom pan key modifiers
scottshambaugh Jul 20, 2022
695fd50
Save some calculation by saving view axes
scottshambaugh Jul 20, 2022
86f4f64
Deprecation warnings for Axes3D.eye, .vvec
scottshambaugh Jul 21, 2022
47f7000
Remove Axes3D._prepare_view_from_bbox for now
scottshambaugh Jul 22, 2022
1eb74b6
Comments and docstrings
scottshambaugh Jul 22, 2022
4a6020b
Switch from uvn to uvw
scottshambaugh Jul 22, 2022
11f6625
Save aspect to axes
scottshambaugh Aug 3, 2022
a1ac931
Merge branch 'aspect_equal_3d' into pan-zoom-3d
scottshambaugh Aug 3, 2022
4a54404
Constrain zooming with mouse when one of the equal aspect ratios is set
scottshambaugh Aug 3, 2022
e1f43f3
Cleanup
scottshambaugh Aug 3, 2022
33aef1c
Merge branch 'aspect_equal_3d' into pan-zoom-3d
scottshambaugh Aug 3, 2022
80211c9
Merge branch 'aspect_equal_3d' into pan-zoom-3d
scottshambaugh Aug 3, 2022
423cec8
Cleanup
scottshambaugh Aug 3, 2022
aa04186
Consolidate finding equal aspect axis indices
scottshambaugh Aug 3, 2022
59e9814
Merge branch 'matplotlib:main' into pan-zoom-3d
scottshambaugh Aug 3, 2022
9e50954
linting
scottshambaugh Aug 3, 2022
0c2864d
Merge branch 'main' into pan-zoom-3d
scottshambaugh Aug 27, 2022
d55706c
More intuitive scaling
scottshambaugh Aug 27, 2022
0008c02
Box zoom keeps existing aspect ratios
scottshambaugh Aug 27, 2022
caeff1b
Linting
scottshambaugh Aug 27, 2022
772b511
Code review comments
scottshambaugh Aug 30, 2022
1cdc5aa
Merge branch 'main' into pan-zoom-3d
scottshambaugh Sep 6, 2022
039b975
Revert parameters for view_transformation
scottshambaugh Sep 6, 2022
bb47e26
Fix new 3d pan/zoom view going on view stack twice
scottshambaugh Sep 6, 2022
f6a3d2e
Better clipping
scottshambaugh Sep 6, 2022
80b08a3
Test 3d toolbar navigation
scottshambaugh Sep 6, 2022
50defe5
Privatize helper functions
scottshambaugh Sep 6, 2022
9e60e62
Deprecations
scottshambaugh Sep 6, 2022
44a6d28
Code review changes
scottshambaugh Sep 8, 2022
7ac9f0a
Deprecation note
scottshambaugh Oct 4, 2022
c2c59e9
Merge branch 'main' into pan-zoom-3d
scottshambaugh Oct 4, 2022
a045f36
Undeprecate proj3d.view_transformation
scottshambaugh Oct 5, 2022
e759fba
Undeprecate proj3d.view_transformation
scottshambaugh Oct 5, 2022
ae8f6a1
Update doc/api/next_api_changes/deprecations/23449-SS.rst
scottshambaugh Oct 6, 2022
28fae53
Merge branch 'pan-zoom-3d' of github.com:scottshambaugh/matplotlib in…
scottshambaugh Oct 7, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions doc/users/next_whats_new/3d_plot_pan_zoom.rst
Original file line number Diff line number Diff line change
@@ -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,
Comment thread
scottshambaugh marked this conversation as resolved.
Resolved
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.
260 changes: 196 additions & 64 deletions lib/mpl_toolkits/mplot3d/axes3d.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Comment thread
scottshambaugh marked this conversation as resolved.
Resolved
'motion_notify_event', self._on_move)
self.figure.canvas.callbacks._connect_picklable(
'button_press_event', self._button_press)
Expand Down Expand Up @@ -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
Expand All @@ -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)
Comment thread
scottshambaugh marked this conversation as resolved.
Resolved

# 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)
Expand All @@ -895,14 +885,16 @@ 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.

Parameters
----------
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.
"""
Expand All @@ -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.
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Comment thread
scottshambaugh marked this conversation as resolved.
Resolved
# 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
Comment thread
scottshambaugh marked this conversation as resolved.
Resolved
# 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
Comment thread
scottshambaugh marked this conversation as resolved.
Resolved
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()
Comment thread
scottshambaugh marked this conversation as resolved.
Resolved

# 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):
"""
Expand Down
Loading