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

Lines changed: 91 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

@@ -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

Lines changed: 21 additions & 0 deletions
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

Lines changed: 13 additions & 0 deletions
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

Lines changed: 15 additions & 10 deletions
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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy