From 0da1b6c80ff067a158af07d5d65fc1e01efdd8cc Mon Sep 17 00:00:00 2001 From: Scott Shambaugh Date: Sat, 29 Apr 2023 01:16:15 -0600 Subject: [PATCH] Implement exact 3d plot limits Fix remaining large image diffs Fix remaining large image diffs Fix the rest of the tests Fix docs error Code review updates Code review updates Fix docs error One more constant Tweaks Keep fixing CI errors Docstrings Update baseline images More test images Images Test new scaling factor Test new scaling factor Test for exact limits Cleanup Tests Update doc/users/next_whats_new/3d_axis_limits.rst Co-authored-by: Elliott Sales de Andrade rebase fixes Apply suggestions from code review Co-authored-by: Oscar Gustafsson Code review cleanup Merge conflicts and better comments Merge conflicts and better comments --- doc/api/toolkits/mplot3d/axes3d.rst | 6 + doc/users/next_whats_new/3d_axis_limits.rst | 18 + lib/matplotlib/axis.py | 19 +- lib/matplotlib/mpl-data/matplotlibrc | 5 +- .../mpl-data/stylelib/classic.mplstyle | 3 +- lib/matplotlib/rcsetup.py | 6 +- lib/matplotlib/tests/test_collections.py | 1 + lib/matplotlib/ticker.py | 5 + lib/mpl_toolkits/mplot3d/axes3d.py | 466 +++++++++++++++--- lib/mpl_toolkits/mplot3d/axis3d.py | 37 +- lib/mpl_toolkits/mplot3d/tests/test_axes3d.py | 41 +- 11 files changed, 493 insertions(+), 114 deletions(-) create mode 100644 doc/users/next_whats_new/3d_axis_limits.rst diff --git a/doc/api/toolkits/mplot3d/axes3d.rst b/doc/api/toolkits/mplot3d/axes3d.rst index b581494e4883..f99074fd9c8a 100644 --- a/doc/api/toolkits/mplot3d/axes3d.rst +++ b/doc/api/toolkits/mplot3d/axes3d.rst @@ -92,12 +92,18 @@ Axis limits and direction get_zaxis get_xlim + set_xlim get_ylim + set_ylim get_zlim set_zlim get_w_lims invert_zaxis zaxis_inverted + get_xbound + set_xbound + get_ybound + set_ybound get_zbound set_zbound diff --git a/doc/users/next_whats_new/3d_axis_limits.rst b/doc/users/next_whats_new/3d_axis_limits.rst new file mode 100644 index 000000000000..7e7d6b2e5133 --- /dev/null +++ b/doc/users/next_whats_new/3d_axis_limits.rst @@ -0,0 +1,18 @@ +Setting 3D axis limits now set the limits exactly +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Previously, setting the limits of a 3D axis would always add a small margin to +the limits. Limits are now set exactly by default. The newly introduced rcparam +``axes3d.automargin`` can be used to revert to the old behavior where margin is +automatically added. + +.. plot:: + :include-source: true + :alt: Example of the new behavior of 3D axis limits, and how setting the rcparam reverts to the old behavior. + + import matplotlib.pyplot as plt + fig, axs = plt.subplots(1, 2, subplot_kw={'projection': '3d'}) + plt.rcParams['axes3d.automargin'] = False # the default in 3.9.0 + axs[0].set(xlim=(0, 1), ylim=(0, 1), zlim=(0, 1), title='New Behavior') + plt.rcParams['axes3d.automargin'] = True + axs[1].set(xlim=(0, 1), ylim=(0, 1), zlim=(0, 1), title='Old Behavior') diff --git a/lib/matplotlib/axis.py b/lib/matplotlib/axis.py index 0ace31916ca9..f0824dc4ed56 100644 --- a/lib/matplotlib/axis.py +++ b/lib/matplotlib/axis.py @@ -855,13 +855,14 @@ def _get_autoscale_on(self): def _set_autoscale_on(self, b): """ Set whether this Axis is autoscaled when drawing or by - `.Axes.autoscale_view`. + `.Axes.autoscale_view`. If b is None, then the value is not changed. Parameters ---------- b : bool """ - self._autoscale_on = b + if b is not None: + self._autoscale_on = b def get_children(self): return [self.label, self.offsetText, @@ -1244,8 +1245,7 @@ def _set_lim(self, v0, v1, *, emit=True, auto): # Mark viewlims as no longer stale without triggering an autoscale. for ax in self._get_shared_axes(): ax._stale_viewlims[name] = False - if auto is not None: - self._set_autoscale_on(bool(auto)) + self._set_autoscale_on(auto) if emit: self.axes.callbacks.process(f"{name}lim_changed", self.axes) @@ -1292,6 +1292,17 @@ def _update_ticks(self): if view_low > view_high: view_low, view_high = view_high, view_low + if (hasattr(self, "axes") and self.axes.name == '3d' + and mpl.rcParams['axes3d.automargin']): + # In mpl3.8, the margin was 1/48. Due to the change in automargin + # behavior in mpl3.9, we need to adjust this to compensate for a + # zoom factor of 2/48, giving us a 23/24 modifier. So the new + # margin is 0.019965277777777776 = 1/48*23/24. + margin = 0.019965277777777776 + delta = view_high - view_low + view_high = view_high - delta * margin + view_low = view_low + delta * margin + interval_t = self.get_transform().transform([view_low, view_high]) ticks_to_draw = [] diff --git a/lib/matplotlib/mpl-data/matplotlibrc b/lib/matplotlib/mpl-data/matplotlibrc index 2c53651da3d6..a097c98429d1 100644 --- a/lib/matplotlib/mpl-data/matplotlibrc +++ b/lib/matplotlib/mpl-data/matplotlibrc @@ -425,8 +425,9 @@ #axes.autolimit_mode: data # If "data", use axes.xmargin and axes.ymargin as is. # If "round_numbers", after application of margins, axis # limits are further expanded to the nearest "round" number. -#polaraxes.grid: True # display grid on polar axes -#axes3d.grid: True # display grid on 3D axes +#polaraxes.grid: True # display grid on polar axes +#axes3d.grid: True # display grid on 3D axes +#axes3d.automargin: False # automatically add margin when manually setting 3D axis limits #axes3d.xaxis.panecolor: (0.95, 0.95, 0.95, 0.5) # background pane on 3D axes #axes3d.yaxis.panecolor: (0.90, 0.90, 0.90, 0.5) # background pane on 3D axes diff --git a/lib/matplotlib/mpl-data/stylelib/classic.mplstyle b/lib/matplotlib/mpl-data/stylelib/classic.mplstyle index 09a38df282f1..8923ce6f0497 100644 --- a/lib/matplotlib/mpl-data/stylelib/classic.mplstyle +++ b/lib/matplotlib/mpl-data/stylelib/classic.mplstyle @@ -223,7 +223,8 @@ axes.spines.left : True axes.spines.right : True axes.spines.top : True polaraxes.grid : True # display grid on polar axes -axes3d.grid : True # display grid on 3d axes +axes3d.grid : True # display grid on 3D axes +axes3d.automargin : False # automatically add margin when manually setting 3D axis limits date.autoformatter.year : %Y date.autoformatter.month : %b %Y diff --git a/lib/matplotlib/rcsetup.py b/lib/matplotlib/rcsetup.py index 276bb9f812a9..dda293f89401 100644 --- a/lib/matplotlib/rcsetup.py +++ b/lib/matplotlib/rcsetup.py @@ -1094,8 +1094,10 @@ def _convert_validator_spec(key, conv): "axes.ymargin": _validate_greaterthan_minushalf, # margin added to yaxis "axes.zmargin": _validate_greaterthan_minushalf, # margin added to zaxis - "polaraxes.grid": validate_bool, # display polar grid or not - "axes3d.grid": validate_bool, # display 3d grid + "polaraxes.grid": validate_bool, # display polar grid or not + "axes3d.grid": validate_bool, # display 3d grid + "axes3d.automargin": validate_bool, # automatically add margin when + # manually setting 3D axis limits "axes3d.xaxis.panecolor": validate_color, # 3d background pane "axes3d.yaxis.panecolor": validate_color, # 3d background pane diff --git a/lib/matplotlib/tests/test_collections.py b/lib/matplotlib/tests/test_collections.py index 43bbea34a2e5..1f7c104a4c07 100644 --- a/lib/matplotlib/tests/test_collections.py +++ b/lib/matplotlib/tests/test_collections.py @@ -401,6 +401,7 @@ def test_EllipseCollection(): @image_comparison(['polycollection_close.png'], remove_text=True, style='mpl20') def test_polycollection_close(): from mpl_toolkits.mplot3d import Axes3D # type: ignore + plt.rcParams['axes3d.automargin'] = True vertsQuad = [ [[0., 0.], [0., 1.], [1., 1.], [1., 0.]], diff --git a/lib/matplotlib/ticker.py b/lib/matplotlib/ticker.py index 8dc79d4c0f30..d6a48e9bb637 100644 --- a/lib/matplotlib/ticker.py +++ b/lib/matplotlib/ticker.py @@ -2106,6 +2106,11 @@ def _raw_ticks(self, vmin, vmax): steps = steps[igood] raw_step = ((_vmax - _vmin) / nbins) + if hasattr(self.axis, "axes") and self.axis.axes.name == '3d': + # Due to the change in automargin behavior in mpl3.9, we need to + # adjust the raw step to match the mpl3.8 appearance. The zoom + # factor of 2/48, gives us the 23/24 modifier. + raw_step = raw_step * 23/24 large_steps = steps >= raw_step if mpl.rcParams['axes.autolimit_mode'] == 'round_numbers': # Classic round_numbers mode may require a larger step. diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index e7abdc0767b5..b995b5a2a057 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -21,7 +21,6 @@ import matplotlib as mpl from matplotlib import _api, cbook, _docstring, _preprocess_data import matplotlib.artist as martist -import matplotlib.axes as maxes import matplotlib.collections as mcoll import matplotlib.colors as mcolors import matplotlib.image as mimage @@ -133,7 +132,9 @@ def __init__( self.xy_viewLim = Bbox.unit() self.zz_viewLim = Bbox.unit() - self.xy_dataLim = Bbox.unit() + xymargin = 0.05 * 10/11 # match mpl3.8 appearance + self.xy_dataLim = Bbox([[xymargin, xymargin], + [1 - xymargin, 1 - xymargin]]) # z-limits are encoded in the x-component of the Bbox, y is un-used self.zz_dataLim = Bbox.unit() @@ -166,6 +167,9 @@ def __init__( self.M = None self.invM = None + self._view_margin = 1/48 # default value to match mpl3.8 + self.autoscale_view() + # func used to format z -- fall back on major formatters self.fmt_zdata = None @@ -345,7 +349,8 @@ def set_aspect(self, aspect, adjustable=None, anchor=None, share=False): self.set_ylim3d, self.set_zlim3d)): if i in ax_indices: - set_lim(mean[i] - deltas[i]/2., mean[i] + deltas[i]/2.) + set_lim(mean[i] - deltas[i]/2., mean[i] + deltas[i]/2., + auto=True, view_margin=None) else: # 'box' # Change the box aspect such that the ratio of the length of # the unmodified axis to the length of the diagonal @@ -413,8 +418,11 @@ def set_box_aspect(self, aspect, *, zoom=1): else: aspect = np.asarray(aspect, dtype=float) _api.check_shape((3,), aspect=aspect) - # default scale tuned to match the mpl32 appearance. - aspect *= 1.8294640721620434 * zoom / np.linalg.norm(aspect) + # The scale 1.8294640721620434 is tuned to match the mpl3.2 appearance. + # The 25/24 factor is to compensate for the change in automargin + # behavior in mpl3.9. This comes from the padding of 1/48 on both sides + # of the axes in mpl3.8. + aspect *= 1.8294640721620434 * 25/24 * zoom / np.linalg.norm(aspect) self._box_aspect = aspect self.stale = True @@ -601,17 +609,17 @@ def autoscale(self, enable=True, axis='both', tight=None): scalez = True else: if axis in ['x', 'both']: - self.set_autoscalex_on(bool(enable)) + self.set_autoscalex_on(enable) scalex = self.get_autoscalex_on() else: scalex = False if axis in ['y', 'both']: - self.set_autoscaley_on(bool(enable)) + self.set_autoscaley_on(enable) scaley = self.get_autoscaley_on() else: scaley = False if axis in ['z', 'both']: - self.set_autoscalez_on(bool(enable)) + self.set_autoscalez_on(enable) scalez = self.get_autoscalez_on() else: scalez = False @@ -636,8 +644,8 @@ def auto_scale_xyz(self, X, Y, Z=None, had_data=None): # Let autoscale_view figure out how to use this data. self.autoscale_view() - def autoscale_view(self, tight=None, scalex=True, scaley=True, - scalez=True): + def autoscale_view(self, tight=None, + scalex=True, scaley=True, scalez=True): """ Autoscale the view limits using the data limits. @@ -669,7 +677,7 @@ def autoscale_view(self, tight=None, scalex=True, scaley=True, x1 += delta if not _tight: x0, x1 = xlocator.view_limits(x0, x1) - self.set_xbound(x0, x1) + self.set_xbound(x0, x1, self._view_margin) if scaley and self.get_autoscaley_on(): y0, y1 = self.xy_dataLim.intervaly @@ -681,7 +689,7 @@ def autoscale_view(self, tight=None, scalex=True, scaley=True, y1 += delta if not _tight: y0, y1 = ylocator.view_limits(y0, y1) - self.set_ybound(y0, y1) + self.set_ybound(y0, y1, self._view_margin) if scalez and self.get_autoscalez_on(): z0, z1 = self.zz_dataLim.intervalx @@ -693,7 +701,7 @@ def autoscale_view(self, tight=None, scalex=True, scaley=True, z1 += delta if not _tight: z0, z1 = zlocator.view_limits(z0, z1) - self.set_zbound(z0, z1) + self.set_zbound(z0, z1, self._view_margin) def get_w_lims(self): """Get 3D world limits.""" @@ -702,28 +710,347 @@ def get_w_lims(self): minz, maxz = self.get_zlim3d() return minx, maxx, miny, maxy, minz, maxz - # set_xlim, set_ylim are directly inherited from base Axes. + def _set_bound3d(self, get_bound, set_lim, axis_inverted, + lower=None, upper=None, view_margin=None): + """ + Set 3D axis bounds. + """ + if upper is None and np.iterable(lower): + lower, upper = lower + + old_lower, old_upper = get_bound() + if lower is None: + lower = old_lower + if upper is None: + upper = old_upper + + set_lim(sorted((lower, upper), reverse=bool(axis_inverted())), + auto=None, view_margin=view_margin) + + def set_xbound(self, lower=None, upper=None, view_margin=None): + """ + Set the lower and upper numerical bounds of the x-axis. + + This method will honor axis inversion regardless of parameter order. + It will not change the autoscaling setting (`.get_autoscalex_on()`). + + Parameters + ---------- + lower, upper : float or None + The lower and upper bounds. If *None*, the respective axis bound + is not modified. + view_margin : float or None + The margin to apply to the bounds. If *None*, the margin is handled + by `.set_xlim`. + + See Also + -------- + get_xbound + get_xlim, set_xlim + invert_xaxis, xaxis_inverted + """ + self._set_bound3d(self.get_xbound, self.set_xlim, self.xaxis_inverted, + lower, upper, view_margin) + + def set_ybound(self, lower=None, upper=None, view_margin=None): + """ + Set the lower and upper numerical bounds of the y-axis. + + This method will honor axis inversion regardless of parameter order. + It will not change the autoscaling setting (`.get_autoscaley_on()`). + + Parameters + ---------- + lower, upper : float or None + The lower and upper bounds. If *None*, the respective axis bound + is not modified. + view_margin : float or None + The margin to apply to the bounds. If *None*, the margin is handled + by `.set_ylim`. + + See Also + -------- + get_ybound + get_ylim, set_ylim + invert_yaxis, yaxis_inverted + """ + self._set_bound3d(self.get_ybound, self.set_ylim, self.yaxis_inverted, + lower, upper, view_margin) + + def set_zbound(self, lower=None, upper=None, view_margin=None): + """ + Set the lower and upper numerical bounds of the z-axis. + This method will honor axis inversion regardless of parameter order. + It will not change the autoscaling setting (`.get_autoscaley_on()`). + + Parameters + ---------- + lower, upper : float or None + The lower and upper bounds. If *None*, the respective axis bound + is not modified. + view_margin : float or None + The margin to apply to the bounds. If *None*, the margin is handled + by `.set_zlim`. + + See Also + -------- + get_zbound + get_zlim, set_zlim + invert_zaxis, zaxis_inverted + """ + self._set_bound3d(self.get_zbound, self.set_zlim, self.zaxis_inverted, + lower, upper, view_margin) + + def _set_lim3d(self, axis, lower=None, upper=None, *, emit=True, + auto=False, view_margin=None, axmin=None, axmax=None): + """ + Set 3D axis limits. + """ + if upper is None: + if np.iterable(lower): + lower, upper = lower + elif axmax is None: + upper = axis.get_view_interval()[1] + if lower is None and axmin is None: + lower = axis.get_view_interval()[0] + if axmin is not None: + if lower is not None: + raise TypeError("Cannot pass both 'lower' and 'min'") + lower = axmin + if axmax is not None: + if upper is not None: + raise TypeError("Cannot pass both 'upper' and 'max'") + upper = axmax + if np.isinf(lower) or np.isinf(upper): + raise ValueError(f"Axis limits {lower}, {upper} cannot be infinite") + if view_margin is None: + if mpl.rcParams['axes3d.automargin']: + view_margin = self._view_margin + else: + view_margin = 0 + delta = (upper - lower) * view_margin + lower -= delta + upper += delta + return axis._set_lim(lower, upper, emit=emit, auto=auto) + + def set_xlim(self, left=None, right=None, *, emit=True, auto=False, + view_margin=None, xmin=None, xmax=None): + """ + Set the 3D x-axis view limits. + + Parameters + ---------- + left : float, optional + The left xlim in data coordinates. Passing *None* leaves the + limit unchanged. + + The left and right xlims may also be passed as the tuple + (*left*, *right*) as the first positional argument (or as + the *left* keyword argument). + + .. ACCEPTS: (left: float, right: float) + + right : float, optional + The right xlim in data coordinates. Passing *None* leaves the + limit unchanged. + + emit : bool, default: True + Whether to notify observers of limit change. + + auto : bool or None, default: False + Whether to turn on autoscaling of the x-axis. *True* turns on, + *False* turns off, *None* leaves unchanged. + + view_margin : float, optional + The additional margin to apply to the limits. + + xmin, xmax : float, optional + They are equivalent to left and right respectively, and it is an + error to pass both *xmin* and *left* or *xmax* and *right*. + + Returns + ------- + left, right : (float, float) + The new x-axis limits in data coordinates. + + See Also + -------- + get_xlim + set_xbound, get_xbound + invert_xaxis, xaxis_inverted + + Notes + ----- + The *left* value may be greater than the *right* value, in which + case the x-axis values will decrease from *left* to *right*. + + Examples + -------- + >>> set_xlim(left, right) + >>> set_xlim((left, right)) + >>> left, right = set_xlim(left, right) + + One limit may be left unchanged. + + >>> set_xlim(right=right_lim) + + Limits may be passed in reverse order to flip the direction of + the x-axis. For example, suppose ``x`` represents depth of the + ocean in m. The x-axis limits might be set like the following + so 5000 m depth is at the left of the plot and the surface, + 0 m, is at the right. + + >>> set_xlim(5000, 0) + """ + return self._set_lim3d(self.xaxis, left, right, emit=emit, auto=auto, + view_margin=view_margin, axmin=xmin, axmax=xmax) + + def set_ylim(self, bottom=None, top=None, *, emit=True, auto=False, + view_margin=None, ymin=None, ymax=None): + """ + Set the 3D y-axis view limits. + + Parameters + ---------- + bottom : float, optional + The bottom ylim in data coordinates. Passing *None* leaves the + limit unchanged. + + The bottom and top ylims may also be passed as the tuple + (*bottom*, *top*) as the first positional argument (or as + the *bottom* keyword argument). + + .. ACCEPTS: (bottom: float, top: float) + + top : float, optional + The top ylim in data coordinates. Passing *None* leaves the + limit unchanged. + + emit : bool, default: True + Whether to notify observers of limit change. + + auto : bool or None, default: False + Whether to turn on autoscaling of the y-axis. *True* turns on, + *False* turns off, *None* leaves unchanged. + + view_margin : float, optional + The additional margin to apply to the limits. + + ymin, ymax : float, optional + They are equivalent to bottom and top respectively, and it is an + error to pass both *ymin* and *bottom* or *ymax* and *top*. + + Returns + ------- + bottom, top : (float, float) + The new y-axis limits in data coordinates. + + See Also + -------- + get_ylim + set_ybound, get_ybound + invert_yaxis, yaxis_inverted + + Notes + ----- + The *bottom* value may be greater than the *top* value, in which + case the y-axis values will decrease from *bottom* to *top*. + + Examples + -------- + >>> set_ylim(bottom, top) + >>> set_ylim((bottom, top)) + >>> bottom, top = set_ylim(bottom, top) + + One limit may be left unchanged. + + >>> set_ylim(top=top_lim) + + Limits may be passed in reverse order to flip the direction of + the y-axis. For example, suppose ``y`` represents depth of the + ocean in m. The y-axis limits might be set like the following + so 5000 m depth is at the bottom of the plot and the surface, + 0 m, is at the top. + + >>> set_ylim(5000, 0) + """ + return self._set_lim3d(self.yaxis, bottom, top, emit=emit, auto=auto, + view_margin=view_margin, axmin=ymin, axmax=ymax) + def set_zlim(self, bottom=None, top=None, *, emit=True, auto=False, - zmin=None, zmax=None): - """ - Set 3D z limits. - - See `.Axes.set_ylim` for full documentation - """ - if top is None and np.iterable(bottom): - bottom, top = bottom - if zmin is not None: - if bottom is not None: - raise TypeError("Cannot pass both 'bottom' and 'zmin'") - bottom = zmin - if zmax is not None: - if top is not None: - raise TypeError("Cannot pass both 'top' and 'zmax'") - top = zmax - return self.zaxis._set_lim(bottom, top, emit=emit, auto=auto) - - set_xlim3d = maxes.Axes.set_xlim - set_ylim3d = maxes.Axes.set_ylim + view_margin=None, zmin=None, zmax=None): + """ + Set the 3D z-axis view limits. + + Parameters + ---------- + bottom : float, optional + The bottom zlim in data coordinates. Passing *None* leaves the + limit unchanged. + + The bottom and top zlims may also be passed as the tuple + (*bottom*, *top*) as the first positional argument (or as + the *bottom* keyword argument). + + .. ACCEPTS: (bottom: float, top: float) + + top : float, optional + The top zlim in data coordinates. Passing *None* leaves the + limit unchanged. + + emit : bool, default: True + Whether to notify observers of limit change. + + auto : bool or None, default: False + Whether to turn on autoscaling of the z-axis. *True* turns on, + *False* turns off, *None* leaves unchanged. + + view_margin : float, optional + The additional margin to apply to the limits. + + zmin, zmax : float, optional + They are equivalent to bottom and top respectively, and it is an + error to pass both *zmin* and *bottom* or *zmax* and *top*. + + Returns + ------- + bottom, top : (float, float) + The new z-axis limits in data coordinates. + + See Also + -------- + get_zlim + set_zbound, get_zbound + invert_zaxis, zaxis_inverted + + Notes + ----- + The *bottom* value may be greater than the *top* value, in which + case the z-axis values will decrease from *bottom* to *top*. + + Examples + -------- + >>> set_zlim(bottom, top) + >>> set_zlim((bottom, top)) + >>> bottom, top = set_zlim(bottom, top) + + One limit may be left unchanged. + + >>> set_zlim(top=top_lim) + + Limits may be passed in reverse order to flip the direction of + the z-axis. For example, suppose ``z`` represents depth of the + ocean in m. The z-axis limits might be set like the following + so 5000 m depth is at the bottom of the plot and the surface, + 0 m, is at the top. + + >>> set_zlim(5000, 0) + """ + return self._set_lim3d(self.zaxis, bottom, top, emit=emit, auto=auto, + view_margin=view_margin, axmin=zmin, axmax=zmax) + + set_xlim3d = set_xlim + set_ylim3d = set_ylim set_zlim3d = set_zlim def get_xlim(self): @@ -1049,6 +1376,15 @@ def clear(self): self._zmargin = mpl.rcParams['axes.zmargin'] else: self._zmargin = 0. + + xymargin = 0.05 * 10/11 # match mpl3.8 appearance + self.xy_dataLim = Bbox([[xymargin, xymargin], + [1 - xymargin, 1 - xymargin]]) + # z-limits are encoded in the x-component of the Bbox, y is un-used + self.zz_dataLim = Bbox.unit() + self._view_margin = 1/48 # default value to match mpl3.8 + self.autoscale_view() + self.grid(mpl.rcParams['axes3d.grid']) def _button_press(self, event): @@ -1177,7 +1513,7 @@ def _calc_coord(self, xv, yv, renderer=None): # Get the pane locations for each of the axes pane_locs = [] for axis in self._axis_map.values(): - xys, loc = axis.active_pane(renderer) + xys, loc = axis.active_pane() pane_locs.append(loc) # Find the distance to the nearest pane by projecting the view vector @@ -1289,9 +1625,9 @@ def drag_pan(self, button, key, x, y): dz = (maxz - minz) * duvw_projected[2] # Set the new axis limits - self.set_xlim3d(minx + dx, maxx + dx) - self.set_ylim3d(miny + dy, maxy + dy) - self.set_zlim3d(minz + dz, maxz + dz) + self.set_xlim3d(minx + dx, maxx + dx, auto=None) + self.set_ylim3d(miny + dy, maxy + dy, auto=None) + self.set_zlim3d(minz + dz, maxz + dz, auto=None) def _calc_view_axes(self, eye): """ @@ -1420,9 +1756,9 @@ def _scale_axis_limits(self, scale_x, scale_y, scale_z): cx, cy, cz, dx, dy, dz = self._get_w_centers_ranges() # Set the scaled axis limits - self.set_xlim3d(cx - dx*scale_x/2, cx + dx*scale_x/2) - self.set_ylim3d(cy - dy*scale_y/2, cy + dy*scale_y/2) - self.set_zlim3d(cz - dz*scale_z/2, cz + dz*scale_z/2) + self.set_xlim3d(cx - dx*scale_x/2, cx + dx*scale_x/2, auto=None) + self.set_ylim3d(cy - dy*scale_y/2, cy + dy*scale_y/2, auto=None) + self.set_zlim3d(cz - dz*scale_z/2, cz + dz*scale_z/2, auto=None) def _get_w_centers_ranges(self): """Get 3D world centers and axis ranges.""" @@ -1531,43 +1867,11 @@ def get_zbound(self): get_zlim, set_zlim invert_zaxis, zaxis_inverted """ - bottom, top = self.get_zlim() - if bottom < top: - return bottom, top + lower, upper = self.get_zlim() + if lower < upper: + return lower, upper else: - return top, bottom - - def set_zbound(self, lower=None, upper=None): - """ - Set the lower and upper numerical bounds of the z-axis. - - This method will honor axes inversion regardless of parameter order. - It will not change the autoscaling setting (`.get_autoscalez_on()`). - - Parameters - ---------- - lower, upper : float or None - The lower and upper bounds. If *None*, the respective axis bound - is not modified. - - See Also - -------- - get_zbound - get_zlim, set_zlim - invert_zaxis, zaxis_inverted - """ - if upper is None and np.iterable(lower): - lower, upper = lower - - old_lower, old_upper = self.get_zbound() - if lower is None: - lower = old_lower - if upper is None: - upper = old_upper - - self.set_zlim(sorted((lower, upper), - reverse=bool(self.zaxis_inverted())), - auto=None) + return upper, lower def text(self, x, y, z, s, zdir=None, **kwargs): """ @@ -3052,10 +3356,10 @@ def errorbar(self, x, y, z, zerr=None, yerr=None, xerr=None, fmt='', lower limits. In that case a caret symbol is used to indicate this. *lims*-arguments may be scalars, or array-likes of the same length as the errors. To use limits with inverted axes, - `~.Axes.set_xlim` or `~.Axes.set_ylim` must be called before - `errorbar`. Note the tricky parameter names: setting e.g. - *ylolims* to True means that the y-value is a *lower* limit of the - True value, so, only an *upward*-pointing arrow will be drawn! + `~.set_xlim`, `~.set_ylim`, or `~.set_zlim` must be + called before `errorbar`. Note the tricky parameter names: setting + e.g. *ylolims* to True means that the y-value is a *lower* limit of + the True value, so, only an *upward*-pointing arrow will be drawn! xuplims, yuplims, zuplims : bool, default: False Same as above, but for controlling the upper limits. diff --git a/lib/mpl_toolkits/mplot3d/axis3d.py b/lib/mpl_toolkits/mplot3d/axis3d.py index 58792deae963..5c102437db85 100644 --- a/lib/mpl_toolkits/mplot3d/axis3d.py +++ b/lib/mpl_toolkits/mplot3d/axis3d.py @@ -276,21 +276,13 @@ def get_rotate_label(self, text): else: return len(text) > 4 - def _get_coord_info(self, renderer): + def _get_coord_info(self): mins, maxs = np.array([ self.axes.get_xbound(), self.axes.get_ybound(), self.axes.get_zbound(), ]).T - # Get the mean value for each bound: - centers = 0.5 * (maxs + mins) - - # Add a small offset between min/max point and the edge of the plot: - deltas = (maxs - mins) / 12 - mins -= 0.25 * deltas - maxs += 0.25 * deltas - # Project the bounds along the current position of the cube: bounds = mins[0], maxs[0], mins[1], maxs[1], mins[2], maxs[2] bounds_proj = self.axes._tunit_cube(bounds, self.axes.M) @@ -314,7 +306,17 @@ def _get_coord_info(self, renderer): elif vertical == 0: # looking at YZ plane highs = np.array([highs[0], False, False]) - return mins, maxs, centers, deltas, bounds_proj, highs + return mins, maxs, bounds_proj, highs + + def _calc_centers_deltas(self, maxs, mins): + centers = 0.5 * (maxs + mins) + # In mpl3.8, the scale factor was 1/12. mpl3.9 changes this to + # 1/12 * 24/25 = 0.08 to compensate for the change in automargin + # behavior and keep appearance the same. The 24/25 factor is from the + # 1/48 padding added to each side of the axis in mpl3.8. + scale = 0.08 + deltas = (maxs - mins) * scale + return centers, deltas def _get_axis_line_edge_points(self, minmax, maxmin, position=None): """Get the edge points for the black bolded axis line.""" @@ -409,8 +411,8 @@ def _get_tickdir(self, position): tickdir = np.roll(info_i, -j)[np.roll(tickdirs_base, j)][i] return tickdir - def active_pane(self, renderer): - mins, maxs, centers, deltas, tc, highs = self._get_coord_info(renderer) + def active_pane(self): + mins, maxs, tc, highs = self._get_coord_info() info = self._axinfo index = info['i'] if not highs[index]: @@ -431,7 +433,7 @@ def draw_pane(self, renderer): renderer : `~matplotlib.backend_bases.RendererBase` subclass """ renderer.open_group('pane3d', gid=self.get_gid()) - xys, loc = self.active_pane(renderer) + xys, loc = self.active_pane() self.pane.xy = xys[:, :2] self.pane.draw(renderer) renderer.close_group('pane3d') @@ -446,6 +448,10 @@ def _draw_ticks(self, renderer, edgep1, centers, deltas, highs, ticks = self._update_ticks() info = self._axinfo index = info["i"] + juggled = info["juggled"] + + mins, maxs, tc, highs = self._get_coord_info() + centers, deltas = self._calc_centers_deltas(maxs, mins) # Draw ticks: tickdir = self._get_tickdir(pos) @@ -575,7 +581,8 @@ def draw(self, renderer): renderer.open_group("axis3d", gid=self.get_gid()) # Get general axis information: - mins, maxs, centers, deltas, tc, highs = self._get_coord_info(renderer) + mins, maxs, tc, highs = self._get_coord_info() + centers, deltas = self._calc_centers_deltas(maxs, mins) # Calculate offset distances # A rough estimate; points are ambiguous since 3D plots rotate @@ -645,7 +652,7 @@ def draw_grid(self, renderer): info = self._axinfo index = info["i"] - mins, maxs, _, _, _, highs = self._get_coord_info(renderer) + mins, maxs, tc, highs = self._get_coord_info() minmax = np.where(highs, maxs, mins) maxmin = np.where(~highs, maxs, mins) diff --git a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py index df9f2ae52fd7..75f0e2261907 100644 --- a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py +++ b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py @@ -222,6 +222,7 @@ def test_bar3d_lightsource(): ['contour3d.png'], style='mpl20', tol=0.002 if platform.machine() in ('aarch64', 'ppc64le', 's390x') else 0) def test_contour3d(): + plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated fig = plt.figure() ax = fig.add_subplot(projection='3d') X, Y, Z = axes3d.get_test_data(0.05) @@ -233,6 +234,7 @@ def test_contour3d(): @mpl3d_image_comparison(['contour3d_extend3d.png'], style='mpl20') def test_contour3d_extend3d(): + plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated fig = plt.figure() ax = fig.add_subplot(projection='3d') X, Y, Z = axes3d.get_test_data(0.05) @@ -244,6 +246,7 @@ def test_contour3d_extend3d(): @mpl3d_image_comparison(['contourf3d.png'], style='mpl20') def test_contourf3d(): + plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated fig = plt.figure() ax = fig.add_subplot(projection='3d') X, Y, Z = axes3d.get_test_data(0.05) @@ -257,6 +260,7 @@ def test_contourf3d(): @mpl3d_image_comparison(['contourf3d_fill.png'], style='mpl20') def test_contourf3d_fill(): + plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated fig = plt.figure() ax = fig.add_subplot(projection='3d') X, Y = np.meshgrid(np.arange(-2, 2, 0.25), np.arange(-2, 2, 0.25)) @@ -300,6 +304,7 @@ def test_contourf3d_extend(fig_test, fig_ref, extend, levels): @mpl3d_image_comparison(['tricontour.png'], tol=0.02, style='mpl20') def test_tricontour(): + plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated fig = plt.figure() np.random.seed(19680801) @@ -369,6 +374,7 @@ def f(t): t1 = np.arange(0.0, 5.0, 0.1) t2 = np.arange(0.0, 5.0, 0.02) + plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated fig = plt.figure(figsize=plt.figaspect(2.)) ax = fig.add_subplot(2, 1, 1) ax.plot(t1, f(t1), 'bo', t2, f(t2), 'k--', markerfacecolor='green') @@ -400,6 +406,7 @@ def test_tight_layout_text(fig_test, fig_ref): @mpl3d_image_comparison(['scatter3d.png'], style='mpl20') def test_scatter3d(): + plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated fig = plt.figure() ax = fig.add_subplot(projection='3d') ax.scatter(np.arange(10), np.arange(10), np.arange(10), @@ -413,6 +420,7 @@ def test_scatter3d(): @mpl3d_image_comparison(['scatter3d_color.png'], style='mpl20') def test_scatter3d_color(): + plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated fig = plt.figure() ax = fig.add_subplot(projection='3d') @@ -597,12 +605,14 @@ def test_surface3d(): Z = np.sin(R) surf = ax.plot_surface(X, Y, Z, rcount=40, ccount=40, cmap=cm.coolwarm, lw=0, antialiased=False) + plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated ax.set_zlim(-1.01, 1.01) fig.colorbar(surf, shrink=0.5, aspect=5) @image_comparison(['surface3d_label_offset_tick_position.png'], style='mpl20') def test_surface3d_label_offset_tick_position(): + plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated ax = plt.figure().add_subplot(projection="3d") x, y = np.mgrid[0:6 * np.pi:0.25, 0:4 * np.pi:0.25] @@ -627,6 +637,7 @@ def test_surface3d_shaded(): Z = np.sin(R) ax.plot_surface(X, Y, Z, rstride=5, cstride=5, color=[0.25, 1, 0.25], lw=1, antialiased=False) + plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated ax.set_zlim(-1.01, 1.01) @@ -695,6 +706,7 @@ def test_text3d(): ax.text(1, 1, 1, "red", color='red') ax.text2D(0.05, 0.95, "2D Text", transform=ax.transAxes) + plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated ax.set_xlim3d(0, 10) ax.set_ylim3d(0, 10) ax.set_zlim3d(0, 10) @@ -808,6 +820,7 @@ def test_mixedsamplesraises(): @mpl3d_image_comparison(['quiver3d.png'], style='mpl20') def test_quiver3d(): + plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated fig = plt.figure() ax = fig.add_subplot(projection='3d') pivots = ['tip', 'middle', 'tail'] @@ -976,6 +989,7 @@ def test_add_collection3d_zs_array(): assert line is not None + plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated ax.set_xlim(-5, 5) ax.set_ylim(-4, 6) ax.set_zlim(-2, 2) @@ -1002,6 +1016,7 @@ def test_add_collection3d_zs_scalar(): assert line is not None + plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated ax.set_xlim(-5, 5) ax.set_ylim(-4, 6) ax.set_zlim(0, 2) @@ -1027,7 +1042,7 @@ def test_axes3d_labelpad(): # Tick labels also respect tick.pad (also from rcParams) for i, tick in enumerate(ax.yaxis.get_major_ticks()): - tick.set_pad(tick.get_pad() - i * 5) + tick.set_pad(tick.get_pad() + 5 - i * 5) @mpl3d_image_comparison(['axes3d_cla.png'], remove_text=False, style='mpl20') @@ -1123,6 +1138,7 @@ def test_proj_axes_cube(): for x, y, t in zip(txs, tys, ts): ax.text(x, y, t) + plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated ax.set_xlim(-0.2, 0.2) ax.set_ylim(-0.2, 0.2) @@ -1152,6 +1168,7 @@ def test_proj_axes_cube_ortho(): for x, y, t in zip(txs, tys, ts): ax.text(x, y, t) + plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated ax.set_xlim(-200, 200) ax.set_ylim(-200, 200) @@ -1171,6 +1188,7 @@ def test_world(): def test_autoscale(): fig, ax = plt.subplots(subplot_kw={"projection": "3d"}) assert ax.get_zscale() == 'linear' + ax._view_margin = 0 ax.margins(x=0, y=.1, z=.2) ax.plot([0, 1], [0, 1], [0, 1]) assert ax.get_w_lims() == (0, 1, -.1, 1.1, -.2, 1.2) @@ -1555,6 +1573,7 @@ def test_errorbar3d(): @image_comparison(['stem3d.png'], style='mpl20', tol=0.003) def test_stem3d(): + plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated fig, axs = plt.subplots(2, 3, figsize=(8, 6), constrained_layout=True, subplot_kw={'projection': '3d'}) @@ -1639,6 +1658,7 @@ def test_colorbar_pos(): def test_inverted_zaxis(): fig = plt.figure() ax = fig.add_subplot(projection='3d') + ax.set_zlim(0, 1) assert not ax.zaxis_inverted() assert ax.get_zlim() == (0, 1) assert ax.get_zbound() == (0, 1) @@ -1671,17 +1691,17 @@ def test_inverted_zaxis(): def test_set_zlim(): fig = plt.figure() ax = fig.add_subplot(projection='3d') - assert ax.get_zlim() == (0, 1) + assert np.allclose(ax.get_zlim(), (-1/48, 49/48)) ax.set_zlim(zmax=2) - assert ax.get_zlim() == (0, 2) + assert np.allclose(ax.get_zlim(), (-1/48, 2)) ax.set_zlim(zmin=1) assert ax.get_zlim() == (1, 2) with pytest.raises( - TypeError, match="Cannot pass both 'bottom' and 'zmin'"): + TypeError, match="Cannot pass both 'lower' and 'min'"): ax.set_zlim(bottom=0, zmin=1) with pytest.raises( - TypeError, match="Cannot pass both 'top' and 'zmax'"): + TypeError, match="Cannot pass both 'upper' and 'max'"): ax.set_zlim(top=0, zmax=1) @@ -1755,13 +1775,13 @@ def convert_lim(dmin, dmax): ("zoom", MouseButton.LEFT, 'x', # zoom in ((-0.01, 0.10), (-0.03, 0.08), (-0.06, 0.06))), ("zoom", MouseButton.LEFT, 'y', # zoom in - ((-0.07, 0.04), (-0.03, 0.08), (0.00, 0.11))), + ((-0.07, 0.05), (-0.04, 0.08), (0.00, 0.12))), ("zoom", MouseButton.RIGHT, None, # zoom out - ((-0.09, 0.15), (-0.07, 0.17), (-0.06, 0.18))), + ((-0.09, 0.15), (-0.08, 0.17), (-0.07, 0.18))), ("pan", MouseButton.LEFT, None, - ((-0.70, -0.58), (-1.03, -0.91), (-1.27, -1.15))), + ((-0.70, -0.58), (-1.04, -0.91), (-1.27, -1.15))), ("pan", MouseButton.LEFT, 'x', - ((-0.96, -0.84), (-0.58, -0.46), (-0.06, 0.06))), + ((-0.97, -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): @@ -1859,6 +1879,7 @@ def test_subfigure_simple(): @image_comparison(baseline_images=['computed_zorder'], remove_text=True, extensions=['png'], style=('mpl20')) def test_computed_zorder(): + plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated fig = plt.figure() ax1 = fig.add_subplot(221, projection='3d') ax2 = fig.add_subplot(222, projection='3d') @@ -2063,6 +2084,7 @@ def test_pathpatch_3d(fig_test, fig_ref): remove_text=True, style='mpl20') def test_scatter_spiral(): + plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated fig = plt.figure() ax = fig.add_subplot(projection='3d') th = np.linspace(0, 2 * np.pi * 6, 256) @@ -2260,6 +2282,7 @@ def test_scatter_masked_color(): @mpl3d_image_comparison(['surface3d_zsort_inf.png'], style='mpl20') def test_surface3d_zsort_inf(): + plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated fig = plt.figure() ax = fig.add_subplot(projection='3d')