Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions doc/release/next_whats_new/path_arc_unwrap_angles.rst
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 24 additions & 10 deletions lib/matplotlib/path.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
<https://web.archive.org/web/20190318044212/http://www.spaceroots.org/documents/ellipse/index.html>`_.

.. 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
Expand Down
8 changes: 7 additions & 1 deletion lib/matplotlib/path.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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: ...
Expand Down
11 changes: 6 additions & 5 deletions lib/matplotlib/projections/polar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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:
Expand All @@ -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.
Expand Down
7 changes: 6 additions & 1 deletion lib/matplotlib/spines.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
29 changes: 29 additions & 0 deletions lib/matplotlib/tests/test_path.py
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
51 changes: 51 additions & 0 deletions lib/matplotlib/tests/test_polar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
Loading