Skip to content

Commit 23ffc9f

Browse files
committed
code to compute bezier segment / path lengths
1 parent 9489b8d commit 23ffc9f

File tree

4 files changed

+140
-10
lines changed

4 files changed

+140
-10
lines changed

lib/matplotlib/bezier.py

+91
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

@@ -219,6 +220,96 @@ def point_at_t(self, t):
219220
"""Evaluate curve at a single point *t*. Returns a Tuple[float*d]."""
220221
return tuple(self(t))
221222

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

lib/matplotlib/path.py

+21
Original file line numberDiff line numberDiff line change
@@ -642,6 +642,27 @@ def intersects_bbox(self, bbox, filled=True):
642642
return _path.path_intersects_rectangle(
643643
self, bbox.x0, bbox.y0, bbox.x1, bbox.y1, filled)
644644

645+
def length(self, rtol=None, atol=None, **kwargs):
646+
r"""Get length of Path.
647+
648+
Equivalent to (but not computed as)
649+
650+
.. math::
651+
652+
\sum_{j=1}^N \int_0^1 ||B'_j(t)|| dt
653+
654+
where the sum is over the :math:`N` Bezier curves that comprise the
655+
Path. Notice that this measure of length will assign zero weight to all
656+
isolated points on the Path.
657+
658+
Returns
659+
-------
660+
length : float
661+
The path length.
662+
"""
663+
return np.sum([B.arc_length(rtol, atol)
664+
for B, code in self.iter_bezier(**kwargs)])
665+
645666
def signed_area(self, **kwargs):
646667
"""
647668
Get signed area of the filled path.

lib/matplotlib/tests/test_bezier.py

+13
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@
99
_test_curves = [list(tc.path.iter_bezier())[-1][0] for tc in _test_curves]
1010

1111

12+
def _integral_arc_length(B):
13+
dB = B.differentiate(B)
14+
def integrand(t):
15+
return np.linalg.norm(dB(t))
16+
return scipy.integrate.quad(integrand, 0, 1)[0]
17+
18+
1219
def _integral_arc_area(B):
1320
"""(Signed) area swept out by ray from origin to curve."""
1421
dB = B.differentiate(B)
@@ -22,3 +29,9 @@ def integrand(t):
2229
def test_area_formula():
2330
for B in _test_curves:
2431
assert(np.isclose(_integral_arc_area(B), B.arc_area()))
32+
33+
34+
def test_length_iteration():
35+
for B in _test_curves:
36+
assert(np.isclose(_integral_arc_length(B), B.arc_length(
37+
rtol=1e-5, atol=1e-8), rtol=1e-5, atol=1e-8))

lib/matplotlib/tests/test_path.py

+15-10
Original file line numberDiff line numberDiff line change
@@ -14,28 +14,27 @@
1414
from matplotlib.backend_bases import MouseEvent
1515

1616

17-
ExampleCurve = namedtuple('ExampleCurve', ['path', 'extents', 'area'])
17+
ExampleCurve = namedtuple('ExampleCurve',
18+
['path', 'extents', 'area', 'length'])
1819
_test_curves = [
1920
# interior extrema determine extents and degenerate derivative
2021
ExampleCurve(Path([[0, 0], [1, 0], [1, 1], [0, 1]],
2122
[Path.MOVETO, Path.CURVE4, Path.CURVE4, Path.CURVE4]),
22-
(0., 0., 0.75, 1.), 0.6),
23+
(0., 0., 0.75, 1.), area=0.6, length=2.0),
2324
# a quadratic curve, clockwise
2425
ExampleCurve(Path([[0, 0], [0, 1], [1, 0]], [Path.MOVETO, Path.CURVE3,
25-
Path.CURVE3]), (0., 0., 1., 0.5), -1/3),
26+
Path.CURVE3]), (0., 0., 1., 0.5), area=-1/3, length=(1/25)*(10
27+
+ 15*np.sqrt(2) + np.sqrt(5)*(np.arcsinh(2) + np.arcsinh(3)))
28+
),
2629
# a linear curve, degenerate vertically
2730
ExampleCurve(Path([[0, 1], [1, 1]], [Path.MOVETO, Path.LINETO]),
28-
(0., 1., 1., 1.), 0.),
31+
(0., 1., 1., 1.), area=0., length=1.),
2932
# a point
30-
ExampleCurve(Path([[1, 2]], [Path.MOVETO]), (1., 2., 1., 2.), 0.),
33+
ExampleCurve(Path([[1, 2]], [Path.MOVETO]), (1., 2., 1., 2.), area=0.,
34+
length=0.),
3135
]
3236

3337

34-
# convenient curve (xmax=0.75, area=0.6, length=2.0)
35-
_hard_curve = Path([[0, 0], [1, 0], [1, 1], [0, 1]],
36-
[Path.MOVETO, Path.CURVE4, Path.CURVE4, Path.CURVE4])
37-
38-
3938
def test_empty_closed_path():
4039
path = Path(np.zeros((0, 2)), closed=True)
4140
assert path.vertices.shape == (0, 2)
@@ -101,6 +100,12 @@ def test_signed_area_unit_rectangle():
101100
assert np.isclose(rect.signed_area(), 1)
102101

103102

103+
def test_length_curve():
104+
for test_curve in _test_curves:
105+
length, path = test_curve.length, test_curve.path
106+
assert(np.isclose(path.length(), length))
107+
108+
104109
def test_point_in_path_nan():
105110
box = np.array([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]])
106111
p = Path(box)

0 commit comments

Comments
 (0)