From 3e7188bc07c103f56a6758941b840210c7300446 Mon Sep 17 00:00:00 2001 From: SharadhNaidu Date: Mon, 18 May 2026 17:23:03 +0530 Subject: [PATCH] Fix radial gridlines and outer spine collapsing on polar plots Path.arc previously unwrapped the angular span to fit within 360 degrees, which silently collapsed near-but-not-quite-full-circle spans caused by floating-point noise (delta = 360 + epsilon) into near-empty arcs. This dropped the outer spine in several polar configurations and skipped radial gridlines drawn via the chunking loop in PolarTransform.transform_path_non_affine. Add an unwrap_angles keyword-only parameter to Path.arc (default True, preserving existing behaviour). PolarTransform's chunking loop is preserved unchanged; only the final partial-chunk Path.arc call uses unwrap_angles=False, which is exactly where the FP edge case triggers. Spine._adjust_location's polar branch also passes unwrap_angles=False so the outer spine is no longer collapsed when set_theta_offset is set. Closes #20388 Closes #26972 --- .../next_whats_new/path_arc_unwrap_angles.rst | 13 +++++ lib/matplotlib/path.py | 34 +++++++++---- lib/matplotlib/path.pyi | 8 ++- lib/matplotlib/projections/polar.py | 11 ++-- lib/matplotlib/spines.py | 7 ++- lib/matplotlib/tests/test_path.py | 29 +++++++++++ lib/matplotlib/tests/test_polar.py | 51 +++++++++++++++++++ 7 files changed, 136 insertions(+), 17 deletions(-) create mode 100644 doc/release/next_whats_new/path_arc_unwrap_angles.rst diff --git a/doc/release/next_whats_new/path_arc_unwrap_angles.rst b/doc/release/next_whats_new/path_arc_unwrap_angles.rst new file mode 100644 index 000000000000..548620e4545e --- /dev/null +++ b/doc/release/next_whats_new/path_arc_unwrap_angles.rst @@ -0,0 +1,13 @@ +``Path.arc`` can opt out of angular unwrapping +---------------------------------------------- +`~matplotlib.path.Path.arc` now accepts an *unwrap_angles* keyword-only +parameter. The default value ``True`` preserves the previous behaviour +of collapsing requests for arcs spanning more than 360 degrees to the +shortest equivalent arc. Passing ``unwrap_angles=False`` honours the +caller's exact angular span. + +The primary motivation is the floating-point edge case where a delta +of nearly-but-not-exactly 360 degrees was unwrapped to a near-empty +arc, which is the root cause of the polar gridline and outer-spine +collapse fixed in this release. The parameter also lets callers +generate multi-turn arcs (spans greater than 360 degrees) directly. diff --git a/lib/matplotlib/path.py b/lib/matplotlib/path.py index f65ade669167..35664a5469ca 100644 --- a/lib/matplotlib/path.py +++ b/lib/matplotlib/path.py @@ -969,14 +969,21 @@ def unit_circle_righthalf(cls): return cls._unit_circle_righthalf @classmethod - def arc(cls, theta1, theta2, n=None, is_wedge=False): + def arc(cls, theta1, theta2, n=None, is_wedge=False, *, + unwrap_angles=True): """ Return a `Path` for the unit circle arc from angles *theta1* to *theta2* (in degrees). - *theta2* is unwrapped to produce the shortest arc within 360 degrees. - That is, if *theta2* > *theta1* + 360, the arc will be from *theta1* to - *theta2* - 360 and not a full circle plus some extra overlap. + If *unwrap_angles* is True (the default), *theta2* is unwrapped to + produce the shortest arc within 360 degrees: if *theta2* > *theta1* + + 360, the arc will be from *theta1* to *theta2* - 360 and not a + full circle plus some extra overlap. + + If *unwrap_angles* is False, *theta1* and *theta2* are used as + given, which lets callers produce arcs spanning more than 360 + degrees (or avoid the floating-point edge case where a near-360 + delta is collapsed to a near-zero arc by the unwrap step). If *n* is provided, it is the number of spline segments to make. If *n* is not provided, the number of spline segments is @@ -985,15 +992,22 @@ def arc(cls, theta1, theta2, n=None, is_wedge=False): Masionobe, L. 2003. `Drawing an elliptical arc using polylines, quadratic or cubic Bezier curves `_. + + .. versionchanged:: 3.11 + Added the *unwrap_angles* keyword-only parameter. """ halfpi = np.pi * 0.5 - eta1 = theta1 - eta2 = theta2 - 360 * np.floor((theta2 - theta1) / 360) - # Ensure 2pi range is not flattened to 0 due to floating-point errors, - # but don't try to expand existing 0 range. - if theta2 != theta1 and eta2 <= eta1: - eta2 += 360 + if unwrap_angles: + eta1 = theta1 + eta2 = theta2 - 360 * np.floor((theta2 - theta1) / 360) + # Ensure 2pi range is not flattened to 0 due to floating-point + # errors, but don't try to expand existing 0 range. + if theta2 != theta1 and eta2 <= eta1: + eta2 += 360 + else: + eta1 = theta1 + eta2 = theta2 eta1, eta2 = np.deg2rad([eta1, eta2]) # number of curve segments to make diff --git a/lib/matplotlib/path.pyi b/lib/matplotlib/path.pyi index 8a5a5c03792e..983f615cb6ee 100644 --- a/lib/matplotlib/path.pyi +++ b/lib/matplotlib/path.pyi @@ -119,7 +119,13 @@ class Path: def unit_circle_righthalf(cls) -> Path: ... @classmethod def arc( - cls, theta1: float, theta2: float, n: int | None = ..., is_wedge: bool = ... + cls, + theta1: float, + theta2: float, + n: int | None = ..., + is_wedge: bool = ..., + *, + unwrap_angles: bool = ..., ) -> Path: ... @classmethod def wedge(cls, theta1: float, theta2: float, n: int | None = ...) -> Path: ... diff --git a/lib/matplotlib/projections/polar.py b/lib/matplotlib/projections/polar.py index 9d999dde2f6f..b526185cec51 100644 --- a/lib/matplotlib/projections/polar.py +++ b/lib/matplotlib/projections/polar.py @@ -86,9 +86,6 @@ def transform_path_non_affine(self, path): xys.extend(self.transform_non_affine(trs)) codes.append(Path.LINETO) elif r == last_r: # Same radius: draw an arc. - # The following is complicated by Path.arc() being - # "helpful" and unwrapping the angles, but we don't want - # that behavior here. last_td, td = np.rad2deg([last_t, t]) if self._use_rmin and self._axis is not None: r = ((r - self._get_rorigin()) @@ -99,7 +96,11 @@ def transform_path_non_affine(self, path): xys.extend(arc.vertices[1:] * r) codes.extend(arc.codes[1:]) last_td += 360 - arc = Path.arc(last_td, td) + # Pass unwrap_angles=False on the final partial + # chunk so a delta of nearly-but-not-exactly 360 + # degrees doesn't collapse to a near-empty arc. + # See gh-20388. + arc = Path.arc(last_td, td, unwrap_angles=False) xys.extend(arc.vertices[1:] * r) codes.extend(arc.codes[1:]) else: @@ -110,7 +111,7 @@ def transform_path_non_affine(self, path): xys.extend(arc.vertices[::-1][1:] * r) codes.extend(arc.codes[1:]) last_td -= 360 - arc = Path.arc(td, last_td) + arc = Path.arc(td, last_td, unwrap_angles=False) xys.extend(arc.vertices[::-1][1:] * r) codes.extend(arc.codes[1:]) else: # Interpolate. diff --git a/lib/matplotlib/spines.py b/lib/matplotlib/spines.py index 741491b3dc58..b51640477a5e 100644 --- a/lib/matplotlib/spines.py +++ b/lib/matplotlib/spines.py @@ -272,7 +272,12 @@ def _adjust_location(self): if low > high: low, high = high, low - self._path = mpath.Path.arc(np.rad2deg(low), np.rad2deg(high)) + # Pass unwrap_angles=False so Path.arc honours the full + # angular span; without it, floating-point noise on a + # near-360 delta can collapse the spine to a near-empty + # arc. See gh-26972. + self._path = mpath.Path.arc( + np.rad2deg(low), np.rad2deg(high), unwrap_angles=False) if self.spine_type == 'bottom': if self.axis is None: diff --git a/lib/matplotlib/tests/test_path.py b/lib/matplotlib/tests/test_path.py index fd41a4ae6a96..a0a452c914c3 100644 --- a/lib/matplotlib/tests/test_path.py +++ b/lib/matplotlib/tests/test_path.py @@ -511,6 +511,35 @@ def test_full_arc(offset): np.testing.assert_allclose(maxs, 1) +def test_arc_unwrap_angles(): + # With unwrap_angles=True (the default), Path.arc collapses requests for + # arcs spanning more than 360 degrees into the shortest equivalent arc. + short = Path.arc(0, 720) + full = Path.arc(0, 360) + assert len(short.vertices) == len(full.vertices) + + # With unwrap_angles=False, the caller's exact angular span is honoured, + # producing additional control points for multi-turn arcs that still + # trace the unit circle. + long = Path.arc(0, 720, unwrap_angles=False) + assert len(long.vertices) > len(full.vertices) + np.testing.assert_allclose(np.min(long.vertices, axis=0), -1) + np.testing.assert_allclose(np.max(long.vertices, axis=0), 1) + + # The floating-point edge case behind the polar grid bug: a span of slightly + # more than 360 degrees gets unwrapped to a near-empty arc by the + # default code path, but unwrap_angles=False preserves the full turn. + overshoot = 360 + 1e-9 + collapsed = Path.arc(0, overshoot) + # collapsed traces only ~1e-9 degrees of arc, so its extent is tiny. + assert np.ptp(collapsed.vertices, axis=0).max() < 1e-6 + preserved = Path.arc(0, overshoot, unwrap_angles=False) + np.testing.assert_allclose( + np.max(preserved.vertices, axis=0), 1, atol=1e-6) + np.testing.assert_allclose( + np.min(preserved.vertices, axis=0), -1, atol=1e-6) + + def test_disjoint_zero_length_segment(): this_path = Path( np.array([ diff --git a/lib/matplotlib/tests/test_polar.py b/lib/matplotlib/tests/test_polar.py index 6bb534b96f25..80d5b4ccb5a3 100644 --- a/lib/matplotlib/tests/test_polar.py +++ b/lib/matplotlib/tests/test_polar.py @@ -5,6 +5,7 @@ import pytest import matplotlib as mpl +from matplotlib.path import Path from matplotlib.projections.polar import RadialLocator from matplotlib import pyplot as plt from matplotlib.testing.decorators import image_comparison, check_figures_equal @@ -328,6 +329,56 @@ def test_polar_gridlines(): assert ax.yaxis.majorTicks[0].gridline.get_alpha() == .2 +@pytest.mark.parametrize('span_deg', [359.999999, 360.0, + 360 - 1e-9, 720.0]) +def test_polar_transform_constant_r_arc(span_deg): + # PolarTransform's chunking boundary used to disagree with Path.arc's + # angle-unwrap step, so an angular delta of nearly-but-not-exactly + # 360 degrees collapsed to a near-empty arc. Apply the transform to + # a constant-r path of varying angular span and check that the + # result remains a non-degenerate circle. + fig, ax = plt.subplots(subplot_kw={'projection': 'polar'}) + fig.canvas.draw() + r = 1.0 + path = Path([(0.0, r), (np.deg2rad(span_deg), r)], + [Path.MOVETO, Path.LINETO]) + path._interpolation_steps = 100 + out = ax.transProjection.transform_path_non_affine(path) + spread = np.ptp(out.vertices, axis=0) + assert spread.min() > r * 1.5, ( + f"transformed arc collapsed for span={span_deg}: spread={spread}") + + +@pytest.mark.parametrize('angle', [10, 20, 30, 45, 90, 110]) +def test_polar_inverted_theta_outer_spine(angle): + # With set_theta_direction(-1) and certain values of + # set_theta_zero_location offset, the polar outer spine used to + # collapse to a near-empty arc. + fig, ax = plt.subplots(subplot_kw={'projection': 'polar'}) + ax.set_theta_direction(-1) + ax.set_theta_zero_location("N", angle) + fig.canvas.draw() + spine = ax.spines['polar'] + tpath = spine.get_transform().transform_path(spine.get_path()) + spread = np.ptp(tpath.vertices, axis=0) + assert spread.min() > 50, ( + f"outer spine collapsed for angle={angle}: spread={spread}") + + +@pytest.mark.parametrize('offset', [1.0, np.pi / 2, np.pi, 1.570796327]) +def test_polar_theta_offset_outer_spine(offset): + # set_theta_offset used to remove the outer spine outline; same + # floating-point root cause as the inverted-theta case above. + fig, ax = plt.subplots(subplot_kw={'projection': 'polar'}) + ax.set_theta_offset(offset) + fig.canvas.draw() + spine = ax.spines['polar'] + tpath = spine.get_transform().transform_path(spine.get_path()) + spread = np.ptp(tpath.vertices, axis=0) + assert spread.min() > 50, ( + f"outer spine collapsed for theta_offset={offset}: spread={spread}") + + def test_get_tightbbox_polar(): fig, ax = plt.subplots(subplot_kw={'projection': 'polar'}) fig.canvas.draw()