Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit bf53eaf

Browse files
brunobeltrangreglucas
authored andcommitted
code to compute bezier segment / path lengths
1 parent 40275b8 commit bf53eaf

File tree

6 files changed

+146
-7
lines changed

6 files changed

+146
-7
lines changed

lib/matplotlib/bezier.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import math
66
import warnings
7+
from collections import deque
78

89
import numpy as np
910

@@ -220,6 +221,93 @@ def point_at_t(self, t):
220221
"""
221222
return tuple(self(t))
222223

224+
def split_at_t(self, t):
225+
"""
226+
Split into two Bezier curves using de casteljau's algorithm.
227+
228+
Parameters
229+
----------
230+
t : float
231+
Point in [0,1] at which to split into two curves
232+
233+
Returns
234+
-------
235+
B1, B2 : BezierSegment
236+
The two sub-curves.
237+
"""
238+
new_cpoints = split_de_casteljau(self._cpoints, t)
239+
return BezierSegment(new_cpoints[0]), BezierSegment(new_cpoints[1])
240+
241+
def control_net_length(self):
242+
"""Sum of lengths between control points"""
243+
L = 0
244+
N, d = self._cpoints.shape
245+
for i in range(N - 1):
246+
L += np.linalg.norm(self._cpoints[i+1] - self._cpoints[i])
247+
return L
248+
249+
def arc_length(self, rtol=1e-4, atol=1e-6):
250+
"""
251+
Estimate the length using iterative refinement.
252+
253+
Our estimate is just the average between the length of the chord and
254+
the length of the control net.
255+
256+
Since the chord length and control net give lower and upper bounds
257+
(respectively) on the length, this maximum possible error is tested
258+
against an absolute tolerance threshold at each subdivision.
259+
260+
However, sometimes this estimator converges much faster than this error
261+
estimate would suggest. Therefore, the relative change in the length
262+
estimate between subdivisions is compared to a relative error tolerance
263+
after each set of subdivisions.
264+
265+
Parameters
266+
----------
267+
rtol : float, default 1e-4
268+
If :code:`abs(est[i+1] - est[i]) <= rtol * est[i+1]`, we return
269+
:code:`est[i+1]`.
270+
atol : float, default 1e-6
271+
If the distance between chord length and control length at any
272+
point falls below this number, iteration is terminated.
273+
"""
274+
chord = np.linalg.norm(self._cpoints[-1] - self._cpoints[0])
275+
net = self.control_net_length()
276+
max_err = (net - chord)/2
277+
curr_est = chord + max_err
278+
# early exit so we don't try to "split" paths of zero length
279+
if max_err < atol:
280+
return curr_est
281+
282+
prev_est = np.inf
283+
curves = deque([self])
284+
errs = deque([max_err])
285+
lengths = deque([curr_est])
286+
while np.abs(curr_est - prev_est) > rtol * curr_est:
287+
# subdivide the *whole* curve before checking relative convergence
288+
# again
289+
prev_est = curr_est
290+
num_curves = len(curves)
291+
for i in range(num_curves):
292+
curve = curves.popleft()
293+
new_curves = curve.split_at_t(0.5)
294+
max_err -= errs.popleft()
295+
curr_est -= lengths.popleft()
296+
for ncurve in new_curves:
297+
chord = np.linalg.norm(
298+
ncurve._cpoints[-1] - ncurve._cpoints[0])
299+
net = ncurve.control_net_length()
300+
nerr = (net - chord)/2
301+
nlength = chord + nerr
302+
max_err += nerr
303+
curr_est += nlength
304+
curves.append(ncurve)
305+
errs.append(nerr)
306+
lengths.append(nlength)
307+
if max_err < atol:
308+
return curr_est
309+
return curr_est
310+
223311
@property
224312
def arc_area(self):
225313
r"""

lib/matplotlib/bezier.pyi

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ class BezierSegment:
3535
def __init__(self, control_points: ArrayLike) -> None: ...
3636
def __call__(self, t: ArrayLike) -> np.ndarray: ...
3737
def point_at_t(self, t: float) -> tuple[float, ...]: ...
38+
def split_at_t(self, t: float) -> tuple[BezierSegment, BezierSegment]: ...
39+
def control_net_length(self) -> float: ...
40+
def arc_length(self, rtol: float, atol: float) -> float: ...
3841
@property
3942
def arc_area(self) -> float: ...
4043
@classmethod

lib/matplotlib/path.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,28 @@ def intersects_bbox(self, bbox, filled=True):
666666
return _path.path_intersects_rectangle(
667667
self, bbox.x0, bbox.y0, bbox.x1, bbox.y1, filled)
668668

669+
def length(self, rtol=None, atol=None, **kwargs):
670+
r"""
671+
Get length of Path.
672+
673+
Equivalent to (but not computed as)
674+
675+
.. math::
676+
677+
\sum_{j=1}^N \int_0^1 ||B'_j(t)|| dt
678+
679+
where the sum is over the :math:`N` Bezier curves that comprise the
680+
Path. Notice that this measure of length will assign zero weight to all
681+
isolated points on the Path.
682+
683+
Returns
684+
-------
685+
length : float
686+
The path length.
687+
"""
688+
return np.sum([B.arc_length(rtol, atol)
689+
for B, code in self.iter_bezier(**kwargs)])
690+
669691
def signed_area(self):
670692
"""
671693
Get signed area of the filled path.

lib/matplotlib/path.pyi

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ class Path:
9090
def get_extents(self, transform: Transform | None = ..., **kwargs) -> Bbox: ...
9191
def intersects_path(self, other: Path, filled: bool = ...) -> bool: ...
9292
def intersects_bbox(self, bbox: Bbox, filled: bool = ...) -> bool: ...
93+
def length(self, rtol: float | None, atol: float | None, **kwargs) -> float: ...
9394
def signed_area(self) -> float: ...
9495
def interpolated(self, steps: int) -> Path: ...
9596
def to_polygons(

lib/matplotlib/tests/test_bezier.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,15 @@ def test_split_bezier_with_large_values():
2626
_test_curves = [list(tc.path.iter_bezier())[-1][0] for tc in _test_curves]
2727

2828

29+
def _integral_arc_length(B):
30+
dB = B.differentiate(B)
31+
def integrand(t):
32+
return np.linalg.norm(dB(t), axis=1)
33+
x = np.linspace(0, 1, 1000)
34+
y = integrand(x)
35+
return np.trapz(y, x)
36+
37+
2938
def _integral_arc_area(B):
3039
"""(Signed) area swept out by ray from origin to curve."""
3140
dB = B.differentiate(B)
@@ -39,3 +48,10 @@ def integrand(t):
3948
@pytest.mark.parametrize("B", _test_curves)
4049
def test_area_formula(B):
4150
assert np.isclose(_integral_arc_area(B), B.arc_area)
51+
52+
53+
@pytest.mark.parametrize("B", _test_curves)
54+
def test_length_iteration(B):
55+
assert np.isclose(_integral_arc_length(B),
56+
B.arc_length(rtol=1e-5, atol=1e-8),
57+
rtol=1e-5, atol=1e-8)

lib/matplotlib/tests/test_path.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -88,22 +88,24 @@ def test_contains_points_negative_radius():
8888
np.testing.assert_equal(result, [True, False, False])
8989

9090

91-
_ExampleCurve = namedtuple('_ExampleCurve', ['path', 'extents', 'area'])
91+
_ExampleCurve = namedtuple('_ExampleCurve',
92+
['path', 'extents', 'area', 'length'])
9293
_test_curves = [
9394
# interior extrema determine extents and degenerate derivative
9495
_ExampleCurve(Path([[0, 0], [1, 0], [1, 1], [0, 1]],
9596
[Path.MOVETO, Path.CURVE4, Path.CURVE4, Path.CURVE4]),
96-
extents=(0., 0., 0.75, 1.), area=0.6),
97+
extents=(0., 0., 0.75, 1.), area=0.6, length=2.0),
9798
# a quadratic curve, clockwise
98-
_ExampleCurve(Path([[0, 0], [0, 1], [1, 0]],
99-
[Path.MOVETO, Path.CURVE3, Path.CURVE3]),
100-
extents=(0., 0., 1., 0.5), area=-1/3),
99+
_ExampleCurve(Path([[0, 0], [0, 1], [1, 0]], [Path.MOVETO, Path.CURVE3,
100+
Path.CURVE3]), extents=(0., 0., 1., 0.5), area=-1/3,
101+
length=(1/25)*(10 + 15*np.sqrt(2) + np.sqrt(5)
102+
* (np.arcsinh(2) + np.arcsinh(3)))),
101103
# a linear curve, degenerate vertically
102104
_ExampleCurve(Path([[0, 1], [1, 1]], [Path.MOVETO, Path.LINETO]),
103-
extents=(0., 1., 1., 1.), area=0.),
105+
extents=(0., 1., 1., 1.), area=0., length=1.0),
104106
# a point
105107
_ExampleCurve(Path([[1, 2]], [Path.MOVETO]), extents=(1., 2., 1., 2.),
106-
area=0.),
108+
area=0., length=0.0),
107109
]
108110

109111

@@ -154,6 +156,13 @@ def test_signed_area_unit_circle():
154156
assert np.isclose(circ.signed_area(), np.pi)
155157

156158

159+
@pytest.mark.parametrize('precomputed_curve', _test_curves)
160+
def test_length_curve(precomputed_curve):
161+
path, length = precomputed_curve.path, precomputed_curve.length
162+
assert np.isclose(path.length(rtol=1e-5, atol=1e-8), length, rtol=1e-5,
163+
atol=1e-8)
164+
165+
157166
def test_point_in_path_nan():
158167
box = np.array([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]])
159168
p = Path(box)

0 commit comments

Comments
 (0)