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
24 changes: 19 additions & 5 deletions lib/matplotlib/path.py
Original file line number Diff line number Diff line change
Expand Up @@ -978,6 +978,11 @@ def arc(cls, theta1, theta2, n=None, is_wedge=False):
That is, if *theta2* > *theta1* + 360, the arc will be from *theta1* to
*theta2* - 360 and not a full circle plus some extra overlap.

As a special case, if the span *theta2* - *theta1* is within
floating-point tolerance of a whole number of turns, a complete circle
is drawn rather than collapsing a delta of 360*n plus a tiny rounding
error to a near-empty arc (matplotlib issues #20388 and #26972).

If *n* is provided, it is the number of spline segments to make.
If *n* is not provided, the number of spline segments is
determined based on the delta between *theta1* and *theta2*.
Expand All @@ -989,11 +994,20 @@ def arc(cls, theta1, theta2, n=None, is_wedge=False):
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
n_turns = (theta2 - theta1) / 360
nearest_turn = np.rint(n_turns)
# If the span is within floating-point tolerance of a non-zero whole
# number of turns, draw a complete circle; otherwise unwrap *theta2*
# to the shortest arc within 360 degrees. Without the snap, a span of
# 360*n plus a tiny rounding error is reduced modulo 360 to a
# near-empty arc (the polar gridline/spine collapse). The error from
# the polar transform pipeline is at the machine-epsilon level (under
# 1e-15 turns in practice), so this tolerance reliably snaps genuine
# full turns while leaving any intentionally non-integer span alone.
if nearest_turn != 0 and abs(n_turns - nearest_turn) <= 1e-12:
eta2 = theta1 + 360
else:
eta2 = theta2 - 360 * np.floor(n_turns)
eta1, eta2 = np.deg2rad([eta1, eta2])

# number of curve segments to make
Expand Down
41 changes: 41 additions & 0 deletions lib/matplotlib/tests/test_path.py
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,47 @@ def test_full_arc(offset):
np.testing.assert_allclose(maxs, 1)


@pytest.mark.parametrize('theta2', [
360, 720, 360 * 5, # exact whole turns
np.nextafter(360, 1e6), # +1 ulp: realistic float noise
np.nextafter(360, 0), # -1 ulp
np.nextafter(720, 1e6),
])
def test_arc_full_circle_snap(theta2):
# A span within floating-point tolerance of a whole number of turns must
# draw a complete circle, not collapse to a near-empty arc. This is the
# floating-point edge case behind gh-20388 and gh-26972.
full = Path.arc(0, 360)
snapped = Path.arc(0, theta2)
assert len(snapped.vertices) == len(full.vertices)
np.testing.assert_allclose(np.min(snapped.vertices, axis=0), -1, atol=1e-12)
np.testing.assert_allclose(np.max(snapped.vertices, axis=0), 1, atol=1e-12)


@pytest.mark.parametrize('theta1, theta2', [(0, -360), (0, -720), (360, 0),
(10, -350)])
def test_arc_negative_full_circle(theta1, theta2):
# An exact negative multiple of 360 must still draw a complete circle,
# matching the legacy behaviour (regression guard for the unwrap rework).
# The result is the same complete circle as the equivalent positive turn
# starting from *theta1* (so the assertion holds for non-cardinal starts).
np.testing.assert_allclose(Path.arc(theta1, theta2).vertices,
Path.arc(theta1, theta1 + 360).vertices)


def test_arc_unwrap_partial_turn():
# A span comfortably more than a whole number of turns (not near-integer)
# is still unwrapped to the equivalent shortest arc within 360 degrees.
np.testing.assert_allclose(Path.arc(0, 410).vertices,
Path.arc(0, 50).vertices)
np.testing.assert_allclose(Path.arc(0, 540).vertices,
Path.arc(0, 180).vertices)
# A span a clear fraction of a degree past a full turn is the caller's
# explicit request and must NOT be snapped to a circle (tolerance guard).
np.testing.assert_allclose(Path.arc(0, 360.001).vertices,
Path.arc(0, 0.001).vertices)


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