diff --git a/lib/mpl_toolkits/mplot3d/axis3d.py b/lib/mpl_toolkits/mplot3d/axis3d.py index 8b3a50c6b32c..53d061b35425 100644 --- a/lib/mpl_toolkits/mplot3d/axis3d.py +++ b/lib/mpl_toolkits/mplot3d/axis3d.py @@ -439,8 +439,9 @@ def _axmask(self): axmask[self._axinfo["i"]] = False return axmask - def _draw_ticks(self, renderer, edgep1, centers, deltas, highs, - deltas_per_point, pos): + def _get_updated_ticks(self, edgep1, centers, deltas, highs, + deltas_per_point, pos): + """Update the ticks ready for draw and return in a list.""" ticks = self._update_ticks() info = self._axinfo index = info["i"] @@ -453,7 +454,7 @@ def _draw_ticks(self, renderer, edgep1, centers, deltas, highs, axis = [self.axes.xaxis, self.axes.yaxis, self.axes.zaxis][index] axis_trans = axis.get_transform() - # Draw ticks: + # Update ticks: tickdir = self._get_tickdir(pos) tickdelta = deltas[tickdir] if highs[tickdir] else -deltas[tickdir] @@ -485,10 +486,12 @@ def _draw_ticks(self, renderer, edgep1, centers, deltas, highs, _tick_update_position(tick, (x1, x2), (y1, y2), (lx, ly)) tick.tick1line.set_linewidth(tick_lw[tick._major]) - tick.draw(renderer) - def _draw_offset_text(self, renderer, edgep1, edgep2, labeldeltas, centers, - highs, pep, dx, dy): + return ticks + + def _update_offset_text(self, edgep1, edgep2, labeldeltas, centers, + highs, pep, dx, dy): + """Update the offset text ready to draw.""" # Get general axis information: info = self._axinfo index = info["i"] @@ -556,12 +559,11 @@ def _draw_offset_text(self, renderer, edgep1, edgep2, labeldeltas, centers, self.offsetText.set_va('center') self.offsetText.set_ha(align) - self.offsetText.draw(renderer) - def _draw_labels(self, renderer, edgep1, edgep2, labeldeltas, centers, dx, dy): + def _update_label_position(self, edgep1, edgep2, labeldeltas, centers, dx, dy): + """Update the axis label position read to draw.""" label = self._axinfo["label"] - # Draw labels lxyz = 0.5 * (edgep1 + edgep2) lxyz = _move_from_center(lxyz, centers, labeldeltas, self._axmask()) tlx, tly, tlz = proj3d.proj_transform(*lxyz, self.axes.M) @@ -572,13 +574,15 @@ def _draw_labels(self, renderer, edgep1, edgep2, labeldeltas, centers, dx, dy): self.label.set_va(label['va']) self.label.set_ha(label['ha']) self.label.set_rotation_mode(label['rotation_mode']) - self.label.draw(renderer) - @artist.allow_rasterization - def draw(self, renderer): + def _update_all_positions(self): + """ + Update positions of all child artists ready to draw. Artists may be drawn + twice, e.g. if tick_position is 'both', the ticks and line show on both sides + of the axes. We therefore yield after setting the position on each side. + """ self.label._transform = self.axes.transData self.offsetText._transform = self.axes.transData - renderer.open_group("axis3d", gid=self.get_gid()) # Get general axis information: mins, maxs, tc, highs = self._get_coord_info() @@ -613,17 +617,17 @@ def draw(self, renderer): dx, dy = (self.axes.transAxes.transform([pep[0:2, 1]]) - self.axes.transAxes.transform([pep[0:2, 0]]))[0] - # Draw the lines + # Handle the lines self.line.set_data(pep[0], pep[1]) - self.line.draw(renderer) - # Draw ticks - self._draw_ticks(renderer, edgep1, centers, deltas, highs, - deltas_per_point, pos) + # Handle Offset text + self._update_offset_text(edgep1, edgep2, labeldeltas, + centers, highs, pep, dx, dy) + + # Handle and yield the ticks + yield self._get_updated_ticks(edgep1, centers, deltas, highs, + deltas_per_point, pos) - # Draw Offset text - self._draw_offset_text(renderer, edgep1, edgep2, labeldeltas, - centers, highs, pep, dx, dy) for edgep1, edgep2, pos in zip(*self._get_all_axis_line_edge_points( minmax, maxmin, self._label_position)): @@ -633,8 +637,25 @@ def draw(self, renderer): dx, dy = (self.axes.transAxes.transform([pep[0:2, 1]]) - self.axes.transAxes.transform([pep[0:2, 0]]))[0] - # Draw labels - self._draw_labels(renderer, edgep1, edgep2, labeldeltas, centers, dx, dy) + # Handle labels + self._update_label_position(edgep1, edgep2, labeldeltas, centers, dx, dy) + yield self.label + + @artist.allow_rasterization + def draw(self, renderer): + renderer.open_group('axis3d', gid=self.get_gid()) + + for arts in self._update_all_positions(): + if isinstance(arts, list): + # We have a list of ticks, and the line and offset text are also ready + # to be drawn + self.line.draw(renderer) + for tick in arts: + tick.draw(renderer) + self.offsetText.draw(renderer) + else: + # We have the axis label + arts.draw(renderer) renderer.close_group('axis3d') self.stale = False @@ -689,49 +710,34 @@ def get_tightbbox(self, renderer=None, *, for_layout_only=False): # docstring inherited 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 + if self.axes.M is None: + # add the projection matrix to the renderer + self.axes.M = self.axes.get_proj() + self.axes.invM = np.linalg.inv(self.axes.M) + + bboxes = [] - bb_1, bb_2 = self._get_ticklabel_bboxes(ticks, renderer) - other = [] + for arts in self._update_all_positions(): + if isinstance(arts, list): + # We have a list of ticks + for tlabel_bbs in self._get_ticklabel_bboxes(arts, renderer): + bboxes.extend(tlabel_bbs) - if self.offsetText.get_visible() and self.offsetText.get_text(): - other.append(self.offsetText.get_window_extent(renderer)) - 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)) + # Line and offset text were also updated + if self.offsetText.get_visible() and self.offsetText.get_text(): + bboxes.append(self.offsetText.get_window_extent(renderer)) + if self.line.get_visible(): + bboxes.append(self.line.get_window_extent(renderer)) - return mtransforms.Bbox.union([*bb_1, *bb_2, *other]) + elif arts.get_visible() and arts.get_text() and not for_layout_only: + # Account for the axis label + bboxes.append(arts.get_window_extent(renderer)) + + if bboxes: + return mtransforms.Bbox.union(bboxes) + else: + return None d_interval = _api.deprecated( "3.6", alternative="get_data_interval", pending=True)( diff --git a/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/axes3d_primary_views.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/axes3d_primary_views.png index 4af8ea9040a0..0dd53df2156f 100644 Binary files a/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/axes3d_primary_views.png and b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/axes3d_primary_views.png differ diff --git a/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/stem3d.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/stem3d.png index 468c684081dd..b311dd5731d8 100644 Binary files a/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/stem3d.png and b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/stem3d.png differ diff --git a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py index 7d479bdba61a..9567d15fb045 100644 --- a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py +++ b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py @@ -152,14 +152,13 @@ def test_axes3d_primary_views(): (0, 180, 0)] # -YZ # When viewing primary planes, draw the two visible axes so they intersect # at their low values - fig, axs = plt.subplots(2, 3, subplot_kw={'projection': '3d'}) + fig, axs = plt.subplots(2, 3, subplot_kw={'projection': '3d'}, layout='tight') for i, ax in enumerate(axs.flat): ax.set_xlabel('x') ax.set_ylabel('y') ax.set_zlabel('z') ax.set_proj_type('ortho') ax.view_init(elev=views[i][0], azim=views[i][1], roll=views[i][2]) - plt.tight_layout() @mpl3d_image_comparison(['bar3d.png'], style='mpl20') @@ -2817,6 +2816,47 @@ def test_axis_get_tightbbox_includes_offset_text(): f"bbox.y1 ({bbox.y1}) should be >= offset_bbox.y1 ({offset_bbox.y1})" +@pytest.mark.parametrize('labeltype', ['ticks', 'axis label']) +def test_axes3d_tightbbox_includes_labels(labeltype): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + + renderer = fig._get_renderer() + + tight_bbs = {} + + if labeltype == 'axis label': + ax.set_zlabel('foo') + + # Remove ticks so they don't affect the result + ax.zaxis.set_ticks_position('none') + + for pos in ['lower', 'upper', 'both', 'none']: + if labeltype == 'ticks': + ax.zaxis.set_ticks_position(pos) + else: + ax.zaxis.set_label_position(pos) + tight_bbs[pos] = ax.get_tightbbox(renderer) + + for pos in ['lower', 'both']: + # Should make space for labels on the left + assert tight_bbs[pos].xmin < tight_bbs['none'].xmin, \ + f'No space for labels on left with {labeltype} position "{pos}"' + + for pos in ['upper', 'both']: + # Should make space for labels on the right + assert tight_bbs['both'].xmax > tight_bbs['none'].xmax, \ + f'No space for labels on right with {labeltype} position "{pos}"' + + # No space on the right for 'lower' + assert tight_bbs['lower'].xmax == tight_bbs['none'].xmax, \ + f'Unexpected space for labels on right with {labeltype} position "lower"' + + # No space on the left for 'upper' + assert tight_bbs['upper'].xmin == tight_bbs['none'].xmin, \ + f'Unexpected space for labels on left with {labeltype} position "{pos}"' + + def test_ctrl_rotation_snaps_to_5deg(): fig = plt.figure() ax = fig.add_subplot(projection='3d') @@ -3227,24 +3267,3 @@ def test_plot_surface_log_scale_invalid_values(): zmin, zmax = ax.get_zlim() assert 1e-3 < zmin < zmax < 1e3, f"zlim corrupted: {(zmin, zmax)}" - - -def test_axes3d_tightbbox_includes_axis_labels(): - fig = plt.figure() - ax = fig.add_subplot(projection='3d') - ax.scatter([1], [1], [1]) - - fig.draw_without_rendering() - renderer = fig._get_renderer() - bb_no_labels = ax.get_tightbbox(renderer) - - ax.set_xlabel('X label') - ax.set_ylabel('Y label') - ax.set_zlabel('Z label') - - fig.draw_without_rendering() - bb_full = ax.get_tightbbox(renderer) - - # The full bbox must be strictly larger in at least one dimension than the - # bbox not including labels. - assert bb_full.width > bb_no_labels.width or bb_full.height > bb_no_labels.height