Skip to content
Closed
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
21 changes: 16 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,17 @@ 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
# If the requested span is within floating-point tolerance of a whole
# number of turns, draw a complete circle. Otherwise unwrap *theta2*
# to the shortest arc within 360 degrees. Snapping near-integer
# windings here avoids the edge case where a delta of 360*n plus a
# tiny rounding error would otherwise collapse to a near-empty arc.
if (theta2 != theta1 and round(n_turns) >= 1
and abs(n_turns - round(n_turns)) < 1e-9):
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
21 changes: 21 additions & 0 deletions lib/matplotlib/tests/test_path.py
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,27 @@ def test_full_arc(offset):
np.testing.assert_allclose(maxs, 1)


@pytest.mark.parametrize('theta2', [360 + 1e-9, 720, 720 + 1e-9, 360 * 5])
def test_arc_full_circle_snap(theta2):
# A span that is 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)


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)


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