bezier.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594
  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. end = middle
  126. else:
  127. t0 = middle_t
  128. start = middle
  129. start_inside = middle_inside
  130. class BezierSegment:
  131. """
  132. A d-dimensional Bézier segment.
  133. Parameters
  134. ----------
  135. control_points : (N, d) array
  136. Location of the *N* control points.
  137. """
  138. def __init__(self, control_points):
  139. self._cpoints = np.asarray(control_points)
  140. self._N, self._d = self._cpoints.shape
  141. self._orders = np.arange(self._N)
  142. coeff = [math.factorial(self._N - 1)
  143. // (math.factorial(i) * math.factorial(self._N - 1 - i))
  144. for i in range(self._N)]
  145. self._px = (self._cpoints.T * coeff).T
  146. def __call__(self, t):
  147. """
  148. Evaluate the Bézier curve at point(s) *t* in [0, 1].
  149. Parameters
  150. ----------
  151. t : (k,) array-like
  152. Points at which to evaluate the curve.
  153. Returns
  154. -------
  155. (k, d) array
  156. Value of the curve for each point in *t*.
  157. """
  158. t = np.asarray(t)
  159. return (np.power.outer(1 - t, self._orders[::-1])
  160. * np.power.outer(t, self._orders)) @ self._px
  161. def point_at_t(self, t):
  162. """
  163. Evaluate the curve at a single point, returning a tuple of *d* floats.
  164. """
  165. return tuple(self(t))
  166. @property
  167. def control_points(self):
  168. """The control points of the curve."""
  169. return self._cpoints
  170. @property
  171. def dimension(self):
  172. """The dimension of the curve."""
  173. return self._d
  174. @property
  175. def degree(self):
  176. """Degree of the polynomial. One less the number of control points."""
  177. return self._N - 1
  178. @property
  179. def polynomial_coefficients(self):
  180. r"""
  181. The polynomial coefficients of the Bézier curve.
  182. .. warning:: Follows opposite convention from `numpy.polyval`.
  183. Returns
  184. -------
  185. (n+1, d) array
  186. Coefficients after expanding in polynomial basis, where :math:`n`
  187. is the degree of the Bézier curve and :math:`d` its dimension.
  188. These are the numbers (:math:`C_j`) such that the curve can be
  189. written :math:`\sum_{j=0}^n C_j t^j`.
  190. Notes
  191. -----
  192. The coefficients are calculated as
  193. .. math::
  194. {n \choose j} \sum_{i=0}^j (-1)^{i+j} {j \choose i} P_i
  195. where :math:`P_i` are the control points of the curve.
  196. """
  197. n = self.degree
  198. # matplotlib uses n <= 4. overflow plausible starting around n = 15.
  199. if n > 10:
  200. warnings.warn("Polynomial coefficients formula unstable for high "
  201. "order Bezier curves!", RuntimeWarning)
  202. P = self.control_points
  203. j = np.arange(n+1)[:, None]
  204. i = np.arange(n+1)[None, :] # _comb is non-zero for i <= j
  205. prefactor = (-1)**(i + j) * _comb(j, i) # j on axis 0, i on axis 1
  206. return _comb(n, j) * prefactor @ P # j on axis 0, self.dimension on 1
  207. def axis_aligned_extrema(self):
  208. """
  209. Return the dimension and location of the curve's interior extrema.
  210. The extrema are the points along the curve where one of its partial
  211. derivatives is zero.
  212. Returns
  213. -------
  214. dims : array of int
  215. Index :math:`i` of the partial derivative which is zero at each
  216. interior extrema.
  217. dzeros : array of float
  218. Of same size as dims. The :math:`t` such that :math:`d/dx_i B(t) =
  219. 0`
  220. """
  221. n = self.degree
  222. if n <= 1:
  223. return np.array([]), np.array([])
  224. Cj = self.polynomial_coefficients
  225. dCj = np.arange(1, n+1)[:, None] * Cj[1:]
  226. dims = []
  227. roots = []
  228. for i, pi in enumerate(dCj.T):
  229. r = np.roots(pi[::-1])
  230. roots.append(r)
  231. dims.append(np.full_like(r, i))
  232. roots = np.concatenate(roots)
  233. dims = np.concatenate(dims)
  234. in_range = np.isreal(roots) & (roots >= 0) & (roots <= 1)
  235. return dims[in_range], np.real(roots)[in_range]
  236. def split_bezier_intersecting_with_closedpath(
  237. bezier, inside_closedpath, tolerance=0.01):
  238. """
  239. Split a Bézier curve into two at the intersection with a closed path.
  240. Parameters
  241. ----------
  242. bezier : (N, 2) array-like
  243. Control points of the Bézier segment. See `.BezierSegment`.
  244. inside_closedpath : callable
  245. A function returning True if a given point (x, y) is inside the
  246. closed path. See also `.find_bezier_t_intersecting_with_closedpath`.
  247. tolerance : float
  248. The tolerance for the intersection. See also
  249. `.find_bezier_t_intersecting_with_closedpath`.
  250. Returns
  251. -------
  252. left, right
  253. Lists of control points for the two Bézier segments.
  254. """
  255. bz = BezierSegment(bezier)
  256. bezier_point_at_t = bz.point_at_t
  257. t0, t1 = find_bezier_t_intersecting_with_closedpath(
  258. bezier_point_at_t, inside_closedpath, tolerance=tolerance)
  259. _left, _right = split_de_casteljau(bezier, (t0 + t1) / 2.)
  260. return _left, _right
  261. # matplotlib specific
  262. def split_path_inout(path, inside, tolerance=0.01, reorder_inout=False):
  263. """
  264. Divide a path into two segments at the point where ``inside(x, y)`` becomes
  265. False.
  266. """
  267. from .path import Path
  268. path_iter = path.iter_segments()
  269. ctl_points, command = next(path_iter)
  270. begin_inside = inside(ctl_points[-2:]) # true if begin point is inside
  271. ctl_points_old = ctl_points
  272. iold = 0
  273. i = 1
  274. for ctl_points, command in path_iter:
  275. iold = i
  276. i += len(ctl_points) // 2
  277. if inside(ctl_points[-2:]) != begin_inside:
  278. bezier_path = np.concatenate([ctl_points_old[-2:], ctl_points])
  279. break
  280. ctl_points_old = ctl_points
  281. else:
  282. raise ValueError("The path does not intersect with the patch")
  283. bp = bezier_path.reshape((-1, 2))
  284. left, right = split_bezier_intersecting_with_closedpath(
  285. bp, inside, tolerance)
  286. if len(left) == 2:
  287. codes_left = [Path.LINETO]
  288. codes_right = [Path.MOVETO, Path.LINETO]
  289. elif len(left) == 3:
  290. codes_left = [Path.CURVE3, Path.CURVE3]
  291. codes_right = [Path.MOVETO, Path.CURVE3, Path.CURVE3]
  292. elif len(left) == 4:
  293. codes_left = [Path.CURVE4, Path.CURVE4, Path.CURVE4]
  294. codes_right = [Path.MOVETO, Path.CURVE4, Path.CURVE4, Path.CURVE4]
  295. else:
  296. raise AssertionError("This should never be reached")
  297. verts_left = left[1:]
  298. verts_right = right[:]
  299. if path.codes is None:
  300. path_in = Path(np.concatenate([path.vertices[:i], verts_left]))
  301. path_out = Path(np.concatenate([verts_right, path.vertices[i:]]))
  302. else:
  303. path_in = Path(np.concatenate([path.vertices[:iold], verts_left]),
  304. np.concatenate([path.codes[:iold], codes_left]))
  305. path_out = Path(np.concatenate([verts_right, path.vertices[i:]]),
  306. np.concatenate([codes_right, path.codes[i:]]))
  307. if reorder_inout and not begin_inside:
  308. path_in, path_out = path_out, path_in
  309. return path_in, path_out
  310. def inside_circle(cx, cy, r):
  311. """
  312. Return a function that checks whether a point is in a circle with center
  313. (*cx*, *cy*) and radius *r*.
  314. The returned function has the signature::
  315. f(xy: tuple[float, float]) -> bool
  316. """
  317. r2 = r ** 2
  318. def _f(xy):
  319. x, y = xy
  320. return (x - cx) ** 2 + (y - cy) ** 2 < r2
  321. return _f
  322. # quadratic Bezier lines
  323. def get_cos_sin(x0, y0, x1, y1):
  324. dx, dy = x1 - x0, y1 - y0
  325. d = (dx * dx + dy * dy) ** .5
  326. # Account for divide by zero
  327. if d == 0:
  328. return 0.0, 0.0
  329. return dx / d, dy / d
  330. def check_if_parallel(dx1, dy1, dx2, dy2, tolerance=1.e-5):
  331. """
  332. Check if two lines are parallel.
  333. Parameters
  334. ----------
  335. dx1, dy1, dx2, dy2 : float
  336. The gradients *dy*/*dx* of the two lines.
  337. tolerance : float
  338. The angular tolerance in radians up to which the lines are considered
  339. parallel.
  340. Returns
  341. -------
  342. is_parallel
  343. - 1 if two lines are parallel in same direction.
  344. - -1 if two lines are parallel in opposite direction.
  345. - False otherwise.
  346. """
  347. theta1 = np.arctan2(dx1, dy1)
  348. theta2 = np.arctan2(dx2, dy2)
  349. dtheta = abs(theta1 - theta2)
  350. if dtheta < tolerance:
  351. return 1
  352. elif abs(dtheta - np.pi) < tolerance:
  353. return -1
  354. else:
  355. return False
  356. def get_parallels(bezier2, width):
  357. """
  358. Given the quadratic Bézier control points *bezier2*, returns
  359. control points of quadratic Bézier lines roughly parallel to given
  360. one separated by *width*.
  361. """
  362. # The parallel Bezier lines are constructed by following ways.
  363. # c1 and c2 are control points representing the start and end of the
  364. # Bezier line.
  365. # cm is the middle point
  366. c1x, c1y = bezier2[0]
  367. cmx, cmy = bezier2[1]
  368. c2x, c2y = bezier2[2]
  369. parallel_test = check_if_parallel(c1x - cmx, c1y - cmy,
  370. cmx - c2x, cmy - c2y)
  371. if parallel_test == -1:
  372. _api.warn_external(
  373. "Lines do not intersect. A straight line is used instead.")
  374. cos_t1, sin_t1 = get_cos_sin(c1x, c1y, c2x, c2y)
  375. cos_t2, sin_t2 = cos_t1, sin_t1
  376. else:
  377. # t1 and t2 is the angle between c1 and cm, cm, c2. They are
  378. # also an angle of the tangential line of the path at c1 and c2
  379. cos_t1, sin_t1 = get_cos_sin(c1x, c1y, cmx, cmy)
  380. cos_t2, sin_t2 = get_cos_sin(cmx, cmy, c2x, c2y)
  381. # find c1_left, c1_right which are located along the lines
  382. # through c1 and perpendicular to the tangential lines of the
  383. # Bezier path at a distance of width. Same thing for c2_left and
  384. # c2_right with respect to c2.
  385. c1x_left, c1y_left, c1x_right, c1y_right = (
  386. get_normal_points(c1x, c1y, cos_t1, sin_t1, width)
  387. )
  388. c2x_left, c2y_left, c2x_right, c2y_right = (
  389. get_normal_points(c2x, c2y, cos_t2, sin_t2, width)
  390. )
  391. # find cm_left which is the intersecting point of a line through
  392. # c1_left with angle t1 and a line through c2_left with angle
  393. # t2. Same with cm_right.
  394. try:
  395. cmx_left, cmy_left = get_intersection(c1x_left, c1y_left, cos_t1,
  396. sin_t1, c2x_left, c2y_left,
  397. cos_t2, sin_t2)
  398. cmx_right, cmy_right = get_intersection(c1x_right, c1y_right, cos_t1,
  399. sin_t1, c2x_right, c2y_right,
  400. cos_t2, sin_t2)
  401. except ValueError:
  402. # Special case straight lines, i.e., angle between two lines is
  403. # less than the threshold used by get_intersection (we don't use
  404. # check_if_parallel as the threshold is not the same).
  405. cmx_left, cmy_left = (
  406. 0.5 * (c1x_left + c2x_left), 0.5 * (c1y_left + c2y_left)
  407. )
  408. cmx_right, cmy_right = (
  409. 0.5 * (c1x_right + c2x_right), 0.5 * (c1y_right + c2y_right)
  410. )
  411. # the parallel Bezier lines are created with control points of
  412. # [c1_left, cm_left, c2_left] and [c1_right, cm_right, c2_right]
  413. path_left = [(c1x_left, c1y_left),
  414. (cmx_left, cmy_left),
  415. (c2x_left, c2y_left)]
  416. path_right = [(c1x_right, c1y_right),
  417. (cmx_right, cmy_right),
  418. (c2x_right, c2y_right)]
  419. return path_left, path_right
  420. def find_control_points(c1x, c1y, mmx, mmy, c2x, c2y):
  421. """
  422. Find control points of the Bézier curve passing through (*c1x*, *c1y*),
  423. (*mmx*, *mmy*), and (*c2x*, *c2y*), at parametric values 0, 0.5, and 1.
  424. """
  425. cmx = .5 * (4 * mmx - (c1x + c2x))
  426. cmy = .5 * (4 * mmy - (c1y + c2y))
  427. return [(c1x, c1y), (cmx, cmy), (c2x, c2y)]
  428. def make_wedged_bezier2(bezier2, width, w1=1., wm=0.5, w2=0.):
  429. """
  430. Being similar to `get_parallels`, returns control points of two quadratic
  431. Bézier lines having a width roughly parallel to given one separated by
  432. *width*.
  433. """
  434. # c1, cm, c2
  435. c1x, c1y = bezier2[0]
  436. cmx, cmy = bezier2[1]
  437. c3x, c3y = bezier2[2]
  438. # t1 and t2 is the angle between c1 and cm, cm, c3.
  439. # They are also an angle of the tangential line of the path at c1 and c3
  440. cos_t1, sin_t1 = get_cos_sin(c1x, c1y, cmx, cmy)
  441. cos_t2, sin_t2 = get_cos_sin(cmx, cmy, c3x, c3y)
  442. # find c1_left, c1_right which are located along the lines
  443. # through c1 and perpendicular to the tangential lines of the
  444. # Bezier path at a distance of width. Same thing for c3_left and
  445. # c3_right with respect to c3.
  446. c1x_left, c1y_left, c1x_right, c1y_right = (
  447. get_normal_points(c1x, c1y, cos_t1, sin_t1, width * w1)
  448. )
  449. c3x_left, c3y_left, c3x_right, c3y_right = (
  450. get_normal_points(c3x, c3y, cos_t2, sin_t2, width * w2)
  451. )
  452. # find c12, c23 and c123 which are middle points of c1-cm, cm-c3 and
  453. # c12-c23
  454. c12x, c12y = (c1x + cmx) * .5, (c1y + cmy) * .5
  455. c23x, c23y = (cmx + c3x) * .5, (cmy + c3y) * .5
  456. c123x, c123y = (c12x + c23x) * .5, (c12y + c23y) * .5
  457. # tangential angle of c123 (angle between c12 and c23)
  458. cos_t123, sin_t123 = get_cos_sin(c12x, c12y, c23x, c23y)
  459. c123x_left, c123y_left, c123x_right, c123y_right = (
  460. get_normal_points(c123x, c123y, cos_t123, sin_t123, width * wm)
  461. )
  462. path_left = find_control_points(c1x_left, c1y_left,
  463. c123x_left, c123y_left,
  464. c3x_left, c3y_left)
  465. path_right = find_control_points(c1x_right, c1y_right,
  466. c123x_right, c123y_right,
  467. c3x_right, c3y_right)
  468. return path_left, path_right