Skip to content

Commit 05d2dd7

Browse files
authored
Merge pull request matplotlib#32123 from meeseeksmachine/auto-backport-of-pr-32048-on-v3.11.x
Backport PR matplotlib#32048 on branch v3.11.x (FIX: update Axis3d artist positions before calculating tight_bbox)
2 parents c282b62 + 476d779 commit 05d2dd7

5 files changed

Lines changed: 152 additions & 94 deletions

File tree

lib/mpl_toolkits/mplot3d/axes3d.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -489,7 +489,7 @@ def draw(self, renderer):
489489
locator = self.get_axes_locator()
490490
self.apply_aspect(locator(self, renderer) if locator else None)
491491

492-
# add the projection matrix to the renderer
492+
# add the projection matrix to the axes
493493
self.M = self.get_proj()
494494
self.invM = np.linalg.inv(self.M)
495495

lib/mpl_toolkits/mplot3d/axis3d.py

Lines changed: 109 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@
22
# Created: 23 Sep 2005
33
# Parts rewritten by Reinier Heeres <reinier@heeres.eu>
44

5+
from dataclasses import dataclass
56
import inspect
67

78
import numpy as np
89

910
import matplotlib as mpl
1011
from matplotlib import (
1112
_api, artist, lines as mlines, axis as maxis, patches as mpatches,
12-
transforms as mtransforms, colors as mcolors)
13+
text as mtext, transforms as mtransforms, colors as mcolors)
1314
from . import art3d, proj3d
1415

1516

@@ -35,6 +36,32 @@ def _tick_update_position(tick, tickxs, tickys, labelpos):
3536
tick.gridline.set_data([0], [0])
3637

3738

39+
@dataclass
40+
class _UpdatedArtists:
41+
"""
42+
Class to supply artists that have been updated ready to draw.
43+
44+
We track the types of artists individually, because they need
45+
different handling in the tightbox calculation.
46+
"""
47+
line: mlines.Line2D = None
48+
ticks: list = None
49+
offset_text: mtext.Text = None
50+
label: mtext.Text = None
51+
52+
def __iter__(self):
53+
"""Yield all updated artists in the correct order for drawing"""
54+
if self.line is not None:
55+
yield self.line
56+
if self.ticks is not None:
57+
for tick in self.ticks:
58+
yield tick
59+
if self.offset_text is not None:
60+
yield self.offset_text
61+
if self.label is not None:
62+
yield self.label
63+
64+
3865
class Axis(maxis.XAxis):
3966
"""An Axis class for the 3D plots."""
4067
# These points from the unit cube make up the x, y and z-planes
@@ -439,8 +466,9 @@ def _axmask(self):
439466
axmask[self._axinfo["i"]] = False
440467
return axmask
441468

442-
def _draw_ticks(self, renderer, edgep1, centers, deltas, highs,
443-
deltas_per_point, pos):
469+
def _get_updated_ticks(self, edgep1, centers, deltas, highs,
470+
deltas_per_point, pos):
471+
"""Update the ticks ready for draw and return in a list."""
444472
ticks = self._update_ticks()
445473
info = self._axinfo
446474
index = info["i"]
@@ -453,7 +481,7 @@ def _draw_ticks(self, renderer, edgep1, centers, deltas, highs,
453481
axis = [self.axes.xaxis, self.axes.yaxis, self.axes.zaxis][index]
454482
axis_trans = axis.get_transform()
455483

456-
# Draw ticks:
484+
# Update ticks:
457485
tickdir = self._get_tickdir(pos)
458486
tickdelta = deltas[tickdir] if highs[tickdir] else -deltas[tickdir]
459487

@@ -485,10 +513,12 @@ def _draw_ticks(self, renderer, edgep1, centers, deltas, highs,
485513

486514
_tick_update_position(tick, (x1, x2), (y1, y2), (lx, ly))
487515
tick.tick1line.set_linewidth(tick_lw[tick._major])
488-
tick.draw(renderer)
489516

490-
def _draw_offset_text(self, renderer, edgep1, edgep2, labeldeltas, centers,
491-
highs, pep, dx, dy):
517+
return ticks
518+
519+
def _update_offset_text(self, edgep1, edgep2, labeldeltas, centers,
520+
highs, pep, dx, dy):
521+
"""Update the offset text ready to draw."""
492522
# Get general axis information:
493523
info = self._axinfo
494524
index = info["i"]
@@ -556,12 +586,11 @@ def _draw_offset_text(self, renderer, edgep1, edgep2, labeldeltas, centers,
556586

557587
self.offsetText.set_va('center')
558588
self.offsetText.set_ha(align)
559-
self.offsetText.draw(renderer)
560589

561-
def _draw_labels(self, renderer, edgep1, edgep2, labeldeltas, centers, dx, dy):
590+
def _update_label_position(self, edgep1, edgep2, labeldeltas, centers, dx, dy):
591+
"""Update the axis label position ready to draw."""
562592
label = self._axinfo["label"]
563593

564-
# Draw labels
565594
lxyz = 0.5 * (edgep1 + edgep2)
566595
lxyz = _move_from_center(lxyz, centers, labeldeltas, self._axmask())
567596
tlx, tly, tlz = proj3d.proj_transform(*lxyz, self.axes.M)
@@ -572,13 +601,26 @@ def _draw_labels(self, renderer, edgep1, edgep2, labeldeltas, centers, dx, dy):
572601
self.label.set_va(label['va'])
573602
self.label.set_ha(label['ha'])
574603
self.label.set_rotation_mode(label['rotation_mode'])
575-
self.label.draw(renderer)
576604

577-
@artist.allow_rasterization
578-
def draw(self, renderer):
605+
def _update_all_positions(self):
606+
"""
607+
Update positions of all child artists ready to draw. Artists may be drawn
608+
twice: if tick_position is 'both', the ticks, line and offset text show on both
609+
sides of the axes; if label_position is 'both' the label shows on both sides.
610+
Since tightbox calculation needs individual handling of the different Artist
611+
types, we return them through the dataclass `_UpdatedArtists` (as opposed to a
612+
plain list).
613+
Since we reuse the same artists with different positions, the calling function
614+
requires them after every position update, so we yield `_UpdatedArtists` up to
615+
four times with different content:
616+
617+
- line, ticks, offset_text on one side
618+
- line, ticks, offset_text on the other side
619+
- label on one side
620+
- label on the other side
621+
"""
579622
self.label._transform = self.axes.transData
580623
self.offsetText._transform = self.axes.transData
581-
renderer.open_group("axis3d", gid=self.get_gid())
582624

583625
# Get general axis information:
584626
mins, maxs, tc, highs = self._get_coord_info()
@@ -613,28 +655,42 @@ def draw(self, renderer):
613655
dx, dy = (self.axes.transAxes.transform([pep[0:2, 1]]) -
614656
self.axes.transAxes.transform([pep[0:2, 0]]))[0]
615657

616-
# Draw the lines
658+
# Handle the lines
617659
self.line.set_data(pep[0], pep[1])
618-
self.line.draw(renderer)
619660

620-
# Draw ticks
621-
self._draw_ticks(renderer, edgep1, centers, deltas, highs,
622-
deltas_per_point, pos)
661+
# Handle ticks
662+
ticks = self._get_updated_ticks(
663+
edgep1, centers, deltas, highs, deltas_per_point, pos)
623664

624-
# Draw Offset text
625-
self._draw_offset_text(renderer, edgep1, edgep2, labeldeltas,
626-
centers, highs, pep, dx, dy)
665+
# Handle Offset text
666+
self._update_offset_text(
667+
edgep1, edgep2, labeldeltas, centers, highs, pep, dx, dy)
627668

628-
for edgep1, edgep2, pos in zip(*self._get_all_axis_line_edge_points(
629-
minmax, maxmin, self._label_position)):
630-
# See comments above
631-
pep = proj3d._proj_trans_points([edgep1, edgep2], self.axes.M)
632-
pep = np.asarray(pep)
633-
dx, dy = (self.axes.transAxes.transform([pep[0:2, 1]]) -
634-
self.axes.transAxes.transform([pep[0:2, 0]]))[0]
669+
yield _UpdatedArtists(line=self.line, ticks=ticks,
670+
offset_text=self.offsetText)
671+
672+
if self.label.get_visible() and self.label.get_text():
673+
# Handle label
674+
for edgep1, edgep2, pos in zip(*self._get_all_axis_line_edge_points(
675+
minmax, maxmin, self._label_position)):
676+
# See comments above
677+
pep = proj3d._proj_trans_points([edgep1, edgep2], self.axes.M)
678+
pep = np.asarray(pep)
679+
dx, dy = (self.axes.transAxes.transform([pep[0:2, 1]]) -
680+
self.axes.transAxes.transform([pep[0:2, 0]]))[0]
681+
682+
self._update_label_position(
683+
edgep1, edgep2, labeldeltas, centers, dx, dy)
684+
685+
yield _UpdatedArtists(label=self.label)
686+
687+
@artist.allow_rasterization
688+
def draw(self, renderer):
689+
renderer.open_group('axis3d', gid=self.get_gid())
635690

636-
# Draw labels
637-
self._draw_labels(renderer, edgep1, edgep2, labeldeltas, centers, dx, dy)
691+
for updated_artists in self._update_all_positions():
692+
for art in updated_artists:
693+
art.draw(renderer)
638694

639695
renderer.close_group('axis3d')
640696
self.stale = False
@@ -689,49 +745,32 @@ def get_tightbbox(self, renderer=None, *, for_layout_only=False):
689745
# docstring inherited
690746
if not self.get_visible():
691747
return
692-
# We have to directly access the internal data structures
693-
# (and hope they are up to date) because at draw time we
694-
# shift the ticks and their labels around in (x, y) space
695-
# based on the projection, the current view port, and their
696-
# position in 3D space. If we extend the transforms framework
697-
# into 3D we would not need to do this different book keeping
698-
# than we do in the normal axis
699-
major_locs = self.get_majorticklocs()
700-
minor_locs = self.get_minorticklocs()
701-
702-
ticks = [*self.get_minor_ticks(len(minor_locs)),
703-
*self.get_major_ticks(len(major_locs))]
704-
view_low, view_high = self.get_view_interval()
705-
if view_low > view_high:
706-
view_low, view_high = view_high, view_low
707-
interval_t = self.get_transform().transform([view_low, view_high])
708-
709-
ticks_to_draw = []
710-
for tick in ticks:
711-
try:
712-
loc_t = self.get_transform().transform(tick.get_loc())
713-
except AssertionError:
714-
# Transform.transform doesn't allow masked values but
715-
# some scales might make them, so we need this try/except.
716-
pass
717-
else:
718-
if mtransforms._interval_contains_close(interval_t, loc_t):
719-
ticks_to_draw.append(tick)
720748

721-
ticks = ticks_to_draw
749+
if self.axes.M is None:
750+
self.axes.M = self.axes.get_proj()
751+
self.axes.invM = np.linalg.inv(self.axes.M)
752+
753+
bboxes = []
722754

723-
bb_1, bb_2 = self._get_ticklabel_bboxes(ticks, renderer)
724-
other = []
755+
for updated_artists in self._update_all_positions():
756+
if (ticks := updated_artists.ticks) is not None:
757+
for tlabel_bbs in self._get_ticklabel_bboxes(ticks, renderer):
758+
bboxes.extend(tlabel_bbs)
725759

726-
if self.offsetText.get_visible() and self.offsetText.get_text():
727-
other.append(self.offsetText.get_window_extent(renderer))
728-
if self.line.get_visible():
729-
other.append(self.line.get_window_extent(renderer))
730-
if (self.label.get_visible() and not for_layout_only and
731-
self.label.get_text()):
732-
other.append(self.label.get_window_extent(renderer))
760+
if (line := updated_artists.line) is not None and line.get_visible():
761+
bboxes.append(line.get_window_extent(renderer))
733762

734-
return mtransforms.Bbox.union([*bb_1, *bb_2, *other])
763+
if ((offset_text := updated_artists.offset_text) is not None and
764+
offset_text.get_visible() and offset_text.get_text()):
765+
bboxes.append(offset_text.get_window_extent(renderer))
766+
767+
if updated_artists.label is not None and not for_layout_only:
768+
bboxes.append(updated_artists.label.get_window_extent(renderer))
769+
770+
if bboxes:
771+
return mtransforms.Bbox.union(bboxes)
772+
else:
773+
return None
735774

736775
d_interval = _api.deprecated(
737776
"3.6", alternative="get_data_interval", pending=True)(
6.73 KB
Loading
35.7 KB
Loading

lib/mpl_toolkits/mplot3d/tests/test_axes3d.py

Lines changed: 42 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -194,14 +194,13 @@ def test_axes3d_primary_views():
194194
(0, 180, 0)] # -YZ
195195
# When viewing primary planes, draw the two visible axes so they intersect
196196
# at their low values
197-
fig, axs = plt.subplots(2, 3, subplot_kw={'projection': '3d'})
197+
fig, axs = plt.subplots(2, 3, subplot_kw={'projection': '3d'}, layout='tight')
198198
for i, ax in enumerate(axs.flat):
199199
ax.set_xlabel('x')
200200
ax.set_ylabel('y')
201201
ax.set_zlabel('z')
202202
ax.set_proj_type('ortho')
203203
ax.view_init(elev=views[i][0], azim=views[i][1], roll=views[i][2])
204-
plt.tight_layout()
205204

206205

207206
@mpl3d_image_comparison(['bar3d.png'], style='mpl20')
@@ -2860,6 +2859,47 @@ def test_axis_get_tightbbox_includes_offset_text():
28602859
f"bbox.y1 ({bbox.y1}) should be >= offset_bbox.y1 ({offset_bbox.y1})"
28612860

28622861

2862+
@pytest.mark.parametrize('labeltype', ['ticks', 'axis label'])
2863+
def test_axes3d_tightbbox_includes_labels(labeltype):
2864+
fig = plt.figure()
2865+
ax = fig.add_subplot(projection='3d')
2866+
2867+
renderer = fig._get_renderer()
2868+
2869+
tight_bbs = {}
2870+
2871+
if labeltype == 'axis label':
2872+
ax.set_zlabel('foo')
2873+
2874+
# Remove ticks so they don't affect the result
2875+
ax.zaxis.set_ticks_position('none')
2876+
2877+
for pos in ['lower', 'upper', 'both', 'none']:
2878+
if labeltype == 'ticks':
2879+
ax.zaxis.set_ticks_position(pos)
2880+
else:
2881+
ax.zaxis.set_label_position(pos)
2882+
tight_bbs[pos] = ax.get_tightbbox(renderer)
2883+
2884+
for pos in ['lower', 'both']:
2885+
# Should make space for labels on the left
2886+
assert tight_bbs[pos].xmin < tight_bbs['none'].xmin, \
2887+
f'No space for labels on left with {labeltype} position "{pos}"'
2888+
2889+
for pos in ['upper', 'both']:
2890+
# Should make space for labels on the right
2891+
assert tight_bbs[pos].xmax > tight_bbs['none'].xmax, \
2892+
f'No space for labels on right with {labeltype} position "{pos}"'
2893+
2894+
# No space on the right for 'lower'
2895+
assert tight_bbs['lower'].xmax == tight_bbs['none'].xmax, \
2896+
f'Unexpected space for labels on right with {labeltype} position "lower"'
2897+
2898+
# No space on the left for 'upper'
2899+
assert tight_bbs['upper'].xmin == tight_bbs['none'].xmin, \
2900+
f'Unexpected space for labels on left with {labeltype} position "{pos}"'
2901+
2902+
28632903
def test_ctrl_rotation_snaps_to_5deg():
28642904
fig = plt.figure()
28652905
ax = fig.add_subplot(projection='3d')
@@ -3270,24 +3310,3 @@ def test_plot_surface_log_scale_invalid_values():
32703310

32713311
zmin, zmax = ax.get_zlim()
32723312
assert 1e-3 < zmin < zmax < 1e3, f"zlim corrupted: {(zmin, zmax)}"
3273-
3274-
3275-
def test_axes3d_tightbbox_includes_axis_labels():
3276-
fig = plt.figure()
3277-
ax = fig.add_subplot(projection='3d')
3278-
ax.scatter([1], [1], [1])
3279-
3280-
fig.draw_without_rendering()
3281-
renderer = fig._get_renderer()
3282-
bb_no_labels = ax.get_tightbbox(renderer)
3283-
3284-
ax.set_xlabel('X label')
3285-
ax.set_ylabel('Y label')
3286-
ax.set_zlabel('Z label')
3287-
3288-
fig.draw_without_rendering()
3289-
bb_full = ax.get_tightbbox(renderer)
3290-
3291-
# The full bbox must be strictly larger in at least one dimension than the
3292-
# bbox not including labels.
3293-
assert bb_full.width > bb_no_labels.width or bb_full.height > bb_no_labels.height

0 commit comments

Comments
 (0)