Skip to content

Commit 03ffe5c

Browse files
committed
FIX: snap near-integer arc windings to a full circle (matplotlib#20388, matplotlib#26972)
On polar plots, radial gridlines and the outer spine could collapse to a near-empty arc. The root cause is Path.arc's angle-unwrap step: a span of 360*n plus a tiny floating-point rounding error was reduced modulo 360 to a near-zero arc instead of a full circle. Detect spans that are within floating-point tolerance of a whole number of turns and draw a complete circle; otherwise unwrap theta2 to the shortest arc within 360 degrees as before. The change is byte-identical for all non-degenerate inputs, so no call sites need to change. Adds unit coverage for the snap/unwrap behaviour and end-to-end polar gridline and spine regression tests.
1 parent 1e6bcaf commit 03ffe5c

3 files changed

Lines changed: 88 additions & 5 deletions

File tree

lib/matplotlib/path.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -978,6 +978,11 @@ def arc(cls, theta1, theta2, n=None, is_wedge=False):
978978
That is, if *theta2* > *theta1* + 360, the arc will be from *theta1* to
979979
*theta2* - 360 and not a full circle plus some extra overlap.
980980
981+
As a special case, if the span *theta2* - *theta1* is within
982+
floating-point tolerance of a whole number of turns, a complete circle
983+
is drawn rather than collapsing a delta of 360*n plus a tiny rounding
984+
error to a near-empty arc (matplotlib issues #20388 and #26972).
985+
981986
If *n* is provided, it is the number of spline segments to make.
982987
If *n* is not provided, the number of spline segments is
983988
determined based on the delta between *theta1* and *theta2*.
@@ -989,11 +994,17 @@ def arc(cls, theta1, theta2, n=None, is_wedge=False):
989994
halfpi = np.pi * 0.5
990995

991996
eta1 = theta1
992-
eta2 = theta2 - 360 * np.floor((theta2 - theta1) / 360)
993-
# Ensure 2pi range is not flattened to 0 due to floating-point errors,
994-
# but don't try to expand existing 0 range.
995-
if theta2 != theta1 and eta2 <= eta1:
996-
eta2 += 360
997+
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):
1005+
eta2 = theta1 + 360
1006+
else:
1007+
eta2 = theta2 - 360 * np.floor(n_turns)
9971008
eta1, eta2 = np.deg2rad([eta1, eta2])
9981009

9991010
# number of curve segments to make

lib/matplotlib/tests/test_path.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,27 @@ 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])
515+
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.
519+
full = Path.arc(0, 360)
520+
snapped = Path.arc(0, theta2)
521+
assert len(snapped.vertices) == len(full.vertices)
522+
np.testing.assert_allclose(np.min(snapped.vertices, axis=0), -1, atol=1e-12)
523+
np.testing.assert_allclose(np.max(snapped.vertices, axis=0), 1, atol=1e-12)
524+
525+
526+
def test_arc_unwrap_partial_turn():
527+
# A span comfortably more than a whole number of turns (not near-integer)
528+
# is still unwrapped to the equivalent shortest arc within 360 degrees.
529+
np.testing.assert_allclose(Path.arc(0, 410).vertices,
530+
Path.arc(0, 50).vertices)
531+
np.testing.assert_allclose(Path.arc(0, 540).vertices,
532+
Path.arc(0, 180).vertices)
533+
534+
514535
def test_disjoint_zero_length_segment():
515536
this_path = Path(
516537
np.array([

lib/matplotlib/tests/test_polar.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import pytest
66

77
import matplotlib as mpl
8+
from matplotlib.path import Path
89
from matplotlib.projections.polar import RadialLocator
910
from matplotlib import pyplot as plt
1011
from matplotlib.testing.decorators import image_comparison, check_figures_equal
@@ -328,6 +329,56 @@ def test_polar_gridlines():
328329
assert ax.yaxis.majorTicks[0].gridline.get_alpha() == .2
329330

330331

332+
@pytest.mark.parametrize('span_deg', [359.999999, 360.0,
333+
360 - 1e-9, 720.0])
334+
def test_polar_transform_constant_r_arc(span_deg):
335+
# PolarTransform's chunking boundary used to disagree with Path.arc's
336+
# angle-unwrap step, so an angular delta of nearly-but-not-exactly
337+
# 360 degrees collapsed to a near-empty arc. Apply the transform to
338+
# a constant-r path of varying angular span and check that the
339+
# result remains a non-degenerate circle.
340+
fig, ax = plt.subplots(subplot_kw={'projection': 'polar'})
341+
fig.canvas.draw()
342+
r = 1.0
343+
path = Path([(0.0, r), (np.deg2rad(span_deg), r)],
344+
[Path.MOVETO, Path.LINETO])
345+
path._interpolation_steps = 100
346+
out = ax.transProjection.transform_path_non_affine(path)
347+
spread = np.ptp(out.vertices, axis=0)
348+
assert spread.min() > r * 1.5, (
349+
f"transformed arc collapsed for span={span_deg}: spread={spread}")
350+
351+
352+
@pytest.mark.parametrize('angle', [10, 20, 30, 45, 90, 110])
353+
def test_polar_inverted_theta_outer_spine(angle):
354+
# With set_theta_direction(-1) and certain values of
355+
# set_theta_zero_location offset, the polar outer spine used to
356+
# collapse to a near-empty arc.
357+
fig, ax = plt.subplots(subplot_kw={'projection': 'polar'})
358+
ax.set_theta_direction(-1)
359+
ax.set_theta_zero_location("N", angle)
360+
fig.canvas.draw()
361+
spine = ax.spines['polar']
362+
tpath = spine.get_transform().transform_path(spine.get_path())
363+
spread = np.ptp(tpath.vertices, axis=0)
364+
assert spread.min() > 50, (
365+
f"outer spine collapsed for angle={angle}: spread={spread}")
366+
367+
368+
@pytest.mark.parametrize('offset', [1.0, np.pi / 2, np.pi, 1.570796327])
369+
def test_polar_theta_offset_outer_spine(offset):
370+
# set_theta_offset used to remove the outer spine outline; same
371+
# floating-point root cause as the inverted-theta case above.
372+
fig, ax = plt.subplots(subplot_kw={'projection': 'polar'})
373+
ax.set_theta_offset(offset)
374+
fig.canvas.draw()
375+
spine = ax.spines['polar']
376+
tpath = spine.get_transform().transform_path(spine.get_path())
377+
spread = np.ptp(tpath.vertices, axis=0)
378+
assert spread.min() > 50, (
379+
f"outer spine collapsed for theta_offset={offset}: spread={spread}")
380+
381+
331382
def test_get_tightbbox_polar():
332383
fig, ax = plt.subplots(subplot_kw={'projection': 'polar'})
333384
fig.canvas.draw()

0 commit comments

Comments
 (0)