Skip to content

Commit 319174c

Browse files
authored
Merge pull request matplotlib#31844 from SharadhNaidu/fix-arc-winding-snap
FIX: snap near-integer arc windings to a full circle on polar plots (matplotlib#20388, matplotlib#26972)
2 parents 1e6bcaf + e9ed519 commit 319174c

3 files changed

Lines changed: 76 additions & 5 deletions

File tree

lib/matplotlib/path.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -978,6 +978,10 @@ 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.
984+
981985
If *n* is provided, it is the number of spline segments to make.
982986
If *n* is not provided, the number of spline segments is
983987
determined based on the delta between *theta1* and *theta2*.
@@ -989,11 +993,20 @@ def arc(cls, theta1, theta2, n=None, is_wedge=False):
989993
halfpi = np.pi * 0.5
990994

991995
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
996+
n_turns = (theta2 - theta1) / 360
997+
nearest_turn = np.rint(n_turns)
998+
is_full_circle = nearest_turn != 0 and abs(n_turns - nearest_turn) <= 1e-12
999+
# We unwrap *theta2* to the shortest arc within 360 degrees.
1000+
# Full circles need special handling as floating point errors can
1001+
# make a full circle have 360° + eps, which would be unwrapped
1002+
# to eps only, i.e. collapsing the full circle to an infinitesimal arc.
1003+
# The threshold of 1e-12 is a defensive choice: Much larger than
1004+
# numeric precision errors (~1e-15) but still smaller than any
1005+
# expected real-world arcs.
1006+
if is_full_circle:
1007+
eta2 = theta1 + 360
1008+
else:
1009+
eta2 = theta2 - 360 * np.floor(n_turns)
9971010
eta1, eta2 = np.deg2rad([eta1, eta2])
9981011

9991012
# number of curve segments to make

lib/matplotlib/tests/test_path.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,42 @@ def test_full_arc(offset):
511511
np.testing.assert_allclose(maxs, 1)
512512

513513

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+
])
520+
def test_arc_full_circle_snap(theta2):
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.
523+
np.testing.assert_allclose(Path.arc(0, theta2).vertices,
524+
Path.arc(0, 360).vertices)
525+
526+
527+
@pytest.mark.parametrize('theta1, theta2', [(0, -360), (0, -720), (360, 0),
528+
(10, -350)])
529+
def test_arc_negative_full_circle(theta1, theta2):
530+
# An exact negative multiple of 360 must draw a complete circle.
531+
# The result is the same complete circle as the equivalent positive turn
532+
# starting from *theta1* (so the assertion holds for non-cardinal starts).
533+
np.testing.assert_allclose(Path.arc(theta1, theta2).vertices,
534+
Path.arc(theta1, theta1 + 360).vertices)
535+
536+
537+
def test_arc_unwrap_partial_turn():
538+
# A span comfortably more than a whole number of turns (not near-integer)
539+
# is unwrapped to the equivalent shortest arc within 360 degrees.
540+
np.testing.assert_allclose(Path.arc(0, 410).vertices,
541+
Path.arc(0, 50).vertices)
542+
np.testing.assert_allclose(Path.arc(0, 540).vertices,
543+
Path.arc(0, 180).vertices)
544+
# A span a clear fraction of a degree past a full turn is the caller's
545+
# explicit request and must NOT be snapped to a circle (tolerance guard).
546+
np.testing.assert_allclose(Path.arc(0, 360.001).vertices,
547+
Path.arc(0, 0.001).vertices)
548+
549+
514550
def test_disjoint_zero_length_segment():
515551
this_path = Path(
516552
np.array([

lib/matplotlib/tests/test_polar.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,28 @@ def test_polar_gridlines():
328328
assert ax.yaxis.majorTicks[0].gridline.get_alpha() == .2
329329

330330

331+
@pytest.mark.parametrize('theta_zero_location, theta_offset', [
332+
(("N", 20), None),
333+
(("N", 30), None),
334+
(None, 1.570796327),
335+
])
336+
def test_polar_outer_spine_not_collapsed(theta_zero_location, theta_offset):
337+
# The polar outer spine spans a full turn via Path.arc. For some theta
338+
# offsets/directions, floating-point error left the span just short of a
339+
# full 360 degrees and the spine collapsed to a near-empty arc. Check it
340+
# still occupies a sensible area.
341+
fig, ax = plt.subplots(subplot_kw={'projection': 'polar'})
342+
if theta_zero_location is not None:
343+
ax.set_theta_direction(-1)
344+
ax.set_theta_zero_location(*theta_zero_location)
345+
if theta_offset is not None:
346+
ax.set_theta_offset(theta_offset)
347+
fig.canvas.draw()
348+
# A collapsed spine has a near-zero (~1e-13) bounding box; a healthy one is
349+
# hundreds of points across.
350+
assert ax.spines['polar'].get_tightbbox().size.min() > 1
351+
352+
331353
def test_get_tightbbox_polar():
332354
fig, ax = plt.subplots(subplot_kw={'projection': 'polar'})
333355
fig.canvas.draw()

0 commit comments

Comments
 (0)