1"""
2A module providing some utility functions regarding Bézier path manipulation.
3"""
4
5from functools import lru_cache
6import math
7import warnings
8
9import numpy as np
10
11from matplotlib import _api
12
13
14# same algorithm as 3.8's math.comb
15@np.vectorize
16@lru_cache(maxsize=128)
17def _comb(n, k):
18 if k > n:
19 return 0
20 k = min(k, n - k)
21 i = np.arange(1, k + 1)
22 return np.prod((n + 1 - i)/i).astype(int)
23
24
25class NonIntersectingPathException(ValueError):
26 pass
27
28
29# some functions
30
31
32def get_intersection(cx1, cy1, cos_t1, sin_t1,
33 cx2, cy2, cos_t2, sin_t2):
34 """
35 Return the intersection between the line through (*cx1*, *cy1*) at angle
36 *t1* and the line through (*cx2*, *cy2*) at angle *t2*.
37 """
38
39 # line1 => sin_t1 * (x - cx1) - cos_t1 * (y - cy1) = 0.
40 # line1 => sin_t1 * x + cos_t1 * y = sin_t1*cx1 - cos_t1*cy1
41
42 line1_rhs = sin_t1 * cx1 - cos_t1 * cy1
43 line2_rhs = sin_t2 * cx2 - cos_t2 * cy2
44
45 # rhs matrix
46 a, b = sin_t1, -cos_t1
47 c, d = sin_t2, -cos_t2
48
49 ad_bc = a * d - b * c
50 if abs(ad_bc) < 1e-12:
51 raise ValueError("Given lines do not intersect. Please verify that "
52 "the angles are not equal or differ by 180 degrees.")
53
54 # rhs_inverse
55 a_, b_ = d, -b
56 c_, d_ = -c, a
57 a_, b_, c_, d_ = [k / ad_bc for k in [a_, b_, c_, d_]]
58
59 x = a_ * line1_rhs + b_ * line2_rhs
60 y = c_ * line1_rhs + d_ * line2_rhs
61
62 return x, y
63
64
65def get_normal_points(cx, cy, cos_t, sin_t, length):
66 """
67 For a line passing through (*cx*, *cy*) and having an angle *t*, return
68 locations of the two points located along its perpendicular line at the
69 distance of *length*.
70 """
71
72 if length == 0.:
73 return cx, cy, cx, cy
74
75 cos_t1, sin_t1 = sin_t, -cos_t
76 cos_t2, sin_t2 = -sin_t, cos_t
77
78 x1, y1 = length * cos_t1 + cx, length * sin_t1 + cy
79 x2, y2 = length * cos_t2 + cx, length * sin_t2 + cy
80
81 return x1, y1, x2, y2
82
83
84# BEZIER routines
85
86# subdividing bezier curve
87# http://www.cs.mtu.edu/~shene/COURSES/cs3621/NOTES/spline/Bezier/bezier-sub.html
88
89
90def _de_casteljau1(beta, t):
91 next_beta = beta[:-1] * (1 - t) + beta[1:] * t
92 return next_beta
93
94
95def split_de_casteljau(beta, t):
96 """
97 Split a Bézier segment defined by its control points *beta* into two
98 separate segments divided at *t* and return their control points.
99 """
100 beta = np.asarray(beta)
101 beta_list = [beta]
102 while True:
103 beta = _de_casteljau1(beta, t)
104 beta_list.append(beta)
105 if len(beta) == 1:
106 break
107 left_beta = [beta[0] for beta in beta_list]
108 right_beta = [beta[-1] for beta in reversed(beta_list)]
109
110 return left_beta, right_beta
111
112
113def find_bezier_t_intersecting_with_closedpath(
114 bezier_point_at_t, inside_closedpath, t0=0., t1=1., tolerance=0.01):
115 """
116 Find the intersection of the Bézier curve with a closed path.
117
118 The intersection point *t* is approximated by two parameters *t0*, *t1*
119 such that *t0* <= *t* <= *t1*.
120
121 Search starts from *t0* and *t1* and uses a simple bisecting algorithm
122 therefore one of the end points must be inside the path while the other
123 doesn't. The search stops when the distance of the points parametrized by
124 *t0* and *t1* gets smaller than the given *tolerance*.
125
126 Parameters
127 ----------
128 bezier_point_at_t : callable
129 A function returning x, y coordinates of the Bézier at parameter *t*.
130 It must have the signature::
131
132 bezier_point_at_t(t: float) -> tuple[float, float]
133
134 inside_closedpath : callable
135 A function returning True if a given point (x, y) is inside the
136 closed path. It must have the signature::
137
138 inside_closedpath(point: tuple[float, float]) -> bool
139
140 t0, t1 : float
141 Start parameters for the search.
142
143 tolerance : float
144 Maximal allowed distance between the final points.
145
146 Returns
147 -------
148 t0, t1 : float
149 The Bézier path parameters.
150 """
151 start = bezier_point_at_t(t0)
152 end = bezier_point_at_t(t1)
153
154 start_inside = inside_closedpath(start)
155 end_inside = inside_closedpath(end)
156
157 if start_inside == end_inside and start != end:
158 raise NonIntersectingPathException(
159 "Both points are on the same side of the closed path")
160
161 while True:
162
163 # return if the distance is smaller than the tolerance
164 if np.hypot(start[0] - end[0], start[1] - end[1]) < tolerance:
165 return t0, t1
166
167 # calculate the middle point
168 middle_t = 0.5 * (t0 + t1)
169 middle = bezier_point_at_t(middle_t)
170 middle_inside = inside_closedpath(middle)
171
172 if start_inside ^ middle_inside:
173 t1 = middle_t
174 if end == middle:
175 # Edge case where infinite loop is possible
176 # Caused by large numbers relative to tolerance
177 return t0, t1
178 end = middle
179 else:
180 t0 = middle_t
181 if start == middle:
182 # Edge case where infinite loop is possible
183 # Caused by large numbers relative to tolerance
184 return t0, t1
185 start = middle
186 start_inside = middle_inside
187
188
189class BezierSegment:
190 """
191 A d-dimensional Bézier segment.
192
193 Parameters
194 ----------
195 control_points : (N, d) array
196 Location of the *N* control points.
197 """
198
199 def __init__(self, control_points):
200 self._cpoints = np.asarray(control_points)
201 self._N, self._d = self._cpoints.shape
202 self._orders = np.arange(self._N)
203 coeff = [math.factorial(self._N - 1)
204 // (math.factorial(i) * math.factorial(self._N - 1 - i))
205 for i in range(self._N)]
206 self._px = (self._cpoints.T * coeff).T
207
208 def __call__(self, t):
209 """
210 Evaluate the Bézier curve at point(s) *t* in [0, 1].
211
212 Parameters
213 ----------
214 t : (k,) array-like
215 Points at which to evaluate the curve.
216
217 Returns
218 -------
219 (k, d) array
220 Value of the curve for each point in *t*.
221 """
222 t = np.asarray(t)
223 return (np.power.outer(1 - t, self._orders[::-1])
224 * np.power.outer(t, self._orders)) @ self._px
225
226 def point_at_t(self, t):
227 """
228 Evaluate the curve at a single point, returning a tuple of *d* floats.
229 """
230 return tuple(self(t))
231
232 @property
233 def control_points(self):
234 """The control points of the curve."""
235 return self._cpoints
236
237 @property
238 def dimension(self):
239 """The dimension of the curve."""
240 return self._d
241
242 @property
243 def degree(self):
244 """Degree of the polynomial. One less the number of control points."""
245 return self._N - 1
246
247 @property
248 def polynomial_coefficients(self):
249 r"""
250 The polynomial coefficients of the Bézier curve.
251
252 .. warning:: Follows opposite convention from `numpy.polyval`.
253
254 Returns
255 -------
256 (n+1, d) array
257 Coefficients after expanding in polynomial basis, where :math:`n`
258 is the degree of the Bézier curve and :math:`d` its dimension.
259 These are the numbers (:math:`C_j`) such that the curve can be
260 written :math:`\sum_{j=0}^n C_j t^j`.
261
262 Notes
263 -----
264 The coefficients are calculated as
265
266 .. math::
267
268 {n \choose j} \sum_{i=0}^j (-1)^{i+j} {j \choose i} P_i
269
270 where :math:`P_i` are the control points of the curve.
271 """
272 n = self.degree
273 # matplotlib uses n <= 4. overflow plausible starting around n = 15.
274 if n > 10:
275 warnings.warn("Polynomial coefficients formula unstable for high "
276 "order Bezier curves!", RuntimeWarning)
277 P = self.control_points
278 j = np.arange(n+1)[:, None]
279 i = np.arange(n+1)[None, :] # _comb is non-zero for i <= j
280 prefactor = (-1)**(i + j) * _comb(j, i) # j on axis 0, i on axis 1
281 return _comb(n, j) * prefactor @ P # j on axis 0, self.dimension on 1
282
283 def axis_aligned_extrema(self):
284 """
285 Return the dimension and location of the curve's interior extrema.
286
287 The extrema are the points along the curve where one of its partial
288 derivatives is zero.
289
290 Returns
291 -------
292 dims : array of int
293 Index :math:`i` of the partial derivative which is zero at each
294 interior extrema.
295 dzeros : array of float
296 Of same size as dims. The :math:`t` such that :math:`d/dx_i B(t) =
297 0`
298 """
299 n = self.degree
300 if n <= 1:
301 return np.array([]), np.array([])
302 Cj = self.polynomial_coefficients
303 dCj = np.arange(1, n+1)[:, None] * Cj[1:]
304 dims = []
305 roots = []
306 for i, pi in enumerate(dCj.T):
307 r = np.roots(pi[::-1])
308 roots.append(r)
309 dims.append(np.full_like(r, i))
310 roots = np.concatenate(roots)
311 dims = np.concatenate(dims)
312 in_range = np.isreal(roots) & (roots >= 0) & (roots <= 1)
313 return dims[in_range], np.real(roots)[in_range]
314
315
316def split_bezier_intersecting_with_closedpath(
317 bezier, inside_closedpath, tolerance=0.01):
318 """
319 Split a Bézier curve into two at the intersection with a closed path.
320
321 Parameters
322 ----------
323 bezier : (N, 2) array-like
324 Control points of the Bézier segment. See `.BezierSegment`.
325 inside_closedpath : callable
326 A function returning True if a given point (x, y) is inside the
327 closed path. See also `.find_bezier_t_intersecting_with_closedpath`.
328 tolerance : float
329 The tolerance for the intersection. See also
330 `.find_bezier_t_intersecting_with_closedpath`.
331
332 Returns
333 -------
334 left, right
335 Lists of control points for the two Bézier segments.
336 """
337
338 bz = BezierSegment(bezier)
339 bezier_point_at_t = bz.point_at_t
340
341 t0, t1 = find_bezier_t_intersecting_with_closedpath(
342 bezier_point_at_t, inside_closedpath, tolerance=tolerance)
343
344 _left, _right = split_de_casteljau(bezier, (t0 + t1) / 2.)
345 return _left, _right
346
347
348# matplotlib specific
349
350
351def split_path_inout(path, inside, tolerance=0.01, reorder_inout=False):
352 """
353 Divide a path into two segments at the point where ``inside(x, y)`` becomes
354 False.
355 """
356 from .path import Path
357 path_iter = path.iter_segments()
358
359 ctl_points, command = next(path_iter)
360 begin_inside = inside(ctl_points[-2:]) # true if begin point is inside
361
362 ctl_points_old = ctl_points
363
364 iold = 0
365 i = 1
366
367 for ctl_points, command in path_iter:
368 iold = i
369 i += len(ctl_points) // 2
370 if inside(ctl_points[-2:]) != begin_inside:
371 bezier_path = np.concatenate([ctl_points_old[-2:], ctl_points])
372 break
373 ctl_points_old = ctl_points
374 else:
375 raise ValueError("The path does not intersect with the patch")
376
377 bp = bezier_path.reshape((-1, 2))
378 left, right = split_bezier_intersecting_with_closedpath(
379 bp, inside, tolerance)
380 if len(left) == 2:
381 codes_left = [Path.LINETO]
382 codes_right = [Path.MOVETO, Path.LINETO]
383 elif len(left) == 3:
384 codes_left = [Path.CURVE3, Path.CURVE3]
385 codes_right = [Path.MOVETO, Path.CURVE3, Path.CURVE3]
386 elif len(left) == 4:
387 codes_left = [Path.CURVE4, Path.CURVE4, Path.CURVE4]
388 codes_right = [Path.MOVETO, Path.CURVE4, Path.CURVE4, Path.CURVE4]
389 else:
390 raise AssertionError("This should never be reached")
391
392 verts_left = left[1:]
393 verts_right = right[:]
394
395 if path.codes is None:
396 path_in = Path(np.concatenate([path.vertices[:i], verts_left]))
397 path_out = Path(np.concatenate([verts_right, path.vertices[i:]]))
398
399 else:
400 path_in = Path(np.concatenate([path.vertices[:iold], verts_left]),
401 np.concatenate([path.codes[:iold], codes_left]))
402
403 path_out = Path(np.concatenate([verts_right, path.vertices[i:]]),
404 np.concatenate([codes_right, path.codes[i:]]))
405
406 if reorder_inout and not begin_inside:
407 path_in, path_out = path_out, path_in
408
409 return path_in, path_out
410
411
412def inside_circle(cx, cy, r):
413 """
414 Return a function that checks whether a point is in a circle with center
415 (*cx*, *cy*) and radius *r*.
416
417 The returned function has the signature::
418
419 f(xy: tuple[float, float]) -> bool
420 """
421 r2 = r ** 2
422
423 def _f(xy):
424 x, y = xy
425 return (x - cx) ** 2 + (y - cy) ** 2 < r2
426 return _f
427
428
429# quadratic Bezier lines
430
431def get_cos_sin(x0, y0, x1, y1):
432 dx, dy = x1 - x0, y1 - y0
433 d = (dx * dx + dy * dy) ** .5
434 # Account for divide by zero
435 if d == 0:
436 return 0.0, 0.0
437 return dx / d, dy / d
438
439
440def check_if_parallel(dx1, dy1, dx2, dy2, tolerance=1.e-5):
441 """
442 Check if two lines are parallel.
443
444 Parameters
445 ----------
446 dx1, dy1, dx2, dy2 : float
447 The gradients *dy*/*dx* of the two lines.
448 tolerance : float
449 The angular tolerance in radians up to which the lines are considered
450 parallel.
451
452 Returns
453 -------
454 is_parallel
455 - 1 if two lines are parallel in same direction.
456 - -1 if two lines are parallel in opposite direction.
457 - False otherwise.
458 """
459 theta1 = np.arctan2(dx1, dy1)
460 theta2 = np.arctan2(dx2, dy2)
461 dtheta = abs(theta1 - theta2)
462 if dtheta < tolerance:
463 return 1
464 elif abs(dtheta - np.pi) < tolerance:
465 return -1
466 else:
467 return False
468
469
470def get_parallels(bezier2, width):
471 """
472 Given the quadratic Bézier control points *bezier2*, returns
473 control points of quadratic Bézier lines roughly parallel to given
474 one separated by *width*.
475 """
476
477 # The parallel Bezier lines are constructed by following ways.
478 # c1 and c2 are control points representing the start and end of the
479 # Bezier line.
480 # cm is the middle point
481
482 c1x, c1y = bezier2[0]
483 cmx, cmy = bezier2[1]
484 c2x, c2y = bezier2[2]
485
486 parallel_test = check_if_parallel(c1x - cmx, c1y - cmy,
487 cmx - c2x, cmy - c2y)
488
489 if parallel_test == -1:
490 _api.warn_external(
491 "Lines do not intersect. A straight line is used instead.")
492 cos_t1, sin_t1 = get_cos_sin(c1x, c1y, c2x, c2y)
493 cos_t2, sin_t2 = cos_t1, sin_t1
494 else:
495 # t1 and t2 is the angle between c1 and cm, cm, c2. They are
496 # also an angle of the tangential line of the path at c1 and c2
497 cos_t1, sin_t1 = get_cos_sin(c1x, c1y, cmx, cmy)
498 cos_t2, sin_t2 = get_cos_sin(cmx, cmy, c2x, c2y)
499
500 # find c1_left, c1_right which are located along the lines
501 # through c1 and perpendicular to the tangential lines of the
502 # Bezier path at a distance of width. Same thing for c2_left and
503 # c2_right with respect to c2.
504 c1x_left, c1y_left, c1x_right, c1y_right = (
505 get_normal_points(c1x, c1y, cos_t1, sin_t1, width)
506 )
507 c2x_left, c2y_left, c2x_right, c2y_right = (
508 get_normal_points(c2x, c2y, cos_t2, sin_t2, width)
509 )
510
511 # find cm_left which is the intersecting point of a line through
512 # c1_left with angle t1 and a line through c2_left with angle
513 # t2. Same with cm_right.
514 try:
515 cmx_left, cmy_left = get_intersection(c1x_left, c1y_left, cos_t1,
516 sin_t1, c2x_left, c2y_left,
517 cos_t2, sin_t2)
518 cmx_right, cmy_right = get_intersection(c1x_right, c1y_right, cos_t1,
519 sin_t1, c2x_right, c2y_right,
520 cos_t2, sin_t2)
521 except ValueError:
522 # Special case straight lines, i.e., angle between two lines is
523 # less than the threshold used by get_intersection (we don't use
524 # check_if_parallel as the threshold is not the same).
525 cmx_left, cmy_left = (
526 0.5 * (c1x_left + c2x_left), 0.5 * (c1y_left + c2y_left)
527 )
528 cmx_right, cmy_right = (
529 0.5 * (c1x_right + c2x_right), 0.5 * (c1y_right + c2y_right)
530 )
531
532 # the parallel Bezier lines are created with control points of
533 # [c1_left, cm_left, c2_left] and [c1_right, cm_right, c2_right]
534 path_left = [(c1x_left, c1y_left),
535 (cmx_left, cmy_left),
536 (c2x_left, c2y_left)]
537 path_right = [(c1x_right, c1y_right),
538 (cmx_right, cmy_right),
539 (c2x_right, c2y_right)]
540
541 return path_left, path_right
542
543
544def find_control_points(c1x, c1y, mmx, mmy, c2x, c2y):
545 """
546 Find control points of the Bézier curve passing through (*c1x*, *c1y*),
547 (*mmx*, *mmy*), and (*c2x*, *c2y*), at parametric values 0, 0.5, and 1.
548 """
549 cmx = .5 * (4 * mmx - (c1x + c2x))
550 cmy = .5 * (4 * mmy - (c1y + c2y))
551 return [(c1x, c1y), (cmx, cmy), (c2x, c2y)]
552
553
554def make_wedged_bezier2(bezier2, width, w1=1., wm=0.5, w2=0.):
555 """
556 Being similar to `get_parallels`, returns control points of two quadratic
557 Bézier lines having a width roughly parallel to given one separated by
558 *width*.
559 """
560
561 # c1, cm, c2
562 c1x, c1y = bezier2[0]
563 cmx, cmy = bezier2[1]
564 c3x, c3y = bezier2[2]
565
566 # t1 and t2 is the angle between c1 and cm, cm, c3.
567 # They are also an angle of the tangential line of the path at c1 and c3
568 cos_t1, sin_t1 = get_cos_sin(c1x, c1y, cmx, cmy)
569 cos_t2, sin_t2 = get_cos_sin(cmx, cmy, c3x, c3y)
570
571 # find c1_left, c1_right which are located along the lines
572 # through c1 and perpendicular to the tangential lines of the
573 # Bezier path at a distance of width. Same thing for c3_left and
574 # c3_right with respect to c3.
575 c1x_left, c1y_left, c1x_right, c1y_right = (
576 get_normal_points(c1x, c1y, cos_t1, sin_t1, width * w1)
577 )
578 c3x_left, c3y_left, c3x_right, c3y_right = (
579 get_normal_points(c3x, c3y, cos_t2, sin_t2, width * w2)
580 )
581
582 # find c12, c23 and c123 which are middle points of c1-cm, cm-c3 and
583 # c12-c23
584 c12x, c12y = (c1x + cmx) * .5, (c1y + cmy) * .5
585 c23x, c23y = (cmx + c3x) * .5, (cmy + c3y) * .5
586 c123x, c123y = (c12x + c23x) * .5, (c12y + c23y) * .5
587
588 # tangential angle of c123 (angle between c12 and c23)
589 cos_t123, sin_t123 = get_cos_sin(c12x, c12y, c23x, c23y)
590
591 c123x_left, c123y_left, c123x_right, c123y_right = (
592 get_normal_points(c123x, c123y, cos_t123, sin_t123, width * wm)
593 )
594
595 path_left = find_control_points(c1x_left, c1y_left,
596 c123x_left, c123y_left,
597 c3x_left, c3y_left)
598 path_right = find_control_points(c1x_right, c1y_right,
599 c123x_right, c123y_right,
600 c3x_right, c3y_right)
601
602 return path_left, path_right