Skip to content
Merged
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
23 changes: 18 additions & 5 deletions lib/matplotlib/path.py
Original file line number Diff line number Diff line change
Expand Up @@ -978,6 +978,10 @@ 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.

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 +993,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)
is_full_circle = nearest_turn != 0 and abs(n_turns - nearest_turn) <= 1e-12
# We unwrap *theta2* to the shortest arc within 360 degrees.
# Full circles need special handling as floating point errors can
# make a full circle have 360° + eps, which would be unwrapped
# to eps only, i.e. collapsing the full circle to an infinitesimal arc.
# The threshold of 1e-12 is a defensive choice: Much larger than
# numeric precision errors (~1e-15) but still smaller than any
# expected real-world arcs.
if is_full_circle:
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
36 changes: 36 additions & 0 deletions lib/matplotlib/tests/test_path.py
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,42 @@ 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.
np.testing.assert_allclose(Path.arc(0, theta2).vertices,
Path.arc(0, 360).vertices)


@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 draw a complete circle.
# 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 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
22 changes: 22 additions & 0 deletions lib/matplotlib/tests/test_polar.py
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,28 @@ def test_polar_gridlines():
assert ax.yaxis.majorTicks[0].gridline.get_alpha() == .2


@pytest.mark.parametrize('theta_zero_location, theta_offset', [
(("N", 20), None),
(("N", 30), None),
(None, 1.570796327),
])
def test_polar_outer_spine_not_collapsed(theta_zero_location, theta_offset):
# The polar outer spine spans a full turn via Path.arc. For some theta
# offsets/directions, floating-point error left the span just short of a
# full 360 degrees and the spine collapsed to a near-empty arc. Check it
# still occupies a sensible area.
fig, ax = plt.subplots(subplot_kw={'projection': 'polar'})
if theta_zero_location is not None:
ax.set_theta_direction(-1)
ax.set_theta_zero_location(*theta_zero_location)
if theta_offset is not None:
ax.set_theta_offset(theta_offset)
fig.canvas.draw()
# A collapsed spine has a near-zero (~1e-13) bounding box; a healthy one is
# hundreds of points across.
assert ax.spines['polar'].get_tightbbox().size.min() > 1


def test_get_tightbbox_polar():
fig, ax = plt.subplots(subplot_kw={'projection': 'polar'})
fig.canvas.draw()
Expand Down
Loading