diff --git a/doc/api/next_api_changes/behavior/18127-ES.rst b/doc/api/next_api_changes/behavior/18127-ES.rst new file mode 100644 index 000000000000..252796ab34ef --- /dev/null +++ b/doc/api/next_api_changes/behavior/18127-ES.rst @@ -0,0 +1,11 @@ +Autoscaling in Axes3D +~~~~~~~~~~~~~~~~~~~~~ + +In Matplotlib 3.2.0, autoscaling was made lazier for 2D Axes, i.e., limits +would only be recomputed when actually rendering the canvas, or when the user +queries the Axes limits. This performance improvement is now extended to +`.Axes3D`. This also fixes some issues with autoscaling being triggered +unexpectedly in Axes3D. + +Please see :ref:`the API change for 2D Axes ` +for further details. diff --git a/doc/api/prev_api_changes/api_changes_3.2.0/behavior.rst b/doc/api/prev_api_changes/api_changes_3.2.0/behavior.rst index 7a95a391ad1a..8e76a047e348 100644 --- a/doc/api/prev_api_changes/api_changes_3.2.0/behavior.rst +++ b/doc/api/prev_api_changes/api_changes_3.2.0/behavior.rst @@ -54,6 +54,8 @@ writer, without performing the availability check on subsequent writers, it is now possible to iterate over the registry, which will yield the names of the available classes. +.. _api-changes-3-2-0-autoscaling: + Autoscaling ~~~~~~~~~~~ diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index 56f45518c340..377603e2d8d9 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -6717,34 +6717,24 @@ def test_ytickcolor_is_not_markercolor(): assert tick.tick1line.get_markeredgecolor() != 'white' +@pytest.mark.parametrize('axis', ('x', 'y')) @pytest.mark.parametrize('auto', (True, False, None)) -def test_unautoscaley(auto): - fig, ax = plt.subplots() - x = np.arange(100) - y = np.linspace(-.1, .1, 100) - ax.scatter(x, y) - - post_auto = ax.get_autoscaley_on() if auto is None else auto - - ax.set_ylim((-.5, .5), auto=auto) - assert post_auto == ax.get_autoscaley_on() - fig.canvas.draw() - assert_array_equal(ax.get_ylim(), (-.5, .5)) - - -@pytest.mark.parametrize('auto', (True, False, None)) -def test_unautoscalex(auto): +def test_unautoscale(axis, auto): fig, ax = plt.subplots() x = np.arange(100) y = np.linspace(-.1, .1, 100) ax.scatter(y, x) - post_auto = ax.get_autoscalex_on() if auto is None else auto + get_autoscale_on = getattr(ax, f'get_autoscale{axis}_on') + set_lim = getattr(ax, f'set_{axis}lim') + get_lim = getattr(ax, f'get_{axis}lim') + + post_auto = get_autoscale_on() if auto is None else auto - ax.set_xlim((-.5, .5), auto=auto) - assert post_auto == ax.get_autoscalex_on() + set_lim((-0.5, 0.5), auto=auto) + assert post_auto == get_autoscale_on() fig.canvas.draw() - assert_array_equal(ax.get_xlim(), (-.5, .5)) + assert_array_equal(get_lim(), (-0.5, 0.5)) @check_figures_equal(extensions=["png"]) diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index 15858f59cc5f..860ef0f09530 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -90,6 +90,7 @@ def __init__( self.zz_viewLim = Bbox.unit() self.xy_dataLim = Bbox.unit() self.zz_dataLim = Bbox.unit() + self._stale_viewlim_z = False # inhibit autoscale_view until the axes are defined # they can't be defined until Axes.__init__ has been called @@ -191,6 +192,24 @@ def get_zaxis(self): def _get_axis_list(self): return super()._get_axis_list() + (self.zaxis, ) + def _unstale_viewLim(self): + # We should arrange to store this information once per share-group + # instead of on every axis. + scalex = any(ax._stale_viewlim_x + for ax in self._shared_x_axes.get_siblings(self)) + scaley = any(ax._stale_viewlim_y + for ax in self._shared_y_axes.get_siblings(self)) + scalez = any(ax._stale_viewlim_z + for ax in self._shared_z_axes.get_siblings(self)) + if scalex or scaley or scalez: + for ax in self._shared_x_axes.get_siblings(self): + ax._stale_viewlim_x = False + for ax in self._shared_y_axes.get_siblings(self): + ax._stale_viewlim_y = False + for ax in self._shared_z_axes.get_siblings(self): + ax._stale_viewlim_z = False + self.autoscale_view(scalex=scalex, scaley=scaley, scalez=scalez) + def unit_cube(self, vals=None): minx, maxx, miny, maxy, minz, maxz = vals or self.get_w_lims() return [(minx, miny, minz), @@ -378,6 +397,8 @@ def apply_aspect(self, position=None): @artist.allow_rasterization def draw(self, renderer): + self._unstale_viewLim() + # draw the background patch self.patch.draw(renderer) self._frameon = False @@ -477,9 +498,9 @@ def _unit_change_handler(self, axis_name, event=None): self._unit_change_handler, axis_name, event=object()) _api.check_in_list(self._get_axis_map(), axis_name=axis_name) self.relim() - self.autoscale_view(scalex=(axis_name == "x"), - scaley=(axis_name == "y"), - scalez=(axis_name == "z")) + self._request_autoscale_view(scalex=(axis_name == "x"), + scaley=(axis_name == "y"), + scalez=(axis_name == "z")) def update_datalim(self, xys, **kwargs): pass @@ -528,6 +549,24 @@ def set_autoscalez_on(self, b): """ self._autoscaleZon = b + def set_xmargin(self, m): + # docstring inherited + scalez = self._stale_viewlim_z + super().set_xmargin(m) + # Superclass is 2D and will call _request_autoscale_view with defaults + # for unknown Axis, which would be scalez=True, but it shouldn't be for + # this call, so restore it. + self._stale_viewlim_z = scalez + + def set_ymargin(self, m): + # docstring inherited + scalez = self._stale_viewlim_z + super().set_ymargin(m) + # Superclass is 2D and will call _request_autoscale_view with defaults + # for unknown Axis, which would be scalez=True, but it shouldn't be for + # this call, so restore it. + self._stale_viewlim_z = scalez + def set_zmargin(self, m): """ Set padding of Z data limits prior to autoscaling. @@ -542,6 +581,7 @@ def set_zmargin(self, m): if m < 0 or m > 1: raise ValueError("margin must be in range 0 to 1") self._zmargin = m + self._request_autoscale_view(scalex=False, scaley=False, scalez=True) self.stale = True def margins(self, *margins, x=None, y=None, z=None, tight=True): @@ -639,8 +679,8 @@ def autoscale(self, enable=True, axis='both', tight=None): self._autoscaleZon = scalez = bool(enable) else: scalez = False - self.autoscale_view(tight=tight, scalex=scalex, scaley=scaley, - scalez=scalez) + self._request_autoscale_view(tight=tight, scalex=scalex, scaley=scaley, + scalez=scalez) def auto_scale_xyz(self, X, Y, Z=None, had_data=None): # This updates the bounding boxes as to keep a record as to what the @@ -656,6 +696,19 @@ 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() + # API could be better, right now this is just to match the old calls to + # autoscale_view() after each plotting method. + def _request_autoscale_view(self, tight=None, scalex=True, scaley=True, + scalez=True): + if tight is not None: + self._tight = tight + if scalex: + self._stale_viewlim_x = True # Else keep old state. + if scaley: + self._stale_viewlim_y = True + if scalez: + self._stale_viewlim_z = True + def autoscale_view(self, tight=None, scalex=True, scaley=True, scalez=True): """ @@ -766,6 +819,9 @@ def set_xlim3d(self, left=None, right=None, emit=True, auto=False, left, right = sorted([left, right], reverse=bool(reverse)) self.xy_viewLim.intervalx = (left, right) + # Mark viewlims as no longer stale without triggering an autoscale. + for ax in self._shared_x_axes.get_siblings(self): + ax._stale_viewlim_x = False if auto is not None: self._autoscaleXon = bool(auto) @@ -821,6 +877,9 @@ def set_ylim3d(self, bottom=None, top=None, emit=True, auto=False, bottom, top = top, bottom self.xy_viewLim.intervaly = (bottom, top) + # Mark viewlims as no longer stale without triggering an autoscale. + for ax in self._shared_y_axes.get_siblings(self): + ax._stale_viewlim_y = False if auto is not None: self._autoscaleYon = bool(auto) @@ -876,6 +935,9 @@ def set_zlim3d(self, bottom=None, top=None, emit=True, auto=False, bottom, top = top, bottom self.zz_viewLim.intervalx = (bottom, top) + # Mark viewlims as no longer stale without triggering an autoscale. + for ax in self._shared_z_axes.get_siblings(self): + ax._stale_viewlim_z = False if auto is not None: self._autoscaleZon = bool(auto) @@ -1346,7 +1408,8 @@ def locator_params(self, axis='both', tight=None, **kwargs): self.yaxis.get_major_locator().set_params(**kwargs) if _z: self.zaxis.get_major_locator().set_params(**kwargs) - self.autoscale_view(tight=tight, scalex=_x, scaley=_y, scalez=_z) + self._request_autoscale_view(tight=tight, scalex=_x, scaley=_y, + scalez=_z) def tick_params(self, axis='both', **kwargs): """ diff --git a/lib/mpl_toolkits/tests/test_mplot3d.py b/lib/mpl_toolkits/tests/test_mplot3d.py index 60436bd6bb93..71872f3a1e40 100644 --- a/lib/mpl_toolkits/tests/test_mplot3d.py +++ b/lib/mpl_toolkits/tests/test_mplot3d.py @@ -895,6 +895,28 @@ def test_autoscale(): assert ax.get_w_lims() == (0, 1, -.1, 1.1, -.4, 2.4) +@pytest.mark.parametrize('axis', ('x', 'y', 'z')) +@pytest.mark.parametrize('auto', (True, False, None)) +def test_unautoscale(axis, auto): + fig = plt.figure() + ax = fig.gca(projection='3d') + + x = np.arange(100) + y = np.linspace(-0.1, 0.1, 100) + ax.scatter(x, y) + + get_autoscale_on = getattr(ax, f'get_autoscale{axis}_on') + set_lim = getattr(ax, f'set_{axis}lim') + get_lim = getattr(ax, f'get_{axis}lim') + + post_auto = get_autoscale_on() if auto is None else auto + + set_lim((-0.5, 0.5), auto=auto) + assert post_auto == get_autoscale_on() + fig.canvas.draw() + np.testing.assert_array_equal(get_lim(), (-0.5, 0.5)) + + @mpl3d_image_comparison(['axes3d_ortho.png'], remove_text=False) def test_axes3d_ortho(): fig = plt.figure()