bezier.py 19 KB

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