Skip to content

Commit 26c160c

Browse files
committed
Address review: tighten arc winding tolerance and fix negative spans
- Replace the 1e-9 magic tolerance with 1e-12 turns, justified by the observed error from the polar transform pipeline (machine-epsilon level, under 1e-15 turns), via np.rint(n_turns). - Snap on any non-zero whole number of turns (abs winding), restoring the legacy full-circle behaviour for exact negative multiples such as Path.arc(0, -360); the previous rework collapsed those to an empty arc. - Add regression tests for negative full circles and a tolerance-tightness guard (a span just past a full turn must not snap).
1 parent 03ffe5c commit 26c160c

2 files changed

Lines changed: 34 additions & 11 deletions

File tree

lib/matplotlib/path.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -995,13 +995,16 @@ def arc(cls, theta1, theta2, n=None, is_wedge=False):
995995

996996
eta1 = theta1
997997
n_turns = (theta2 - theta1) / 360
998-
# If the requested span is within floating-point tolerance of a whole
999-
# number of turns, draw a complete circle. Otherwise unwrap *theta2*
1000-
# to the shortest arc within 360 degrees. Snapping near-integer
1001-
# windings here avoids the edge case where a delta of 360*n plus a
1002-
# tiny rounding error would otherwise collapse to a near-empty arc.
1003-
if (theta2 != theta1 and round(n_turns) >= 1
1004-
and abs(n_turns - round(n_turns)) < 1e-9):
998+
nearest_turn = np.rint(n_turns)
999+
# If the span is within floating-point tolerance of a non-zero whole
1000+
# number of turns, draw a complete circle; otherwise unwrap *theta2*
1001+
# to the shortest arc within 360 degrees. Without the snap, a span of
1002+
# 360*n plus a tiny rounding error is reduced modulo 360 to a
1003+
# near-empty arc (the polar gridline/spine collapse). The error from
1004+
# the polar transform pipeline is at the machine-epsilon level (under
1005+
# 1e-15 turns in practice), so this tolerance reliably snaps genuine
1006+
# full turns while leaving any intentionally non-integer span alone.
1007+
if nearest_turn != 0 and abs(n_turns - nearest_turn) <= 1e-12:
10051008
eta2 = theta1 + 360
10061009
else:
10071010
eta2 = theta2 - 360 * np.floor(n_turns)

lib/matplotlib/tests/test_path.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -511,25 +511,45 @@ def test_full_arc(offset):
511511
np.testing.assert_allclose(maxs, 1)
512512

513513

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

525530

531+
@pytest.mark.parametrize('theta1, theta2', [(0, -360), (0, -720), (360, 0),
532+
(10, -350)])
533+
def test_arc_negative_full_circle(theta1, theta2):
534+
# An exact negative multiple of 360 must still draw a complete circle,
535+
# matching the legacy behaviour (regression guard for the unwrap rework).
536+
# The result is the same complete circle as the equivalent positive turn
537+
# starting from *theta1* (so the assertion holds for non-cardinal starts).
538+
np.testing.assert_allclose(Path.arc(theta1, theta2).vertices,
539+
Path.arc(theta1, theta1 + 360).vertices)
540+
541+
526542
def test_arc_unwrap_partial_turn():
527543
# A span comfortably more than a whole number of turns (not near-integer)
528544
# is still unwrapped to the equivalent shortest arc within 360 degrees.
529545
np.testing.assert_allclose(Path.arc(0, 410).vertices,
530546
Path.arc(0, 50).vertices)
531547
np.testing.assert_allclose(Path.arc(0, 540).vertices,
532548
Path.arc(0, 180).vertices)
549+
# A span a clear fraction of a degree past a full turn is the caller's
550+
# explicit request and must NOT be snapped to a circle (tolerance guard).
551+
np.testing.assert_allclose(Path.arc(0, 360.001).vertices,
552+
Path.arc(0, 0.001).vertices)
533553

534554

535555
def test_disjoint_zero_length_segment():

0 commit comments

Comments
 (0)