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')