"""This submodule contains tools that deal with generic, degree n, Bezier curves. Note: Bezier curves here are always represented by the tuple of their control points given by their standard representation.""" # External dependencies: from __future__ import division, absolute_import, print_function from math import factorial as fac, ceil, log, sqrt from numpy import poly1d # Internal dependencies from .polytools import real, imag, polyroots, polyroots01 # Evaluation ################################################################## def n_choose_k(n, k): return fac(n)//fac(k)//fac(n-k) def bernstein(n, t): """returns a list of the Bernstein basis polynomials b_{i, n} evaluated at t, for i =0...n""" t1 = 1-t return [n_choose_k(n, k) * t1**(n-k) * t**k for k in range(n+1)] def bezier_point(p, t): """Evaluates the Bezier curve given by it's control points, p, at t. Note: Uses Horner's rule for cubic and lower order Bezier curves. Warning: Be concerned about numerical stability when using this function with high order curves.""" # begin arc support block ######################## try: p.large_arc return p.point(t) except: pass # end arc support block ########################## deg = len(p) - 1 if deg == 3: return p[0] + t*( 3*(p[1] - p[0]) + t*( 3*(p[0] + p[2]) - 6*p[1] + t*( -p[0] + 3*(p[1] - p[2]) + p[3]))) elif deg == 2: return p[0] + t*( 2*(p[1] - p[0]) + t*( p[0] - 2*p[1] + p[2])) elif deg == 1: return p[0] + t*(p[1] - p[0]) elif deg == 0: return p[0] else: bern = bernstein(deg, t) return sum(bern[k]*p[k] for k in range(deg+1)) # Conversion ################################################################## def bezier2polynomial(p, numpy_ordering=True, return_poly1d=False): """Converts a tuple of Bezier control points to a tuple of coefficients of the expanded polynomial. return_poly1d : returns a numpy.poly1d object. This makes computations of derivatives/anti-derivatives and many other operations quite quick. numpy_ordering : By default (to accommodate numpy) the coefficients will be output in reverse standard order.""" if len(p) == 4: coeffs = (-p[0] + 3*(p[1] - p[2]) + p[3], 3*(p[0] - 2*p[1] + p[2]), 3*(p[1]-p[0]), p[0]) elif len(p) == 3: coeffs = (p[0] - 2*p[1] + p[2], 2*(p[1] - p[0]), p[0]) elif len(p) == 2: coeffs = (p[1]-p[0], p[0]) elif len(p) == 1: coeffs = p else: # https://en.wikipedia.org/wiki/Bezier_curve#Polynomial_form n = len(p) - 1 coeffs = [fac(n)//fac(n-j) * sum( (-1)**(i+j) * p[i] / (fac(i) * fac(j-i)) for i in range(j+1)) for j in range(n+1)] coeffs.reverse() if not numpy_ordering: coeffs = coeffs[::-1] # can't use .reverse() as might be tuple if return_poly1d: return poly1d(coeffs) return coeffs def polynomial2bezier(poly): """Converts a cubic or lower order Polynomial object (or a sequence of coefficients) to a CubicBezier, QuadraticBezier, or Line object as appropriate.""" if isinstance(poly, poly1d): c = poly.coeffs else: c = poly order = len(c)-1 if order == 3: bpoints = (c[3], c[2]/3 + c[3], (c[1] + 2*c[2])/3 + c[3], c[0] + c[1] + c[2] + c[3]) elif order == 2: bpoints = (c[2], c[1]/2 + c[2], c[0] + c[1] + c[2]) elif order == 1: bpoints = (c[1], c[0] + c[1]) else: raise AssertionError("This function is only implemented for linear, " "quadratic, and cubic polynomials.") return bpoints # Curve Splitting ############################################################# def split_bezier(bpoints, t): """Uses deCasteljau's recursion to split the Bezier curve at t into two Bezier curves of the same order.""" def split_bezier_recursion(bpoints_left_, bpoints_right_, bpoints_, t_): if len(bpoints_) == 1: bpoints_left_.append(bpoints_[0]) bpoints_right_.append(bpoints_[0]) else: new_points = [None]*(len(bpoints_) - 1) bpoints_left_.append(bpoints_[0]) bpoints_right_.append(bpoints_[-1]) for i in range(len(bpoints_) - 1): new_points[i] = (1 - t_)*bpoints_[i] + t_*bpoints_[i + 1] bpoints_left_, bpoints_right_ = split_bezier_recursion( bpoints_left_, bpoints_right_, new_points, t_) return bpoints_left_, bpoints_right_ bpoints_left = [] bpoints_right = [] bpoints_left, bpoints_right = \ split_bezier_recursion(bpoints_left, bpoints_right, bpoints, t) bpoints_right.reverse() return bpoints_left, bpoints_right def halve_bezier(p): # begin arc support block ######################## try: p.large_arc return p.split(0.5) except: pass # end arc support block ########################## if len(p) == 4: return ([p[0], (p[0] + p[1])/2, (p[0] + 2*p[1] + p[2])/4, (p[0] + 3*p[1] + 3*p[2] + p[3])/8], [(p[0] + 3*p[1] + 3*p[2] + p[3])/8, (p[1] + 2*p[2] + p[3])/4, (p[2] + p[3])/2, p[3]]) else: return split_bezier(p, 0.5) # Bounding Boxes ############################################################## def bezier_real_minmax(p): """returns the minimum and maximum for any real cubic bezier""" local_extremizers = [0, 1] if len(p) == 4: # cubic case a = [p.real for p in p] denom = a[0] - 3*a[1] + 3*a[2] - a[3] if denom != 0: delta = a[1]**2 - (a[0] + a[1])*a[2] + a[2]**2 + (a[0] - a[1])*a[3] if delta >= 0: # otherwise no local extrema sqdelta = sqrt(delta) tau = a[0] - 2*a[1] + a[2] r1 = (tau + sqdelta)/denom r2 = (tau - sqdelta)/denom if 0 < r1 < 1: local_extremizers.append(r1) if 0 < r2 < 1: local_extremizers.append(r2) local_extrema = [bezier_point(a, t) for t in local_extremizers] return min(local_extrema), max(local_extrema) # find reverse standard coefficients of the derivative dcoeffs = bezier2polynomial(a, return_poly1d=True).deriv().coeffs # find real roots, r, such that 0 <= r <= 1 local_extremizers += polyroots01(dcoeffs) local_extrema = [bezier_point(a, t) for t in local_extremizers] return min(local_extrema), max(local_extrema) def bezier_bounding_box(bez): """returns the bounding box for the segment in the form (xmin, xmax, ymin, ymax). Warning: For the non-cubic case this is not particularly efficient.""" # begin arc support block ######################## try: bla = bez.large_arc return bez.bbox() # added to support Arc objects except: pass # end arc support block ########################## if len(bez) == 4: xmin, xmax = bezier_real_minmax([p.real for p in bez]) ymin, ymax = bezier_real_minmax([p.imag for p in bez]) return xmin, xmax, ymin, ymax poly = bezier2polynomial(bez, return_poly1d=True) x = real(poly) y = imag(poly) dx = x.deriv() dy = y.deriv() x_extremizers = [0, 1] + polyroots(dx, realroots=True, condition=lambda r: 0 < r < 1) y_extremizers = [0, 1] + polyroots(dy, realroots=True, condition=lambda r: 0 < r < 1) x_extrema = [x(t) for t in x_extremizers] y_extrema = [y(t) for t in y_extremizers] return min(x_extrema), max(x_extrema), min(y_extrema), max(y_extrema) def box_area(xmin, xmax, ymin, ymax): """ INPUT: 2-tuple of cubics (given by control points) OUTPUT: boolean """ return (xmax - xmin)*(ymax - ymin) def interval_intersection_width(a, b, c, d): """returns the width of the intersection of intervals [a,b] and [c,d] (thinking of these as intervals on the real number line)""" return max(0, min(b, d) - max(a, c)) def boxes_intersect(box1, box2): """Determines if two rectangles, each input as a tuple (xmin, xmax, ymin, ymax), intersect.""" xmin1, xmax1, ymin1, ymax1 = box1 xmin2, xmax2, ymin2, ymax2 = box2 if interval_intersection_width(xmin1, xmax1, xmin2, xmax2) and \ interval_intersection_width(ymin1, ymax1, ymin2, ymax2): return True else: return False # Intersections ############################################################### class ApproxSolutionSet(list): """A class that behaves like a set but treats two elements , x and y, as equivalent if abs(x-y) < self.tol""" def __init__(self, tol): self.tol = tol def __contains__(self, x): for y in self: if abs(x - y) < self.tol: return True return False def appadd(self, pt): if pt not in self: self.append(pt) class BPair(object): def __init__(self, bez1, bez2, t1, t2): self.bez1 = bez1 self.bez2 = bez2 self.t1 = t1 # t value to get the mid point of this curve from cub1 self.t2 = t2 # t value to get the mid point of this curve from cub2 def bezier_intersections(bez1, bez2, longer_length, tol=1e-8, tol_deC=1e-8): """INPUT: bez1, bez2 = [P0,P1,P2,...PN], [Q0,Q1,Q2,...,PN] defining the two Bezier curves to check for intersections between. longer_length - the length (or an upper bound) on the longer of the two Bezier curves. Determines the maximum iterations needed together with tol. tol - is the smallest distance that two solutions can differ by and still be considered distinct solutions. OUTPUT: a list of tuples (t,s) in [0,1]x[0,1] such that abs(bezier_point(bez1[0],t) - bezier_point(bez2[1],s)) < tol_deC Note: This will return exactly one such tuple for each intersection (assuming tol_deC is small enough).""" maxits = int(ceil(1-log(tol_deC/longer_length)/log(2))) pair_list = [BPair(bez1, bez2, 0.5, 0.5)] intersection_list = [] k = 0 approx_point_set = ApproxSolutionSet(tol) while pair_list and k < maxits: new_pairs = [] delta = 0.5**(k + 2) for pair in pair_list: bbox1 = bezier_bounding_box(pair.bez1) bbox2 = bezier_bounding_box(pair.bez2) if boxes_intersect(bbox1, bbox2): if box_area(*bbox1) < tol_deC and box_area(*bbox2) < tol_deC: point = bezier_point(bez1, pair.t1) if point not in approx_point_set: approx_point_set.append(point) # this is the point in the middle of the pair intersection_list.append((pair.t1, pair.t2)) # this prevents the output of redundant intersection points for otherPair in pair_list: if pair.bez1 == otherPair.bez1 or \ pair.bez2 == otherPair.bez2 or \ pair.bez1 == otherPair.bez2 or \ pair.bez2 == otherPair.bez1: pair_list.remove(otherPair) else: (c11, c12) = halve_bezier(pair.bez1) (t11, t12) = (pair.t1 - delta, pair.t1 + delta) (c21, c22) = halve_bezier(pair.bez2) (t21, t22) = (pair.t2 - delta, pair.t2 + delta) new_pairs += [BPair(c11, c21, t11, t21), BPair(c11, c22, t11, t22), BPair(c12, c21, t12, t21), BPair(c12, c22, t12, t22)] pair_list = new_pairs k += 1 if k >= maxits: raise Exception("bezier_intersections has reached maximum " "iterations without terminating... " "either there's a problem/bug or you can fix by " "raising the max iterations or lowering tol_deC") return intersection_list def bezier_by_line_intersections(bezier, line): """Returns tuples (t1,t2) such that bezier.point(t1) ~= line.point(t2).""" # The method here is to translate (shift) then rotate the complex plane so # that line starts at the origin and proceeds along the positive real axis. # After this transformation, the intersection points are the real roots of # the imaginary component of the bezier for which the real component is # between 0 and abs(line[1]-line[0])]. assert len(line[:]) == 2 assert line[0] != line[1] if not any(p != bezier[0] for p in bezier): raise ValueError("bezier is nodal, use " "bezier_by_line_intersection(bezier[0], line) " "instead for a bool to be returned.") # First let's shift the complex plane so that line starts at the origin shifted_bezier = [z - line[0] for z in bezier] shifted_line_end = line[1] - line[0] line_length = abs(shifted_line_end) # Now let's rotate the complex plane so that line falls on the x-axis rotation_matrix = line_length/shifted_line_end transformed_bezier = [rotation_matrix*z for z in shifted_bezier] # Now all intersections should be roots of the imaginary component of # the transformed bezier transformed_bezier_imag = [p.imag for p in transformed_bezier] coeffs_y = bezier2polynomial(transformed_bezier_imag) roots_y = list(polyroots01(coeffs_y)) # returns real roots 0 <= r <= 1 transformed_bezier_real = [p.real for p in transformed_bezier] intersection_list = [] for bez_t in set(roots_y): xval = bezier_point(transformed_bezier_real, bez_t) if 0 <= xval <= line_length: line_t = xval/line_length intersection_list.append((bez_t, line_t)) return intersection_list