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
131 changes: 97 additions & 34 deletions lib/matplotlib/path.py
Original file line number Diff line number Diff line change
Expand Up @@ -969,36 +969,77 @@ def unit_circle_righthalf(cls):
return cls._unit_circle_righthalf

@classmethod
def arc(cls, theta1, theta2, n=None, is_wedge=False):
def arc(cls, theta1, theta2, n=None, is_wedge=False, wrap=True):
"""
Return a `Path` for the unit circle arc from angles *theta1* to
*theta2* (in degrees).
Return a `Path` for a counter-clockwise unit circle arc from angles
*theta1* to *theta2* (in degrees).

*theta2* is unwrapped to produce the shortest arc within 360 degrees.
That is, if *theta2* > *theta1* + 360, the arc will be from *theta1* to
*theta2* - 360 and not a full circle plus some extra overlap.

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*.
Parameters
----------
theta1, theta2 : float
The starting and ending angles (in degrees) of the arc, measured
counter-clockwise from the positive x-axis.

The arc is always drawn counter-clockwise from *theta1* to
*theta2*. If *theta2* < *theta1*, the arc wraps around. For
example, an arc from 90° to 70° travels 340° counter-clockwise
(through 90° → 180° → 270° → 360°/0° → 70°).

By default (*wrap*=True) the arc is limited to span at most 360°,
so an arc from 0° to 700° would be drawn as a 340° arc. If
*wrap* is False, the full span from *theta1* to *theta2* is drawn,
which can exceed 360° (e.g., 0° to 700° draws a full circle and a
340° arc).

n : int, optional
The number of spline segments to make. If not provided, the number
of spline segments is determined based on the delta between
*theta1* and *theta2*.

is_wedge : bool, default: False
If True, return a wedge: a pie-slice shape consisting of the arc
with lines connecting the endpoints to the origin (0, 0). If False,
return just the arc itself.

wrap : bool, default: True
Whether to limit the arc to span at most 360°.

If True, the angular span (*theta2* - *theta1*) is wrapped using
modulo 360°. Spans that are exact multiples of 360° (e.g., 360°,
720°, 1080°) are preserved as full circles (360°), while other
spans are reduced (e.g., 400° becomes 40°). If False, the full span
from *theta1* to *theta2* is drawn, which can exceed 360° (e.g., 0°
to 700° draws a full circle and a 340° arc).

Masionobe, L. 2003. `Drawing an elliptical arc using
polylines, quadratic or cubic Bezier curves
<https://web.archive.org/web/20190318044212/http://www.spaceroots.org/documents/ellipse/index.html>`_.
Notes
-----
The arc is approximated using cubic Bézier curves, as described in
Masionobe, L. 2003. `Drawing an elliptical arc using polylines,
quadratic or cubic Bezier curves
<https://web.archive.org/web/20190318044212/http://www.spaceroots.org/documents/ellipse/index.html>`_.
"""
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
if wrap:
# Limit theta2 to be at most 360 degrees from theta1.
eta2 = np.mod(theta2 - theta1, 360.0) + theta1
# Ensure 360-deg 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
else:
eta2 = theta2
Comment thread
dstansby marked this conversation as resolved.
eta1, eta2 = np.deg2rad([eta1, eta2])

# number of curve segments to make
if n is None:
n = int(2 ** np.ceil((eta2 - eta1) / halfpi))
if np.abs(eta2 - eta1) <= 2 * np.pi + 1e-3:
# this doesn't need to grow exponentially, but we have left
# this way for back compatibility
n = int(2 ** np.ceil(2 * np.abs(eta2 - eta1) / np.pi))
else:
# this will grow linearly if we allow wrapping arcs:
n = int(2 * np.ceil(2 * np.abs(eta2 - eta1) / np.pi))
Comment thread
dstansby marked this conversation as resolved.
if n < 1:
raise ValueError("n must be >= 1 or None")

Expand Down Expand Up @@ -1048,22 +1089,44 @@ def arc(cls, theta1, theta2, n=None, is_wedge=False):
return cls(vertices, codes, readonly=True)

@classmethod
def wedge(cls, theta1, theta2, n=None):
def wedge(cls, theta1, theta2, n=None, wrap=True):
"""
Return a `Path` for the unit circle wedge from angles *theta1* to
*theta2* (in degrees).

*theta2* is unwrapped to produce the shortest wedge within 360 degrees.
That is, if *theta2* > *theta1* + 360, the wedge will be from *theta1*
to *theta2* - 360 and not a full circle plus some extra overlap.

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*.
Return a `Path` for a counter-clockwise unit circle wedge from angles
*theta1* to *theta2* (in degrees).

See `Path.arc` for the reference on the approximation used.
"""
return cls.arc(theta1, theta2, n, True)
Parameters
----------
theta1, theta2 : float
The starting and ending angles (in degrees) of the wedge, measured
counter-clockwise from the positive x-axis.

The wedge is always drawn counter-clockwise from *theta1* to
*theta2*. If *theta2* < *theta1*, the wedge wraps around. For
example, a wedge from 90° to 70° spans 340° counter-clockwise
(through 90° → 180° → 270° → 360°/0° → 70°).

By default (*wrap*=True) the wedge is limited to span at most 360°,
so a wedge from 0° to 700° would be drawn as a 340° wedge. If
*wrap* is False, the full span from *theta1* to *theta2* is drawn,
which can exceed 360° (e.g., 0° to 700° draws a full circle and a
340° wedge).

n : int, optional
The number of spline segments to make. If not provided, the number
of spline segments is determined based on the delta between
*theta1* and *theta2*.

wrap : bool, default: True
Whether to limit the wedge to span at most 360°.

If True, the angular span (*theta2* - *theta1*) is wrapped using
modulo 360°. Spans that are exact multiples of 360° (e.g., 360°,
720°, 1080°) are preserved as full circles (360°), while other
spans are reduced (e.g., 400° becomes 40°). If False, the full span
from *theta1* to *theta2* is drawn, which can exceed 360° (e.g., 0°
to 700° draws a full circle and a 340° arc).
"""
return cls.arc(theta1, theta2, n, is_wedge=True, wrap=wrap)

@staticmethod
@lru_cache(8)
Expand Down
8 changes: 6 additions & 2 deletions lib/matplotlib/path.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +119,14 @@ class Path:
def unit_circle_righthalf(cls) -> Path: ...
@classmethod
def arc(
cls, theta1: float, theta2: float, n: int | None = ..., is_wedge: bool = ...
cls, theta1: float, theta2: float, n: int | None = ...,
is_wedge: bool = ..., wrap: bool = ...
) -> Path: ...
@classmethod
def wedge(cls, theta1: float, theta2: float, n: int | None = ...) -> Path: ...
def wedge(
cls, theta1: float, theta2: float, n: int | None = ...,
wrap: bool = ...
) -> Path: ...
@overload
@staticmethod
def hatch(hatchpattern: str, density: float = ...) -> Path: ...
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
43 changes: 42 additions & 1 deletion lib/matplotlib/tests/test_path.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from matplotlib import patches
from matplotlib.path import Path
from matplotlib.patches import Polygon
from matplotlib.testing.decorators import image_comparison
from matplotlib.testing.decorators import image_comparison, check_figures_equal
import matplotlib.pyplot as plt
from matplotlib import transforms
from matplotlib.backend_bases import MouseEvent
Expand Down Expand Up @@ -511,6 +511,47 @@ def test_full_arc(offset):
np.testing.assert_allclose(maxs, 1)


@check_figures_equal()
def test_arc_close360(fig_test, fig_ref):
# check that the wrap at 360 is treated properly.
axs_test = fig_test.subplots(1, 2)
axs_ref = fig_ref.subplots(1, 2)

# these should be the same
axs_ref[0].add_patch(patches.PathPatch(Path.arc(theta1=-90, theta2=270)))
axs_test[0].add_patch(patches.PathPatch(Path.arc(theta1=-90-1e-14, theta2=270)))

# there should be no patch drawn for the test case:
axs_test[1].add_patch(patches.PathPatch(Path.arc(theta1=-90-1e-14, theta2=-90)))

for a in [axs_test, axs_ref]:
for num in range(2):
a[num].set_xlim(-1, 1)
a[num].set_ylim(-1, 1)
a[num].set_aspect("equal")


@image_comparison(['arc_wrap_false'], style='default', remove_text=True,
extensions=['png'])
def test_arc_wrap_false():
_, ax = plt.subplots(3, 2)
ax = ax.flatten()
ax[0].add_patch(patches.PathPatch(Path.arc(theta1=10, theta2=20,
is_wedge=True, wrap=True)))
ax[1].add_patch(patches.PathPatch(Path.arc(theta1=10, theta2=380,
is_wedge=True, wrap=True)))
ax[2].add_patch(patches.PathPatch(Path.arc(theta1=10, theta2=20,
is_wedge=True, wrap=False)))
ax[3].add_patch(patches.PathPatch(Path.arc(theta1=10, theta2=740,
is_wedge=True, wrap=False)))
ax[4].add_patch(patches.PathPatch(Path.arc(theta1=10, theta2=740,
is_wedge=True, wrap=True)))
for a in ax:
a.set_xlim(-1, 1)
a.set_ylim(-1, 1)
a.set_aspect("equal")


def test_disjoint_zero_length_segment():
this_path = Path(
np.array([
Expand Down
Loading