diff --git a/doc/users/next_whats_new/2019-03-25-mplot3d-projection.rst b/doc/users/next_whats_new/2019-03-25-mplot3d-projection.rst index d60a4a921c45..fdc16761c713 100644 --- a/doc/users/next_whats_new/2019-03-25-mplot3d-projection.rst +++ b/doc/users/next_whats_new/2019-03-25-mplot3d-projection.rst @@ -4,6 +4,13 @@ Plots made with :class:`~mpl_toolkits.mplot3d.axes3d.Axes3D` were previously stretched to fit a square bounding box. As this stretching was done after the projection from 3D to 2D, it resulted in distorted images if non-square -bounding boxes were used. +bounding boxes were used. As of 3.3, this no longer occurs. -As of this release, this no longer occurs. +Currently, modes of setting the aspect (via +`~mpl_toolkits.mplot3d.axes3d.Axes3D.set_aspect`) in data space are +not supported for Axes3D but may be in the future. If you want to +simulate having equal aspect in data space, set the ratio of your data +limits to match the value of `~.get_box_aspect`. To control these +ratios use the `~mpl_toolkits.mplot3d.axes3d.Axes3D.set_box_aspect` +method which accepts the ratios as a 3-tuple of X:Y:Z. The default +aspect ratio is 4:4:3. diff --git a/lib/matplotlib/axes/_base.py b/lib/matplotlib/axes/_base.py index 2f5f4ce7b718..da9ddeebe6d4 100644 --- a/lib/matplotlib/axes/_base.py +++ b/lib/matplotlib/axes/_base.py @@ -1339,10 +1339,6 @@ def set_aspect(self, aspect, adjustable=None, anchor=None, share=False): if cbook._str_equal(aspect, 'equal'): aspect = 1 if not cbook._str_equal(aspect, 'auto'): - if self.name == '3d': - raise NotImplementedError( - 'It is not currently possible to manually set the aspect ' - 'on 3D axes') aspect = float(aspect) # raise ValueError if necessary if share: diff --git a/lib/matplotlib/tests/baseline_images/test_figure/tightbbox_box_aspect.svg b/lib/matplotlib/tests/baseline_images/test_figure/tightbbox_box_aspect.svg new file mode 100644 index 000000000000..7ac69c1a1daa --- /dev/null +++ b/lib/matplotlib/tests/baseline_images/test_figure/tightbbox_box_aspect.svg @@ -0,0 +1,403 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/matplotlib/tests/test_figure.py b/lib/matplotlib/tests/test_figure.py index b2f39e81b961..5d508db78e7b 100644 --- a/lib/matplotlib/tests/test_figure.py +++ b/lib/matplotlib/tests/test_figure.py @@ -566,3 +566,16 @@ def test_add_subplot_twotuple(): assert ax4.get_subplotspec().colspan == range(0, 2) with pytest.raises(IndexError): fig.add_subplot(3, 2, (6, 3)) + + +@image_comparison(['tightbbox_box_aspect.svg'], style='mpl20', + savefig_kwarg={'bbox_inches': 'tight', + 'facecolor': 'teal'}, + remove_text=True) +def test_tightbbox_box_aspect(): + fig = plt.figure() + gs = fig.add_gridspec(1, 2) + ax1 = fig.add_subplot(gs[0, 0]) + ax2 = fig.add_subplot(gs[0, 1], projection='3d') + ax1.set_box_aspect(.5) + ax2.set_box_aspect((2, 1, 1)) diff --git a/lib/matplotlib/tight_bbox.py b/lib/matplotlib/tight_bbox.py index d05ce7ec9135..06f944c2b5bd 100644 --- a/lib/matplotlib/tight_bbox.py +++ b/lib/matplotlib/tight_bbox.py @@ -15,6 +15,8 @@ def adjust_bbox(fig, bbox_inches, fixed_dpi=None): changes, the scale of the original figure is conserved. A function which restores the original values are returned. """ + def no_op_apply_aspect(position=None): + return origBbox = fig.bbox origBboxInches = fig.bbox_inches @@ -22,23 +24,33 @@ def adjust_bbox(fig, bbox_inches, fixed_dpi=None): _boxout = fig.transFigure._boxout fig.set_tight_layout(False) - - asp_list = [] + old_aspect = [] locator_list = [] + sentinel = object() for ax in fig.axes: pos = ax.get_position(original=False).frozen() locator_list.append(ax.get_axes_locator()) - asp_list.append(ax.get_aspect()) def _l(a, r, pos=pos): return pos ax.set_axes_locator(_l) - ax.set_aspect("auto") + # override the method that enforces the aspect ratio + # on the Axes + if 'apply_aspect' in ax.__dict__: + old_aspect.append(ax.apply_aspect) + else: + old_aspect.append(sentinel) + ax.apply_aspect = no_op_apply_aspect def restore_bbox(): - for ax, asp, loc in zip(fig.axes, asp_list, locator_list): - ax.set_aspect(asp) + for ax, loc, aspect in zip(fig.axes, locator_list, old_aspect): ax.set_axes_locator(loc) + if aspect is sentinel: + # delete our no-op function which un-hides the + # original method + del ax.apply_aspect + else: + ax.apply_aspect = aspect fig.bbox = origBbox fig.bbox_inches = origBboxInches diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index 9ac10918604a..584e12461d6d 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -24,6 +24,7 @@ import matplotlib.colors as mcolors import matplotlib.docstring as docstring import matplotlib.scale as mscale +import matplotlib.transforms as mtransforms from matplotlib.axes import Axes, rcParams from matplotlib.axes._base import _axis_method_wrapper from matplotlib.transforms import Bbox @@ -52,6 +53,7 @@ class Axes3D(Axes): def __init__( self, fig, rect=None, *args, azim=-60, elev=30, sharez=None, proj_type='persp', + box_aspect=None, **kwargs): """ Parameters @@ -90,6 +92,7 @@ def __init__( self.zz_viewLim = Bbox.unit() self.xy_dataLim = Bbox.unit() self.zz_dataLim = Bbox.unit() + # inhibit autoscale_view until the axes are defined # they can't be defined until Axes.__init__ has been called self.view_init(self.initial_elev, self.initial_azim) @@ -99,7 +102,9 @@ def __init__( self._shared_z_axes.join(self, sharez) self._adjustable = 'datalim' - super().__init__(fig, rect, frameon=True, *args, **kwargs) + super().__init__( + fig, rect, frameon=True, box_aspect=box_aspect, *args, **kwargs + ) # Disable drawing of axes by base class super().set_axis_off() # Enable drawing of axes by Axes3D class @@ -261,6 +266,138 @@ def tunit_edges(self, vals=None, M=None): (tc[7], tc[4])] return edges + def set_aspect(self, aspect, adjustable=None, anchor=None, share=False): + """ + Set the aspect ratios. + + Axes 3D does not current support any aspect but 'auto' which fills + the axes with the data limits. + + To simulate having equal aspect in data space, set the ratio + of your data limits to match the value of `~.get_box_aspect`. + To control box aspect ratios use `~.Axes3D.set_box_aspect`. + + Parameters + ---------- + aspect : {'auto'} + Possible values: + + ========= ================================================== + value description + ========= ================================================== + 'auto' automatic; fill the position rectangle with data. + ========= ================================================== + + adjustable : None + Currently ignored by Axes3D + + If not *None*, this defines which parameter will be adjusted to + meet the required aspect. See `.set_adjustable` for further + details. + + anchor : None or str or 2-tuple of float, optional + If not *None*, this defines where the Axes will be drawn if there + is extra space due to aspect constraints. The most common way to + to specify the anchor are abbreviations of cardinal directions: + + ===== ===================== + value description + ===== ===================== + 'C' centered + 'SW' lower left corner + 'S' middle of bottom edge + 'SE' lower right corner + etc. + ===== ===================== + + See `.set_anchor` for further details. + + share : bool, default: False + If ``True``, apply the settings to all shared Axes. + + See Also + -------- + mpl_toolkits.mplot3d.axes3d.Axes3D.set_box_aspect + """ + if aspect != 'auto': + raise NotImplementedError( + "Axes3D currently only supports the aspect argument " + f"'auto'. You passed in {aspect!r}." + ) + + if share: + axes = {*self._shared_x_axes.get_siblings(self), + *self._shared_y_axes.get_siblings(self), + *self._shared_z_axes.get_siblings(self), + } + else: + axes = {self} + + for ax in axes: + ax._aspect = aspect + ax.stale = True + + if anchor is not None: + self.set_anchor(anchor, share=share) + + def set_anchor(self, anchor, share=False): + # docstring inherited + if not (anchor in mtransforms.Bbox.coefs or len(anchor) == 2): + raise ValueError('anchor must be among %s' % + ', '.join(mtransforms.Bbox.coefs)) + if share: + axes = {*self._shared_x_axes.get_siblings(self), + *self._shared_y_axes.get_siblings(self), + *self._shared_z_axes.get_siblings(self), + } + else: + axes = {self} + for ax in axes: + ax._anchor = anchor + ax.stale = True + + def set_box_aspect(self, aspect, *, zoom=1): + """ + Set the axes box aspect. + + The box aspect is the ratio of height to width in display + units for each face of the box when viewed perpendicular to + that face. This is not to be confused with the data aspect + (which for Axes3D is always 'auto'). The default ratios are + 4:4:3 (x:y:z). + + To simulate having equal aspect in data space, set the box + aspect to match your data range in each dimension. + + *zoom* controls the overall size of the Axes3D in the figure. + + Parameters + ---------- + aspect : 3-tuple of floats or None + Changes the physical dimensions of the Axes3D, such that the ratio + of the axis lengths in display units is x:y:z. + + If None, defaults to 4:4:3 + + zoom : float + Control overall size of the Axes3D in the figure. + """ + if aspect is None: + aspect = np.asarray((4, 4, 3), dtype=float) + else: + orig_aspect = aspect + aspect = np.asarray(aspect, dtype=float) + if aspect.shape != (3,): + raise ValueError( + "You must pass a 3-tuple that can be cast to floats. " + f"You passed {orig_aspect!r}" + ) + # default scale tuned to match the mpl32 appearance. + aspect *= 1.8294640721620434 * zoom / np.linalg.norm(aspect) + + self._box_aspect = aspect + self.stale = True + def apply_aspect(self, position=None): if position is None: position = self.get_position(original=True) @@ -882,8 +1019,11 @@ def set_proj_type(self, proj_type): def get_proj(self): """Create the projection matrix from the current viewing position.""" - # chosen for similarity with the initial view before gh-8896 - pb_aspect = np.array([4, 4, 3]) / 3.5 + # elev stores the elevation angle in the z plane + # azim stores the azimuth angle in the x,y plane + # + # dist is the distance of the eye viewing point from the object + # point. relev, razim = np.pi * self.elev/180, np.pi * self.azim/180 @@ -894,10 +1034,11 @@ def get_proj(self): # transform to uniform world coordinates 0-1, 0-1, 0-1 worldM = proj3d.world_transformation(xmin, xmax, ymin, ymax, - zmin, zmax, pb_aspect=pb_aspect) + zmin, zmax, + pb_aspect=self._box_aspect) # look into the middle of the new coordinates - R = pb_aspect / 2 + R = self._box_aspect / 2 xp = R[0] + np.cos(razim) * np.cos(relev) * self.dist yp = R[1] + np.sin(razim) * np.cos(relev) * self.dist @@ -2770,6 +2911,27 @@ def permutation_matrices(n): return polygons + def get_tightbbox(self, renderer, call_axes_locator=True, + bbox_extra_artists=None, *, for_layout_only=False): + ret = super().get_tightbbox(renderer, + call_axes_locator=call_axes_locator, + bbox_extra_artists=bbox_extra_artists, + for_layout_only=for_layout_only) + batch = [ret] + if self._axis3don: + for axis in self._get_axis_list(): + if axis.get_visible(): + try: + axis_bb = axis.get_tightbbox( + renderer, + for_layout_only=for_layout_only + ) + except TypeError: + # in case downstream library has redefined axis: + axis_bb = axis.get_tightbbox(renderer) + if axis_bb: + batch.append(axis_bb) + return mtransforms.Bbox.union(batch) docstring.interpd.update(Axes3D=artist.kwdoc(Axes3D)) docstring.dedent_interpd(Axes3D.__init__) diff --git a/lib/mpl_toolkits/mplot3d/axis3d.py b/lib/mpl_toolkits/mplot3d/axis3d.py index c3ca45b5df80..b232405e25d6 100644 --- a/lib/mpl_toolkits/mplot3d/axis3d.py +++ b/lib/mpl_toolkits/mplot3d/axis3d.py @@ -4,6 +4,7 @@ import numpy as np +import matplotlib.transforms as mtransforms from matplotlib import ( artist, lines as mlines, axis as maxis, patches as mpatches, rcParams) from . import art3d, proj3d @@ -398,12 +399,53 @@ def draw(self, renderer): renderer.close_group('axis3d') self.stale = False - # TODO: Get this to work properly when mplot3d supports - # the transforms framework. - def get_tightbbox(self, renderer): - # Currently returns None so that Axis.get_tightbbox - # doesn't return junk info. - return None + # TODO: Get this to work (more) properly when mplot3d supports the + # transforms framework. + def get_tightbbox(self, renderer, *, for_layout_only=False): + # inherited docstring + if not self.get_visible(): + return + # We have to directly access the internal data structures + # (and hope they are up to date) because at draw time we + # shift the ticks and their labels around in (x, y) space + # based on the projection, the current view port, and their + # position in 3D space. If we extend the transforms framework + # into 3D we would not need to do this different book keeping + # than we do in the normal axis + major_locs = self.get_majorticklocs() + minor_locs = self.get_minorticklocs() + + ticks = [*self.get_minor_ticks(len(minor_locs)), + *self.get_major_ticks(len(major_locs))] + view_low, view_high = self.get_view_interval() + if view_low > view_high: + view_low, view_high = view_high, view_low + interval_t = self.get_transform().transform([view_low, view_high]) + + ticks_to_draw = [] + for tick in ticks: + try: + loc_t = self.get_transform().transform(tick.get_loc()) + except AssertionError: + # Transform.transform doesn't allow masked values but + # some scales might make them, so we need this try/except. + pass + else: + if mtransforms._interval_contains_close(interval_t, loc_t): + ticks_to_draw.append(tick) + + ticks = ticks_to_draw + + bb_1, bb_2 = self._get_tick_bboxes(ticks, renderer) + other = [] + + if self.line.get_visible(): + other.append(self.line.get_window_extent(renderer)) + if (self.label.get_visible() and not for_layout_only and + self.label.get_text()): + other.append(self.label.get_window_extent(renderer)) + + return mtransforms.Bbox.union([*bb_1, *bb_2, *other]) @property def d_interval(self): diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/equal_box_aspect.png b/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/equal_box_aspect.png new file mode 100644 index 000000000000..5f6dd6ea3099 Binary files /dev/null and b/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/equal_box_aspect.png differ diff --git a/lib/mpl_toolkits/tests/test_mplot3d.py b/lib/mpl_toolkits/tests/test_mplot3d.py index dc441568cc5c..fcc225053632 100644 --- a/lib/mpl_toolkits/tests/test_mplot3d.py +++ b/lib/mpl_toolkits/tests/test_mplot3d.py @@ -961,3 +961,38 @@ def test_minor_ticks(): ax.set_yticklabels(["third"], minor=True) ax.set_zticks([0.50], minor=True) ax.set_zticklabels(["half"], minor=True) + + +@image_comparison(["equal_box_aspect.png"], style="mpl20") +def test_equal_box_aspect(): + from itertools import product, combinations + + fig = plt.figure() + ax = fig.add_subplot(111, projection="3d") + + # Make data + u = np.linspace(0, 2 * np.pi, 100) + v = np.linspace(0, np.pi, 100) + x = np.outer(np.cos(u), np.sin(v)) + y = np.outer(np.sin(u), np.sin(v)) + z = np.outer(np.ones_like(u), np.cos(v)) + + # Plot the surface + ax.plot_surface(x, y, z) + + # draw cube + r = [-1, 1] + for s, e in combinations(np.array(list(product(r, r, r))), 2): + if np.sum(np.abs(s - e)) == r[1] - r[0]: + ax.plot3D(*zip(s, e), color="b") + + # Make axes limits + xyzlim = np.column_stack( + [ax.get_xlim3d(), ax.get_ylim3d(), ax.get_zlim3d()] + ) + XYZlim = [min(xyzlim[0]), max(xyzlim[1])] + ax.set_xlim3d(XYZlim) + ax.set_ylim3d(XYZlim) + ax.set_zlim3d(XYZlim) + ax.axis('off') + ax.set_box_aspect((1, 1, 1))