123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594 |
- """
- A module providing some utility functions regarding Bézier path manipulation.
- """
- from functools import lru_cache
- import math
- import warnings
- import numpy as np
- from matplotlib import _api
- # same algorithm as 3.8's math.comb
- @np.vectorize
- @lru_cache(maxsize=128)
- def _comb(n, k):
- if k > n:
- return 0
- k = min(k, n - k)
- i = np.arange(1, k + 1)
- return np.prod((n + 1 - i)/i).astype(int)
- class NonIntersectingPathException(ValueError):
- pass
- # some functions
- def get_intersection(cx1, cy1, cos_t1, sin_t1,
- cx2, cy2, cos_t2, sin_t2):
- """
- Return the intersection between the line through (*cx1*, *cy1*) at angle
- *t1* and the line through (*cx2*, *cy2*) at angle *t2*.
- """
- # line1 => sin_t1 * (x - cx1) - cos_t1 * (y - cy1) = 0.
- # line1 => sin_t1 * x + cos_t1 * y = sin_t1*cx1 - cos_t1*cy1
- line1_rhs = sin_t1 * cx1 - cos_t1 * cy1
- line2_rhs = sin_t2 * cx2 - cos_t2 * cy2
- # rhs matrix
- a, b = sin_t1, -cos_t1
- c, d = sin_t2, -cos_t2
- ad_bc = a * d - b * c
- if abs(ad_bc) < 1e-12:
- raise ValueError("Given lines do not intersect. Please verify that "
- "the angles are not equal or differ by 180 degrees.")
- # rhs_inverse
- a_, b_ = d, -b
- c_, d_ = -c, a
- a_, b_, c_, d_ = [k / ad_bc for k in [a_, b_, c_, d_]]
- x = a_ * line1_rhs + b_ * line2_rhs
- y = c_ * line1_rhs + d_ * line2_rhs
- return x, y
- def get_normal_points(cx, cy, cos_t, sin_t, length):
- """
- For a line passing through (*cx*, *cy*) and having an angle *t*, return
- locations of the two points located along its perpendicular line at the
- distance of *length*.
- """
- if length == 0.:
- return cx, cy, cx, cy
- cos_t1, sin_t1 = sin_t, -cos_t
- cos_t2, sin_t2 = -sin_t, cos_t
- x1, y1 = length * cos_t1 + cx, length * sin_t1 + cy
- x2, y2 = length * cos_t2 + cx, length * sin_t2 + cy
- return x1, y1, x2, y2
- # BEZIER routines
- # subdividing bezier curve
- # http://www.cs.mtu.edu/~shene/COURSES/cs3621/NOTES/spline/Bezier/bezier-sub.html
- def _de_casteljau1(beta, t):
- next_beta = beta[:-1] * (1 - t) + beta[1:] * t
- return next_beta
- def split_de_casteljau(beta, t):
- """
- Split a Bézier segment defined by its control points *beta* into two
- separate segments divided at *t* and return their control points.
- """
- beta = np.asarray(beta)
- beta_list = [beta]
- while True:
- beta = _de_casteljau1(beta, t)
- beta_list.append(beta)
- if len(beta) == 1:
- break
- left_beta = [beta[0] for beta in beta_list]
- right_beta = [beta[-1] for beta in reversed(beta_list)]
- return left_beta, right_beta
- def find_bezier_t_intersecting_with_closedpath(
- bezier_point_at_t, inside_closedpath, t0=0., t1=1., tolerance=0.01):
- """
- Find the intersection of the Bézier curve with a closed path.
- The intersection point *t* is approximated by two parameters *t0*, *t1*
- such that *t0* <= *t* <= *t1*.
- Search starts from *t0* and *t1* and uses a simple bisecting algorithm
- therefore one of the end points must be inside the path while the other
- doesn't. The search stops when the distance of the points parametrized by
- *t0* and *t1* gets smaller than the given *tolerance*.
- Parameters
- ----------
- bezier_point_at_t : callable
- A function returning x, y coordinates of the Bézier at parameter *t*.
- It must have the signature::
- bezier_point_at_t(t: float) -> tuple[float, float]
- inside_closedpath : callable
- A function returning True if a given point (x, y) is inside the
- closed path. It must have the signature::
- inside_closedpath(point: tuple[float, float]) -> bool
- t0, t1 : float
- Start parameters for the search.
- tolerance : float
- Maximal allowed distance between the final points.
- Returns
- -------
- t0, t1 : float
- The Bézier path parameters.
- """
- start = bezier_point_at_t(t0)
- end = bezier_point_at_t(t1)
- start_inside = inside_closedpath(start)
- end_inside = inside_closedpath(end)
- if start_inside == end_inside and start != end:
- raise NonIntersectingPathException(
- "Both points are on the same side of the closed path")
- while True:
- # return if the distance is smaller than the tolerance
- if np.hypot(start[0] - end[0], start[1] - end[1]) < tolerance:
- return t0, t1
- # calculate the middle point
- middle_t = 0.5 * (t0 + t1)
- middle = bezier_point_at_t(middle_t)
- middle_inside = inside_closedpath(middle)
- if start_inside ^ middle_inside:
- t1 = middle_t
- end = middle
- else:
- t0 = middle_t
- start = middle
- start_inside = middle_inside
- class BezierSegment:
- """
- A d-dimensional Bézier segment.
- Parameters
- ----------
- control_points : (N, d) array
- Location of the *N* control points.
- """
- def __init__(self, control_points):
- self._cpoints = np.asarray(control_points)
- self._N, self._d = self._cpoints.shape
- self._orders = np.arange(self._N)
- coeff = [math.factorial(self._N - 1)
- // (math.factorial(i) * math.factorial(self._N - 1 - i))
- for i in range(self._N)]
- self._px = (self._cpoints.T * coeff).T
- def __call__(self, t):
- """
- Evaluate the Bézier curve at point(s) *t* in [0, 1].
- Parameters
- ----------
- t : (k,) array-like
- Points at which to evaluate the curve.
- Returns
- -------
- (k, d) array
- Value of the curve for each point in *t*.
- """
- t = np.asarray(t)
- return (np.power.outer(1 - t, self._orders[::-1])
- * np.power.outer(t, self._orders)) @ self._px
- def point_at_t(self, t):
- """
- Evaluate the curve at a single point, returning a tuple of *d* floats.
- """
- return tuple(self(t))
- @property
- def control_points(self):
- """The control points of the curve."""
- return self._cpoints
- @property
- def dimension(self):
- """The dimension of the curve."""
- return self._d
- @property
- def degree(self):
- """Degree of the polynomial. One less the number of control points."""
- return self._N - 1
- @property
- def polynomial_coefficients(self):
- r"""
- The polynomial coefficients of the Bézier curve.
- .. warning:: Follows opposite convention from `numpy.polyval`.
- Returns
- -------
- (n+1, d) array
- Coefficients after expanding in polynomial basis, where :math:`n`
- is the degree of the Bézier curve and :math:`d` its dimension.
- These are the numbers (:math:`C_j`) such that the curve can be
- written :math:`\sum_{j=0}^n C_j t^j`.
- Notes
- -----
- The coefficients are calculated as
- .. math::
- {n \choose j} \sum_{i=0}^j (-1)^{i+j} {j \choose i} P_i
- where :math:`P_i` are the control points of the curve.
- """
- n = self.degree
- # matplotlib uses n <= 4. overflow plausible starting around n = 15.
- if n > 10:
- warnings.warn("Polynomial coefficients formula unstable for high "
- "order Bezier curves!", RuntimeWarning)
- P = self.control_points
- j = np.arange(n+1)[:, None]
- i = np.arange(n+1)[None, :] # _comb is non-zero for i <= j
- prefactor = (-1)**(i + j) * _comb(j, i) # j on axis 0, i on axis 1
- return _comb(n, j) * prefactor @ P # j on axis 0, self.dimension on 1
- def axis_aligned_extrema(self):
- """
- Return the dimension and location of the curve's interior extrema.
- The extrema are the points along the curve where one of its partial
- derivatives is zero.
- Returns
- -------
- dims : array of int
- Index :math:`i` of the partial derivative which is zero at each
- interior extrema.
- dzeros : array of float
- Of same size as dims. The :math:`t` such that :math:`d/dx_i B(t) =
- 0`
- """
- n = self.degree
- if n <= 1:
- return np.array([]), np.array([])
- Cj = self.polynomial_coefficients
- dCj = np.arange(1, n+1)[:, None] * Cj[1:]
- dims = []
- roots = []
- for i, pi in enumerate(dCj.T):
- r = np.roots(pi[::-1])
- roots.append(r)
- dims.append(np.full_like(r, i))
- roots = np.concatenate(roots)
- dims = np.concatenate(dims)
- in_range = np.isreal(roots) & (roots >= 0) & (roots <= 1)
- return dims[in_range], np.real(roots)[in_range]
- def split_bezier_intersecting_with_closedpath(
- bezier, inside_closedpath, tolerance=0.01):
- """
- Split a Bézier curve into two at the intersection with a closed path.
- Parameters
- ----------
- bezier : (N, 2) array-like
- Control points of the Bézier segment. See `.BezierSegment`.
- inside_closedpath : callable
- A function returning True if a given point (x, y) is inside the
- closed path. See also `.find_bezier_t_intersecting_with_closedpath`.
- tolerance : float
- The tolerance for the intersection. See also
- `.find_bezier_t_intersecting_with_closedpath`.
- Returns
- -------
- left, right
- Lists of control points for the two Bézier segments.
- """
- bz = BezierSegment(bezier)
- bezier_point_at_t = bz.point_at_t
- t0, t1 = find_bezier_t_intersecting_with_closedpath(
- bezier_point_at_t, inside_closedpath, tolerance=tolerance)
- _left, _right = split_de_casteljau(bezier, (t0 + t1) / 2.)
- return _left, _right
- # matplotlib specific
- def split_path_inout(path, inside, tolerance=0.01, reorder_inout=False):
- """
- Divide a path into two segments at the point where ``inside(x, y)`` becomes
- False.
- """
- from .path import Path
- path_iter = path.iter_segments()
- ctl_points, command = next(path_iter)
- begin_inside = inside(ctl_points[-2:]) # true if begin point is inside
- ctl_points_old = ctl_points
- iold = 0
- i = 1
- for ctl_points, command in path_iter:
- iold = i
- i += len(ctl_points) // 2
- if inside(ctl_points[-2:]) != begin_inside:
- bezier_path = np.concatenate([ctl_points_old[-2:], ctl_points])
- break
- ctl_points_old = ctl_points
- else:
- raise ValueError("The path does not intersect with the patch")
- bp = bezier_path.reshape((-1, 2))
- left, right = split_bezier_intersecting_with_closedpath(
- bp, inside, tolerance)
- if len(left) == 2:
- codes_left = [Path.LINETO]
- codes_right = [Path.MOVETO, Path.LINETO]
- elif len(left) == 3:
- codes_left = [Path.CURVE3, Path.CURVE3]
- codes_right = [Path.MOVETO, Path.CURVE3, Path.CURVE3]
- elif len(left) == 4:
- codes_left = [Path.CURVE4, Path.CURVE4, Path.CURVE4]
- codes_right = [Path.MOVETO, Path.CURVE4, Path.CURVE4, Path.CURVE4]
- else:
- raise AssertionError("This should never be reached")
- verts_left = left[1:]
- verts_right = right[:]
- if path.codes is None:
- path_in = Path(np.concatenate([path.vertices[:i], verts_left]))
- path_out = Path(np.concatenate([verts_right, path.vertices[i:]]))
- else:
- path_in = Path(np.concatenate([path.vertices[:iold], verts_left]),
- np.concatenate([path.codes[:iold], codes_left]))
- path_out = Path(np.concatenate([verts_right, path.vertices[i:]]),
- np.concatenate([codes_right, path.codes[i:]]))
- if reorder_inout and not begin_inside:
- path_in, path_out = path_out, path_in
- return path_in, path_out
- def inside_circle(cx, cy, r):
- """
- Return a function that checks whether a point is in a circle with center
- (*cx*, *cy*) and radius *r*.
- The returned function has the signature::
- f(xy: tuple[float, float]) -> bool
- """
- r2 = r ** 2
- def _f(xy):
- x, y = xy
- return (x - cx) ** 2 + (y - cy) ** 2 < r2
- return _f
- # quadratic Bezier lines
- def get_cos_sin(x0, y0, x1, y1):
- dx, dy = x1 - x0, y1 - y0
- d = (dx * dx + dy * dy) ** .5
- # Account for divide by zero
- if d == 0:
- return 0.0, 0.0
- return dx / d, dy / d
- def check_if_parallel(dx1, dy1, dx2, dy2, tolerance=1.e-5):
- """
- Check if two lines are parallel.
- Parameters
- ----------
- dx1, dy1, dx2, dy2 : float
- The gradients *dy*/*dx* of the two lines.
- tolerance : float
- The angular tolerance in radians up to which the lines are considered
- parallel.
- Returns
- -------
- is_parallel
- - 1 if two lines are parallel in same direction.
- - -1 if two lines are parallel in opposite direction.
- - False otherwise.
- """
- theta1 = np.arctan2(dx1, dy1)
- theta2 = np.arctan2(dx2, dy2)
- dtheta = abs(theta1 - theta2)
- if dtheta < tolerance:
- return 1
- elif abs(dtheta - np.pi) < tolerance:
- return -1
- else:
- return False
- def get_parallels(bezier2, width):
- """
- Given the quadratic Bézier control points *bezier2*, returns
- control points of quadratic Bézier lines roughly parallel to given
- one separated by *width*.
- """
- # The parallel Bezier lines are constructed by following ways.
- # c1 and c2 are control points representing the start and end of the
- # Bezier line.
- # cm is the middle point
- c1x, c1y = bezier2[0]
- cmx, cmy = bezier2[1]
- c2x, c2y = bezier2[2]
- parallel_test = check_if_parallel(c1x - cmx, c1y - cmy,
- cmx - c2x, cmy - c2y)
- if parallel_test == -1:
- _api.warn_external(
- "Lines do not intersect. A straight line is used instead.")
- cos_t1, sin_t1 = get_cos_sin(c1x, c1y, c2x, c2y)
- cos_t2, sin_t2 = cos_t1, sin_t1
- else:
- # t1 and t2 is the angle between c1 and cm, cm, c2. They are
- # also an angle of the tangential line of the path at c1 and c2
- cos_t1, sin_t1 = get_cos_sin(c1x, c1y, cmx, cmy)
- cos_t2, sin_t2 = get_cos_sin(cmx, cmy, c2x, c2y)
- # find c1_left, c1_right which are located along the lines
- # through c1 and perpendicular to the tangential lines of the
- # Bezier path at a distance of width. Same thing for c2_left and
- # c2_right with respect to c2.
- c1x_left, c1y_left, c1x_right, c1y_right = (
- get_normal_points(c1x, c1y, cos_t1, sin_t1, width)
- )
- c2x_left, c2y_left, c2x_right, c2y_right = (
- get_normal_points(c2x, c2y, cos_t2, sin_t2, width)
- )
- # find cm_left which is the intersecting point of a line through
- # c1_left with angle t1 and a line through c2_left with angle
- # t2. Same with cm_right.
- try:
- cmx_left, cmy_left = get_intersection(c1x_left, c1y_left, cos_t1,
- sin_t1, c2x_left, c2y_left,
- cos_t2, sin_t2)
- cmx_right, cmy_right = get_intersection(c1x_right, c1y_right, cos_t1,
- sin_t1, c2x_right, c2y_right,
- cos_t2, sin_t2)
- except ValueError:
- # Special case straight lines, i.e., angle between two lines is
- # less than the threshold used by get_intersection (we don't use
- # check_if_parallel as the threshold is not the same).
- cmx_left, cmy_left = (
- 0.5 * (c1x_left + c2x_left), 0.5 * (c1y_left + c2y_left)
- )
- cmx_right, cmy_right = (
- 0.5 * (c1x_right + c2x_right), 0.5 * (c1y_right + c2y_right)
- )
- # the parallel Bezier lines are created with control points of
- # [c1_left, cm_left, c2_left] and [c1_right, cm_right, c2_right]
- path_left = [(c1x_left, c1y_left),
- (cmx_left, cmy_left),
- (c2x_left, c2y_left)]
- path_right = [(c1x_right, c1y_right),
- (cmx_right, cmy_right),
- (c2x_right, c2y_right)]
- return path_left, path_right
- def find_control_points(c1x, c1y, mmx, mmy, c2x, c2y):
- """
- Find control points of the Bézier curve passing through (*c1x*, *c1y*),
- (*mmx*, *mmy*), and (*c2x*, *c2y*), at parametric values 0, 0.5, and 1.
- """
- cmx = .5 * (4 * mmx - (c1x + c2x))
- cmy = .5 * (4 * mmy - (c1y + c2y))
- return [(c1x, c1y), (cmx, cmy), (c2x, c2y)]
- def make_wedged_bezier2(bezier2, width, w1=1., wm=0.5, w2=0.):
- """
- Being similar to `get_parallels`, returns control points of two quadratic
- Bézier lines having a width roughly parallel to given one separated by
- *width*.
- """
- # c1, cm, c2
- c1x, c1y = bezier2[0]
- cmx, cmy = bezier2[1]
- c3x, c3y = bezier2[2]
- # t1 and t2 is the angle between c1 and cm, cm, c3.
- # They are also an angle of the tangential line of the path at c1 and c3
- cos_t1, sin_t1 = get_cos_sin(c1x, c1y, cmx, cmy)
- cos_t2, sin_t2 = get_cos_sin(cmx, cmy, c3x, c3y)
- # find c1_left, c1_right which are located along the lines
- # through c1 and perpendicular to the tangential lines of the
- # Bezier path at a distance of width. Same thing for c3_left and
- # c3_right with respect to c3.
- c1x_left, c1y_left, c1x_right, c1y_right = (
- get_normal_points(c1x, c1y, cos_t1, sin_t1, width * w1)
- )
- c3x_left, c3y_left, c3x_right, c3y_right = (
- get_normal_points(c3x, c3y, cos_t2, sin_t2, width * w2)
- )
- # find c12, c23 and c123 which are middle points of c1-cm, cm-c3 and
- # c12-c23
- c12x, c12y = (c1x + cmx) * .5, (c1y + cmy) * .5
- c23x, c23y = (cmx + c3x) * .5, (cmy + c3y) * .5
- c123x, c123y = (c12x + c23x) * .5, (c12y + c23y) * .5
- # tangential angle of c123 (angle between c12 and c23)
- cos_t123, sin_t123 = get_cos_sin(c12x, c12y, c23x, c23y)
- c123x_left, c123y_left, c123x_right, c123y_right = (
- get_normal_points(c123x, c123y, cos_t123, sin_t123, width * wm)
- )
- path_left = find_control_points(c1x_left, c1y_left,
- c123x_left, c123y_left,
- c3x_left, c3y_left)
- path_right = find_control_points(c1x_right, c1y_right,
- c123x_right, c123y_right,
- c3x_right, c3y_right)
- return path_left, path_right
|