diff --git a/lib/mpl_toolkits/mplot3d/axis3d.py b/lib/mpl_toolkits/mplot3d/axis3d.py index 0ac2e50b1a1a..7f94e3b2facc 100644 --- a/lib/mpl_toolkits/mplot3d/axis3d.py +++ b/lib/mpl_toolkits/mplot3d/axis3d.py @@ -684,6 +684,23 @@ def get_tightbbox(self, renderer=None, *, for_layout_only=False): # docstring inherited if not self.get_visible(): return + if renderer is None: + renderer = self.get_figure(root=True)._get_renderer() + + # For constrained_layout, the layout engine queries tightbbox before + # any draw() call, so tick 2D positions have not been set yet and the + # bbox would be degenerate. Do a no-output draw to initialize them. + # M must be computed first if it hasn't been set yet. + # The flag is per-axis so each of xaxis/yaxis/zaxis is initialised once. + if (not getattr(self, '_tightbbox_initialized', False) + and self.get_figure(root=True).get_constrained_layout()): + if self.axes.M is None: + self.axes.M = self.axes.get_proj() + self.axes.invM = np.linalg.inv(self.axes.M) + with renderer._draw_disabled(): + self.draw(renderer) + self._tightbbox_initialized = True + # 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 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..9a11b2839405 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 078da596a9a4..f93782308220 100644 --- a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py +++ b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py @@ -2780,6 +2780,39 @@ def test_axis_get_tightbbox_includes_offset_text(): f"bbox.y1 ({bbox.y1}) should be >= offset_bbox.y1 ({offset_bbox.y1})" +def test_axis_get_tightbbox_before_draw_includes_ticklabels(): + # Regression test for GH#31277. + # Axis3D.get_tightbbox() must return a non-degenerate bbox before draw() + # has been called when constrained_layout is active, so that the layout + # engine can account for 3D tick labels. + fig = plt.figure(constrained_layout=True) + ax = fig.add_subplot(projection='3d') + + renderer = fig.canvas.get_renderer() + for axis in [ax.xaxis, ax.yaxis, ax.zaxis]: + bbox = axis.get_tightbbox(renderer) + assert bbox is not None, f"{axis.axis_name}: get_tightbbox returned None" + assert bbox.width > 0 and bbox.height > 0, \ + f"{axis.axis_name}: tightbbox has zero area before draw()" + + +def test_constrained_layout_3d_no_overlap_with_subplot_title(): + # Regression test for GH#31277. + # When using constrained_layout, 3D tick labels must not overlap the title + # of an adjacent 2D subplot. + fig = plt.figure(constrained_layout=True) + ax1 = fig.add_subplot(2, 1, 1, projection='3d') + ax1.set_title('3D Plot') + ax2 = fig.add_subplot(2, 1, 2) + ax2.set_title('2D Plot') + + fig.canvas.draw() + renderer = fig.canvas.get_renderer() + + assert ax1.get_tightbbox(renderer).y0 >= ax2.title.get_window_extent(renderer).y1, \ + "3D subplot tick labels overlap the title of the 2D subplot below" + + def test_ctrl_rotation_snaps_to_5deg(): fig = plt.figure() ax = fig.add_subplot(projection='3d')