From 0c3df8a096326553572d065f1878a54ceedbeefe Mon Sep 17 00:00:00 2001 From: Muhtasim-Munif-Fahim Date: Wed, 12 Aug 2026 13:27:35 +0600 Subject: [PATCH] feat(patches): respect mutation_aspect in Round boxstyle Add opt-in "screen-proportional" mode to FancyBboxPatch.mutation_aspect that resolves the display-space aspect ratio from the axes at draw time. Rounded corners of Round/other boxstyles then render circular on screen regardless of the axes aspect ratio, without requiring the private ax._get_aspect_ratio() workaround. Closes #31175. --- lib/matplotlib/patches.py | 33 +++++++++++++++++++++-- lib/matplotlib/tests/test_patches.py | 40 ++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/lib/matplotlib/patches.py b/lib/matplotlib/patches.py index 922bd932f605..b736a4af535e 100644 --- a/lib/matplotlib/patches.py +++ b/lib/matplotlib/patches.py @@ -4053,12 +4053,18 @@ def __init__(self, xy, width, height, boxstyle="round", *, Scaling factor applied to the attributes of the box style (e.g. pad or rounding_size). - mutation_aspect : float, default: 1 + mutation_aspect : float or str, default: 1 The height of the rectangle will be squeezed by this value before the mutation and the mutated box will be stretched by the inverse of it. For example, this allows different horizontal and vertical padding. + The special value ``"screen-proportional"`` resolves the aspect + dynamically from the axes at draw time, so that the box is scaled + proportionally to the display scaling of the data. This keeps + features such as the rounding of ``"Round"`` boxstyles circular + on screen, independent of the axes aspect ratio. + Other Parameters ---------------- **kwargs : `~matplotlib.patches.Patch` properties @@ -4140,8 +4146,15 @@ def set_mutation_aspect(self, aspect): Parameters ---------- - aspect : float + aspect : float or "screen-proportional" + A float value, or the string ``"screen-proportional"`` to resolve + the aspect dynamically from the axes at draw time (see + `.FancyBboxPatch` for details). """ + if (not isinstance(aspect, (int, float)) + and aspect != "screen-proportional"): + raise ValueError( + "mutation_aspect must be a number or 'screen-proportional'") self._mutation_aspect = aspect self.stale = True @@ -4150,10 +4163,26 @@ def get_mutation_aspect(self): return (self._mutation_aspect if self._mutation_aspect is not None else 1) # backcompat. + def _get_screen_proportional_aspect(self): + """Return the mutation aspect that is isotropic in display space.""" + axes = self.axes + if axes is None: + return 1 + # Display-space size of the Axes and of one data unit in each + # direction, so that the box is scaled like the data on screen. + bbox = axes.get_window_extent() + txmin, txmax = axes.xaxis.get_transform().transform(axes.get_xbound()) + tymin, tymax = axes.yaxis.get_transform().transform(axes.get_ybound()) + xsize = max(abs(txmax - txmin), 1e-30) + ysize = max(abs(tymax - tymin), 1e-30) + return bbox.width * ysize / (bbox.height * xsize) + def get_path(self): """Return the mutated path of the rectangle.""" boxstyle = self.get_boxstyle() m_aspect = self.get_mutation_aspect() + if m_aspect == "screen-proportional": + m_aspect = self._get_screen_proportional_aspect() # Call boxstyle with y, height squeezed by aspect_ratio. path = boxstyle(self._x, self._y / m_aspect, self._width, self._height / m_aspect, diff --git a/lib/matplotlib/tests/test_patches.py b/lib/matplotlib/tests/test_patches.py index 3a79bce643ea..8e49f5bfd3fe 100644 --- a/lib/matplotlib/tests/test_patches.py +++ b/lib/matplotlib/tests/test_patches.py @@ -813,6 +813,46 @@ def test_boxstyle_errors(fmt, match): BoxStyle(fmt) +def test_fancybbox_patch_screen_proportional_mutation_aspect(): + fig, ax = plt.subplots(figsize=(10, 5)) + w, h = 0.18, 1.0 + patch = mpatches.FancyBboxPatch( + (0.3, 0), w, h, + boxstyle=BoxStyle("Round", pad=0, rounding_size=w / 2), + mutation_aspect="screen-proportional") + ax.add_patch(patch) + ax.autoscale_view() + fig.canvas.draw() + + # The corner of the Round box must be a circular arc in display space, + # i.e. the rounding is isotropic on screen. + verts = patch.get_transform().transform(patch.get_path().vertices) + # Bottom-right corner: verts[1] = (x1 - dr, y0), verts[2] = (x1, y0), + # verts[3] = (x1, y0 + dr). + np.testing.assert_allclose(verts[2, 0] - verts[1, 0], + verts[3, 1] - verts[2, 1]) + + # A plain numeric mutation_aspect does not compensate the axes aspect. + patch.set_mutation_aspect(1) + verts = patch.get_transform().transform(patch.get_path().vertices) + assert not np.isclose(verts[2, 0] - verts[1, 0], + verts[3, 1] - verts[2, 1]) + + with pytest.raises(ValueError, match="mutation_aspect"): + patch.set_mutation_aspect("bogus") + + +def test_fancybbox_patch_screen_proportional_without_axes(): + # Without an Axes the screen aspect cannot be resolved; it falls back to 1. + patch = mpatches.FancyBboxPatch( + (0, 0), 0.2, 0.1, + boxstyle=BoxStyle("Round", pad=0, rounding_size=0.1), + mutation_aspect="screen-proportional") + verts = patch.get_path().vertices + np.testing.assert_allclose(verts[2, 0] - verts[1, 0], + verts[3, 1] - verts[2, 1]) + + @image_comparison(['annulus.png'], style='mpl20') def test_annulus():