Skip to content

Commit 9c69a53

Browse files
committed
test new exact path bbox code
1 parent 4c67038 commit 9c69a53

File tree

3 files changed

+150
-1
lines changed

3 files changed

+150
-1
lines changed

lib/matplotlib/bezier.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,57 @@ def point_at_t(self, t):
238238
return tuple(
239239
self._px @ (((1 - t) ** self._orders)[::-1] * t ** self._orders))
240240

241+
def __call__(self, t):
242+
return self.point_at_t(t)
243+
244+
@property
245+
def interior_extrema(self):
246+
"""
247+
Return the location along the curve's interior where its partial derivative is
248+
zero, along with the dimension along which it is zero for each
249+
instance.
250+
251+
Returns
252+
-------
253+
dims : int, array_like
254+
dimension $i$ along which the corresponding zero occurs
255+
dzeros : float, array_like
256+
of same size as dims. the $t$ such that $d/dx_i B(t) = 0$
257+
"""
258+
if self.n <= 2: # a line's extrema are always its tips
259+
p = [0] # roots returns empty array for zero polynomial
260+
return np.array([]), np.array([])
261+
elif self.n == 3: # quadratic curve
262+
# the bezier curve in standard form is
263+
# cp[0] * (1 - t)^2 + cp[1] * 2t(1-t) + cp[2] * t^2
264+
# can be re-written as
265+
# cp[0] + 2 (cp[1] - cp[0]) t + (cp[2] - 2 cp[1] + cp[0]) t^2
266+
# which has simple derivative
267+
# 2*(cp[2] - 2*cp[1] + cp[0]) t + 2*(cp[1] - cp[0])
268+
p = [2*(self.cpoints[2] - 2*self.cpoints[1] + self.cpoints[0]),
269+
2*(self.cpoints[1] - self.cpoints[0])]
270+
elif self.n == 4: # cubic curve
271+
P = self.cpoints
272+
# derivative of cubic bezier curve has coefficients
273+
a = 3*(P[3] - 3*P[2] + 3*P[1] - P[0])
274+
b = 6*(P[2] - 2*P[1] + P[0])
275+
c = 3*(P[1] - P[0])
276+
p = [a, b, c]
277+
else: # self.n > 4:
278+
# a general formula exists, but is not useful for matplotlib
279+
raise NotImplementedError("Zero finding only implemented up to "
280+
"cubic curves.")
281+
dims = []
282+
roots = []
283+
for i, pi in enumerate(np.array(p).T):
284+
r = np.roots(pi)
285+
roots.append(r)
286+
dims.append(i*np.ones_like(r))
287+
roots = np.concatenate(roots)
288+
dims = np.concatenate(dims)
289+
in_range = np.isreal(roots) & (roots > 0) & (roots < 1)
290+
return dims[in_range], np.real(roots)[in_range]
291+
241292

242293
def split_bezier_intersecting_with_closedpath(
243294
bezier, inside_closedpath, tolerance=0.01):

lib/matplotlib/path.py

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,18 @@
1717
import matplotlib as mpl
1818
from . import _path, cbook
1919
from .cbook import _to_unmasked_float_array, simple_linear_interpolation
20-
from .bezier import split_bezier_intersecting_with_closedpath
20+
from .bezier import BezierSegment, split_bezier_intersecting_with_closedpath
21+
22+
23+
def _update_extents(extents, point):
24+
dim = len(point)
25+
for i,xi in enumerate(point):
26+
if xi < extents[i]:
27+
extents[i] = xi
28+
# elif here would fail to correctly update from "null" extents of
29+
# np.array([np.inf, np.inf, -np.inf, -np.inf])
30+
if extents[i+dim] < xi:
31+
extents[i+dim] = xi
2132

2233

2334
class Path:
@@ -422,6 +433,54 @@ def iter_segments(self, transform=None, remove_nans=True, clip=None,
422433
curr_vertices = np.append(curr_vertices, next(vertices))
423434
yield curr_vertices, code
424435

436+
def iter_curves(self, **kwargs):
437+
"""
438+
Iterate over each bezier curve (lines included) in a Path.
439+
440+
This is in contrast to iter_segments, which omits the first control
441+
point of each curve.
442+
443+
Parameters
444+
----------
445+
kwargs : Dict[str, object]
446+
Forwareded to iter_segments.
447+
448+
Yields
449+
------
450+
vertices : float, (N, 2) array_like
451+
The N control points of the next 2-dimensional bezier curve making
452+
up self. Note in particular that freestanding points are bezier
453+
curves of order 0, and lines are bezier curves of order 1 (with two
454+
control points).
455+
code : Path.code_type
456+
The code describing what kind of curve is being returned.
457+
Path.MOVETO, Path.LINETO, Path.CURVE3, Path.CURVE4 correspond to
458+
bezier curves with 1, 2, 3, and 4 control points (respectively).
459+
"""
460+
first_vertex = None
461+
prev_vertex = None
462+
for vertices, code in self.iter_segments(**kwargs):
463+
if first_vertex is None:
464+
if code != Path.MOVETO:
465+
raise ValueError("Malformed path, must start with MOVETO.")
466+
if code == Path.MOVETO: # a point is like "CURVE1"
467+
first_vertex = vertices
468+
yield np.array([first_vertex]), code
469+
elif code == Path.LINETO: # "CURVE2"
470+
yield np.array([prev_vertex, vertices]), code
471+
elif code == Path.CURVE3:
472+
yield np.array([prev_vertex, vertices[:2], vertices[2:]]), code
473+
elif code == Path.CURVE4:
474+
yield np.array([prev_vertex, vertices[:2], vertices[2:4],
475+
vertices[4:]]), code
476+
elif code == Path.CLOSEPOLY:
477+
yield np.array([prev_vertex, first_vertex]), code
478+
elif code == Path.STOP:
479+
return
480+
else:
481+
raise ValueError("Invalid Path.code_type: " + str(code))
482+
prev_vertex = vertices[-2:]
483+
425484
@cbook._delete_parameter("3.3", "quantize")
426485
def cleaned(self, transform=None, remove_nans=False, clip=None,
427486
quantize=False, simplify=False, curves=False,
@@ -547,6 +606,36 @@ def get_extents(self, transform=None):
547606
transform = None
548607
return Bbox(_path.get_path_extents(path, transform))
549608

609+
def get_exact_extents(self, **kwargs):
610+
"""Get size of Bbox of curve (instead of Bbox of control points).
611+
612+
Parameters
613+
----------
614+
kwargs : Dict[str, object]
615+
Forwarded to self.iter_curves.
616+
617+
Returns
618+
-------
619+
extents : (4,) float, array_like
620+
The extents of the path (xmin, ymin, xmax, ymax).
621+
"""
622+
maxi = 2 # [xmin, ymin, *xmax, ymax]
623+
# return value for empty paths to match _path.h
624+
extents = np.array([np.inf, np.inf, -np.inf, -np.inf])
625+
for curve, code in self.iter_curves(**kwargs):
626+
curve = BezierSegment(curve)
627+
# start and endpoints can be extrema of the curve
628+
_update_extents(extents, curve(0)) # start point
629+
_update_extents(extents, curve(1)) # end point
630+
# interior extrema where d/ds B(s) == 0
631+
_, dzeros = curve.interior_extrema
632+
if len(dzeros) == 0:
633+
continue
634+
for zero in dzeros:
635+
potential_extrema = curve.point_at_t(zero)
636+
_update_extents(extents, potential_extrema)
637+
return extents
638+
550639
def intersects_path(self, other, filled=True):
551640
"""
552641
Returns *True* if this path intersects another given path.

lib/matplotlib/tests/test_path.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,15 @@ def test_contains_points_negative_radius():
4949
np.testing.assert_equal(result, [True, False, False])
5050

5151

52+
def test_exact_extents_cubic():
53+
hard_curve = Path([[0, 0],
54+
[1, 0], [1, 1], [0, 1]],
55+
[Path.MOVETO,
56+
Path.CURVE4, Path.CURVE4, Path.CURVE4])
57+
np.testing.assert_equal(hard_curve.get_exact_extents(),
58+
[0., 0., 0.75, 1.])
59+
60+
5261
def test_point_in_path_nan():
5362
box = np.array([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]])
5463
p = Path(box)

0 commit comments

Comments
 (0)