def center(self): """ Returns a Point representing the center of the Triangle. >>> P = point.Point >>> print(Triangle(P(3, 5), P(0, 1), P(-1, 3)).center()) (0.6666666666666667, 3.0) >>> print(Triangle(P(-10, 0), P(0, 0.000001), P(10, 0)).center()) (0.0, 5e-07) """ if self.isDegenerate(): minX = min(self.p1.x, self.p2.x, self.p3.x) minY = min(self.p1.y, self.p2.y, self.p3.y) maxX = max(self.p1.x, self.p2.x, self.p3.x) maxY = max(self.p1.y, self.p2.y, self.p3.y) L = line.Line(point.Point(minX, minY), point.Point(maxX, maxY)) return L.midpoint() Line = line.Line line12 = Line(self.p1, self.p2) line23 = Line(self.p2, self.p3) testLine1 = Line(self.p3, line12.midpoint()) testLine2 = Line(self.p1, line23.midpoint()) return testLine1.intersection(testLine2)
def normalized(self, length=1): """ Returns a new Line whose first point is the same as self's, but whose second point is moved such that the length of the new Line is as specified. The new Line's second point will be in the same direction relative to the first point as the original Line's was. If self is degenerate it cannot be normalized to a non-zero length, so a copy of self will be returned if length is zero and None will be returned otherwise. Specifying a length of zero will return a degenerate line both of whose points are the same as self.p1. >>> P = point.Point >>> print(Line(P(0, 0), P(10, 0)).normalized()) Line from (0, 0) to (1.0, 0.0) >>> print(Line(P(0, 0), P(0, 10)).normalized()) Line from (0, 0) to (0, 1) >>> print(Line(P(0, 0), P(0, -10)).normalized()) Line from (0, 0) to (0, -1) >>> print(Line(P(1, -1), P(-2, -6)).normalized()) Line from (1, -1) to (0.48550424457247343, -1.8574929257125443) >>> print(Line(P(0, 0), P(10, 0)).normalized(length=5)) Line from (0, 0) to (5.0, 0.0) Degenerate cases: >>> LDeg = Line(P(2, -3), P(2, -3)) >>> LDeg.normalized() is None True >>> LDeg.normalized(0) == LDeg True >>> LDeg.normalized(0) is LDeg False """ p = self.p1 if not length: return type(self)(p, p) slope = self.slope() if slope is None: return (None if length else self.__copy__()) if abs(slope) == float("+inf"): # line is vertical sign = (-1 if self.p2.y < p.y else 1) return type(self)(p, point.Point(p.x, p.y + sign * length)) sign = (-1 if self.p2.x < p.x else 1) x = p.x + sign * math.sqrt(length**2 / (1 + slope**2)) y = p.y + slope * (x - p.x) return type(self)(p, point.Point(x, y))
def corners(self): """ Returns a tuple with the two corners as Points. >>> for obj in _testingValues[1].corners(): print(obj) (10, -20) (90, 100) """ p1 = point.Point(self.xMin, self.yMin) p2 = point.Point(self.xMax, self.yMax) return (p1, p2)
def transitions(self, xStart, xStop, yCoord, leftToRight): """ Finds the intersection of self and the horizontal line segment defined by xStart, xStop, and yCoord, then for each point in the intersection determines whether the curve passes by left-to-right or right-to-left when looked at in the specified leftToRight orientation. See the doctest examples below for a sense of how this works; basically, a dict is returned with True mapping to seen left-to-right transitions, and False mapping to seen right-to-left transitions. >>> obj = _testingValues[0] >>> print(obj.transitions(0, 10, 3, True)) {False: 1, True: 0} >>> print(obj.transitions(0, 10, 3, False)) {False: 0, True: 1} >>> obj = _testingValues[4] >>> print(obj.transitions(0, 5, 2, True)) {False: 1, True: 1} >>> print(obj.transitions(0, 5, 1, True)) {False: 2, True: 1} """ ray = type(self)( start = point.Point(xStart, yCoord), control0 = None, control1 = None, end = point.Point(xStop, yCoord)) sects = self.intersection(ray) r = {False: 0, True: 0} CE = mathutilities.closeEnough for p in sorted(sects, reverse=(not leftToRight)): if not isinstance(p, point.Point): continue t = self.parametricValueFromPoint(p) p2 = self.pointFromParametricValue(t - 0.001) p3 = self.pointFromParametricValue(t + 0.001) if CE(p2.y, p3.y): continue if p2.y > p3.y: r[leftToRight] += 1 else: r[not leftToRight] += 1 return r
def pointFromParametricValue(self, t): """ Given a parametric value t, where t=0 maps to self.onCurve1 and t=1 maps to self.onCurve2, returns a Point representing that value. >>> P = point.Point >>> b = BSpline(P(2, -3), P(4, 4), P(0, 1)) >>> print(b.pointFromParametricValue(0)) (2, -3) >>> print(b.pointFromParametricValue(1)) (4, 4) >>> print(b.pointFromParametricValue(0.5)) (1.5, 0.75) """ if self.offCurve is None: # linear case x = (t * self.onCurve2.x) + ((1.0 - t) * self.onCurve1.x) y = (t * self.onCurve2.y) + ((1.0 - t) * self.onCurve1.y) else: # quadratic case factor0 = (1 - t)**2 factor1 = 2 * t * (1 - t) factor2 = t * t x = ((factor0 * self.onCurve1.x) + (factor1 * self.offCurve.x) + (factor2 * self.onCurve2.x)) y = ((factor0 * self.onCurve1.y) + (factor1 * self.offCurve.y) + (factor2 * self.onCurve2.y)) return point.Point(x, y)
def piece(self, t1, t2): """ Create a new b-spline mapping the original parametric values in the range t1 to t2 into new values from 0 to 1. Returns two things: the new b-spline, and an anonymous function which maps an old t-value (i.e. a value in the range from t1 through t2) onto a new u-value from 0 through 1. >>> P = point.Point >>> b = BSpline(P(2, -3), P(4, 4), P(0, 1)) >>> bNew, f = b.piece(0.25, 0.75) >>> print(bNew) Curve from (1.375, -1.0625) to (2.375, 2.4375) with off-curve point (1.125, 0.8125) >>> for t in (0.0, 0.25, 0.5, 0.75, 1.0): ... tNew = f(t) ... print(b.pointFromParametricValue(t), bNew.pointFromParametricValue(tNew)) (2.0, -3.0) (2.0, -3.0) (1.375, -1.0625) (1.375, -1.0625) (1.5, 0.75) (1.5, 0.75) (2.375, 2.4375) (2.375, 2.4375) (4.0, 4.0) (4.0, 4.0) >>> c = BSpline(P(2, -3), P(4, 4), None) >>> c.piece(0,10)[0].pprint() onCurve1: 0: 2.0 1: -3.0 onCurve2: 0: 22.0 1: 67.0 offCurve: (no data) >>> c.piece(100,-10)[0].pprint() onCurve1: 0: -18.0 1: -73.0 onCurve2: 0: 202.0 1: 697.0 offCurve: (no data) """ if t1 > t2: t1, t2 = t2, t1 newP0 = self.pointFromParametricValue(t1) newP2 = self.pointFromParametricValue(t2) if self.offCurve is None: # linear case newSpline = BSpline(newP0, newP2) else: # quadratic case newP1 = self.pointFromParametricValue((t1 + t2) / 2) newP1x = 2 * (newP1.x - 0.25 * (newP0.x + newP2.x)) newP1y = 2 * (newP1.y - 0.25 * (newP0.y + newP2.y)) newSpline = BSpline(newP0, newP2, point.Point(newP1x, newP1y)) return newSpline, lambda x: ((x - t1) / (t2 - t1))
def pointFromParametricValue(self, t): """ Returns a Point representing the specified parametric value. >>> obj = _testingValues[0] >>> print(obj.pointFromParametricValue(0)) (1, 2) >>> print(obj.pointFromParametricValue(1)) (10, 7) >>> print(obj.pointFromParametricValue(0.5)) (4.0, 5.25) >>> print(obj.pointFromParametricValue(0.25)) (2.125, 4.1875) >>> print(obj.pointFromParametricValue(1.5)) (19.0, 13.25) >>> print(obj.pointFromParametricValue(-0.75)) (2.125, -18.8125) """ if self.isLine(): L = line.Line(self.start, self.end) return L.pointFromParametricValue(t) if t == 0: return self.start if t == 1: return self.end a = self.start.x b = self.control0.x c = self.control1.x d = self.end.x e = self.start.y f = self.control0.y g = self.control1.y h = self.end.y x = ( (-a + 3 * b - 3 * c + d) * (t ** 3) + (3 * a - 6 * b + 3 * c) * (t ** 2) + (-3 * a + 3 * b) * t + a) y = ( (-e + 3 * f - 3 * g + h) * (t ** 3) + (3 * e - 6 * f + 3 * g) * (t ** 2) + (-3 * e + 3 * f) * t + e) return point.Point(x, y)
def center(self): """ Returns a Point representing the center of the rectangle. >>> print(_testingValues[1].center()) (50.0, 40.0) """ return point.Point((self.xMin + self.xMax) / 2, (self.yMin + self.yMax) / 2)
def parametricValueFromProjectedPoint(self, p): """ Given a Point, not necessarily on the line, find the t-value representing the projection of that point onto the line. The returned t-value is not necessarily in the [0, 1] range. If self is degenerate, zero is returned. >>> P = point.Point >>> Line(P(0, 0), P(10, 0)).parametricValueFromProjectedPoint(P(2, 6)) 0.2 >>> Line(P(0, 0), P(0, 10)).parametricValueFromProjectedPoint(P(2, 6)) 0.6 >>> Line(P(1, 1), P(5, 4)).parametricValueFromProjectedPoint(P(2, 4)) 0.52 >>> Line(P(1, 1), P(5, 4)).parametricValueFromProjectedPoint(P(25, 4)) 4.2 Degenerate lines always return 0, regardless of the point: >>> Line(P(2, -3), P(2, -3)).parametricValueFromProjectedPoint(P(0, 0)) 0.0 """ m = self.slope() if m is None: return 0.0 if abs(m) == float("+inf"): # line is vertical return self.parametricValueFromPoint(point.Point(self.p1.x, p.y)) if mathutilities.zeroEnough(m): # line is horizontal return self.parametricValueFromPoint(point.Point(p.x, self.p1.y)) # line is neither vertical nor horizontal numer = self.p1.y - p.y - (m * self.p1.x) - ((1 / m) * p.x) denom = -m - (1 / m) x = numer / denom return (x - self.p1.x) / (self.p2.x - self.p1.x)
def piece(self, t1, t2): """ Create and return a new CSpline object which maps t1 to t2 in the original to 0 to 1 in the new. Returns two things: the new CSpline, and an anonymous function which maps an old t-value to a new u-value. >>> origObj = _testingValues[0] >>> origObj.pprint() Start point: 0: 1 1: 2 First control point: 0: 2 1: 6 Second control point: 0: 5 1: 5 End point: 0: 10 1: 7 >>> newObj, f = origObj.piece(0.25, 0.75) >>> newObj.pprint() Start point: 0: 2.125 1: 4.1875 First control point: 0: 3.125 1: 5.1875 Second control point: 0: 4.625 1: 5.4375 End point: 0: 6.625 1: 5.9375 >>> print(origObj.pointFromParametricValue(0.25)) (2.125, 4.1875) >>> print(newObj.pointFromParametricValue(0)) (2.125, 4.1875) >>> print(origObj.pointFromParametricValue(0.75)) (6.625, 5.9375) >>> print(newObj.pointFromParametricValue(1)) (6.625, 5.9375) """ if self.isLine(): return line.Line(self.start, self.end).piece(t1, t2) A, B, C, D, E, F, G, H = self._coefficients() if t1 > t2: t1, t2 = t2, t1 a = t2 - t1 b = t1 A2 = A * (a ** 3) B2 = 3 * A * a * a * b + B * a * a C2 = 3 * A * a * b * b + 2 * B * a * b + C * a D2 = A * (b ** 3) + B * b * b + C * b + D E2 = E * (a ** 3) F2 = 3 * E * a * a * b + F * a * a G2 = 3 * E * a * b * b + 2 * F * a * b + G * a H2 = E * (b ** 3) + F * b * b + G * b + H pVec = self._parametersToPoints(A2, B2, C2, D2, E2, F2, G2, H2) newSpline = type(self)( point.Point(pVec[0], pVec[4]), point.Point(pVec[1], pVec[5]), point.Point(pVec[2], pVec[6]), point.Point(pVec[3], pVec[7])) return newSpline, lambda x: ((x - t1) / (t2 - t1))
class CSpline(object, metaclass=simplemeta.FontDataMetaclass): """ Objects representing cubic b-splines. These are simple collections of 4 attributes, representing the start and end points and the two control points. """ # # Class definition variables # attrSpec = dict( start = dict( attr_followsprotocol = True, attr_initfunc = (lambda: point.Point(0, 0)), attr_label = "Start point"), control0 = dict( attr_followsprotocol = True, attr_initfunc = (lambda: point.Point(0, 0)), attr_label = "First control point"), control1 = dict( attr_followsprotocol = True, attr_initfunc = (lambda: point.Point(0, 0)), attr_label = "Second control point"), end = dict( attr_followsprotocol = True, attr_initfunc = (lambda: point.Point(0, 0)), attr_label = "End point")) attrSorted = ('start', 'control0', 'control1', 'end') # # Methods # def __str__(self): """ Returns a nice string representation of the CSpline object. >>> for obj in _testingValues: print(obj) Curve from (1, 2) to (10, 7) with controls points (2, 6) and (5, 5) Curve from (1, 1) to (19, 1) with controls points (17, 9) and (3, 8) Line from (1, 1) to (5, 5) Curve from (4, 5.25) to (19, 13.25) with controls points (7, 6.25) and (12, 6.25) Curve from (1, 1) to (4, 1) with controls points (2, 10) and (3, -10) Curve from (1, 1) to (7, 12) with coincident control points at (4, 3) Point at (4, -3) """ if self.start.equalEnough(self.end): return "Point at %s" % (self.start,) if self.isLine(): return "Line from %s to %s" % (self.start, self.end) if self.control0.equalEnough(self.control1): return ( "Curve from %s to %s with coincident control points at %s" % (self.start, self.end, self.control0)) return ( "Curve from %s to %s with controls points %s and %s" % (self.start, self.end, self.control0, self.control1)) def _coefficients(self): """ Returns a tuple with 8 elements, being the numeric factors in the expansion of self into the form: x(t) = At^3 + Bt^2 + Ct + D y(t) = Et^3 + Ft^2 + Gt + H That is, the tuple (A, B, C, D, E, F, G, H) is returned. >>> print(_testingValues[0]._coefficients()) (0, 6, 3, 1, 8, -15, 12, 2) >>> print(_testingValues[1]._coefficients()) (60, -90, 48, 1, 3, -27, 24, 1) >>> print(_testingValues[3]._coefficients()) (0, 6, 9, 4, 8.0, -3.0, 3.0, 5.25) """ return self._pointsToParameters( self.start.x, self.control0.x, self.control1.x, self.end.x, self.start.y, self.control0.y, self.control1.y, self.end.y) @staticmethod def _parametersToPoints(A, B, C, D, E, F, G, H): a = D b = (C + 3 * a) / 3 c = (B + 6 * b - 3 * a) / 3 d = A + a - 3 * b + 3 * c e = H f = (G + 3 * e) / 3 g = (F + 6 * f - 3 * e) / 3 h = E + e - 3 * f + 3 * g return (a, b, c, d, e, f, g, h) @staticmethod def _pointsToParameters(a, b, c, d, e, f, g, h): A = 3 * b - 3 * c + d - a B = 3 * a + 3 * c - 6 * b C = 3 * b - 3 * a D = a E = 3 * f - 3 * g + h - e F = 3 * e + 3 * g - 6 * f G = 3 * f - 3 * e H = e return (A, B, C, D, E, F, G, H) def area(self): """ Analytical solution for the area bounded by the curve and the line connecting self.start and self.end. Note this is sign-sensitive, so the area might not be the value you might expect. >>> round(_testingValues[0].area(), 10) 8.7 >>> _testingValues[4].area() -1.5 >>> _testingValues[2].area() 12.0 """ width = self.end.x - self.start.x trapezoidArea = width * (self.start.y + self.end.y) / 2 if self.isLine(): return trapezoidArea A, B, C, D, E, F, G, H = self._coefficients() tY = (H, G, F, E) tX = (D, C, B, A) txDeriv = mathutilities.polyderiv(tX) tProd = mathutilities.polymul(tY, txDeriv) return sum(mathutilities.polyinteg(tProd)) - trapezoidArea def bounds(self): """ Returns a Rectangle representing the actual bounds, based on the curve itself. >>> print(_testingValues[0].bounds()) Minimum X = 1, Minimum Y = 2, Maximum X = 10, Maximum Y = 7 >>> print(_testingValues[1].bounds()) Minimum X = 1, Minimum Y = 1, Maximum X = 19, Maximum Y = 6.631236180096162 >>> print(_testingValues[2].bounds()) Minimum X = 1, Minimum Y = 1, Maximum X = 5, Maximum Y = 5 """ if self.isLine(): return line.Line(self.start, self.end).bounds() extremaRect = self.extrema(True) a = self.start.x b = self.control0.x c = self.control1.x d = self.end.x e = self.start.y f = self.control0.y g = self.control1.y h = self.end.y ZE = mathutilities.zeroEnough CES = mathutilities.closeEnoughSpan # xZeroes are where dx/dt goes to zero (i.e. vertical tangents) xZeroes = mathutilities.quadratic( 3 * (-a + 3 * b - 3 * c + d), 2 * (3 * a - 6 * b + 3 * c), (-3 * a + 3 * b)) xZeroes = [n for n in xZeroes if ZE(n.imag) if CES(n)] # yZeroes are where dy/dt goes to zero (i.e. horizontal tangents) yZeroes = mathutilities.quadratic( 3 * (-e + 3 * f - 3 * g + h), 2 * (3 * e - 6 * f + 3 * g), (-3 * e + 3 * f)) yZeroes = [n for n in yZeroes if ZE(n.imag) if CES(n)] pX = [ self.pointFromParametricValue(root) for root in xZeroes] pY = [ self.pointFromParametricValue(root) for root in yZeroes if root not in xZeroes] vXMin = [extremaRect.xMin] + [p.x for p in pX] vXMax = [extremaRect.xMax] + [p.x for p in pX] vYMin = [extremaRect.yMin] + [p.y for p in pY] vYMax = [extremaRect.yMax] + [p.y for p in pY] return rectangle.Rectangle( min(vXMin), min(vYMin), max(vXMax), max(vYMax)) def distanceToPoint(self, p): """ Returns an unsigned distance from the point to the nearest position on the curve. This will perforce be a point normal to the curve, but as there may be several such, the distance returned will be the smallest. >>> P = point.Point >>> print(_testingValues[0].distanceToPoint(P(5, 3))) 2.4471964319857658 >>> print(_testingValues[1].distanceToPoint(P(10, 10))) 3.3691151093862604 >>> print(_testingValues[2].distanceToPoint(P(6,6))) 0.0 """ if self.isLine(): L = line.Line(self.start, self.end) return L.distanceToPoint(p) a = self.start.x b = self.control0.x c = self.control1.x d = self.end.x e = self.start.y f = self.control0.y g = self.control1.y h = self.end.y A = 3 * b - 3 * c + d - a B = 3 * a + 3 * c - 6 * b C = 3 * b - 3 * a D = 3 * f - 3 * g + h - e E = 3 * e + 3 * g - 6 * f F = 3 * f - 3 * e G = a - p.x H = e - p.y def sumPrime(t): return ( 6 * (A * A + D * D) * (t ** 5) + 10 * (A * B + D * E) * (t ** 4) + 4 * (B * B + E * E + 2 * A * C + 2 * D * F) * (t ** 3) + 6 * (A * G + B * C + D * H + E * F) * (t * t) + 2 * (C * C + F * F + 2 * B * G + 2 * E * H) * t + 2 * (C * G + F * H)) def sumDoublePrime(t): return ( 30 * (A * A + D * D) * (t ** 4) + 40 * (A * B + D * E) * (t ** 3) + 12 * (B * B + E * E + 2 * A * C + 2 * D * F) * (t * t) + 12 * (A * G + B * C + D * H + E * F) * t + 2 * (C * C + F * F + 2 * B * G + 2 * E * H)) tReal = mathutilities.newton(sumPrime, sumDoublePrime, 0.5) # Now we remove this root from the quintic, leaving a quartic that can # be solved directly. factors = ( 2 * (C * G + F * H), 2 * (C * C + F * F + 2 * B * G + 2 * E * H), 6 * (A * G + B * C + D * H + E * F), 4 * (B * B + E * E + 2 * A * C + 2 * D * F), 10 * (A * B + D * E), 6 * (A * A + D * D)) reducedFactors = mathutilities.polyreduce(factors, tReal) roots = (tReal,) + mathutilities.quartic(*reversed(reducedFactors)) ZE = mathutilities.zeroEnough v = [ p.distanceFrom(self.pointFromParametricValue(root)) for root in roots if ZE(root.imag)] return utilities.safeMin(v) def extrema(self, excludeOffCurve=False): """ Returns a Rectangle representing the extrema (based on the actual point coordinates, and not the curve itself). >>> print(_testingValues[0].extrema()) Minimum X = 1, Minimum Y = 2, Maximum X = 10, Maximum Y = 7 >>> print(_testingValues[2].extrema()) Minimum X = 1, Minimum Y = 1, Maximum X = 5, Maximum Y = 5 """ if self.isLine(): return line.Line(self.start, self.end).extrema() if excludeOffCurve: ps = [self.start, self.end] else: ps = [self.start, self.control0, self.control1, self.end] return rectangle.Rectangle( min(p.x for p in ps), min(p.y for p in ps), max(p.x for p in ps), max(p.y for p in ps)) def intersection(self, other, delta=1.0e-5): """ Returns a tuple of objects representing the intersection of the two CSpline objects, usually Points but possibly a CSpline; empty if there is no intersection. We use Newton's method here to solve the nonlinear system: self_x(t) - other_x(u) = 0 self_y(t) - other_y(u) = 0 There is an admittedly ad hoc decision here to look at 25 different (t, u) pairs as starting values to find local solutions. This method has (so far) found all solutions for intersections I've thrown at it, including one case with 5 intersections, but this heuristic code should be rigorously tested. Also, the resulting t- and u-values are rounded to six places. >>> for obj in _testingValues[0].intersection(_testingValues[3]): ... obj.pprint() Start point: 0: 4.0 1: 5.25 First control point: 0: 5.5 1: 5.75 Second control point: 0: 7.5 1: 6.0 End point: 0: 10.0 1: 7.0 >>> P = point.Point >>> a = CSpline(P(0, 0), P(3, 3), P(4, 4), P(6, 6)) >>> for obj in _testingValues[2].intersection(a): ... print(str(obj)) Line from (1, 1) to (5, 5) >>> for obj in _testingValues[2].intersection(_testingValues[2]): ... print(str(obj)) Line from (1, 1) to (5, 5) >>> c = CSpline(P(0, 0), P(-1, 0), P(0, 0), P(0, 0)) >>> print(_testingValues[2].intersection(c)) () """ # Before we start working on potential point intersections, do a check # to see if the entire cubics overlap, or if they're both lines. selfLine = self.isLine() otherLine = other.isLine() if selfLine or otherLine: if selfLine and otherLine: L1 = line.Line(self.start, self.end) L2 = line.Line(other.start, other.end) sect = L1.intersection(L2, delta) if sect is None: return () return (sect,) if selfLine and (self.control0 is None and self.control1 is None): L1 = line.Line(self.start, self.end) self = type(self)( self.start, L1.midpoint(), L1.midpoint(), self.end) if otherLine and (other.control0 is None and other.control1 is None): L1 = line.Line(other.start, other.end) other = type(other)( other.start, L1.midpoint(), L1.midpoint(), other.end) pvsSelf = [0.0, 0.25, 0.5, 0.75, 1.0] pts = [self.pointFromParametricValue(n) for n in pvsSelf] pvsOther = [other.parametricValueFromPoint(pt) for pt in pts] ZE = mathutilities.zeroEnough if None not in pvsOther: diffs = [i - j for i, j in zip(pvsSelf, pvsOther)] if ZE(max(diffs) - min(diffs)): d = diffs[0] if not (ZE(d) or ZE(d - 1) or (0 < d < 1)): return () elif ZE(d): return (self.__deepcopy__(),) elif d < 0: return (self.piece(0, 1 + d)[0],) return (self.piece(d, 1)[0],) # If we get here, there is no overlap, so proceed with the actual # calculations. A, B, C, D, E, F, G, H = self._coefficients() S, T, U, V, W, X, Y, Z = other._coefficients() def func(v): # maps a (u, v) pair to a distance in x and y t, u = v x = ( A * (t ** 3) + B * t * t + C * t + D - S * (u ** 3) - T * u * u - U * u - V) y = ( E * (t ** 3) + F * t * t + G * t + H - W * (u ** 3) - X * u * u - Y * u - Z) return [x, y] def Jacobian(t, u): e1 = 3 * A * t * t + 2 * B * t + C e2 = -3 * S * u * u - 2 * T * u - U e3 = 3 * E * t * t + 2 * F * t + G e4 = -3 * W * u * u - 2 * X * u - Y return [[e1, e2], [e3, e4]] def matInv(m): det = m[0][0] * m[1][1] - m[0][1] * m[1][0] return [ [m[1][1] / det, -m[0][1] / det], [-m[1][0] / det, m[0][0] / det]] def matMul(m, v): e1 = m[0][0] * v[0] + m[0][1] * v[1] e2 = m[1][0] * v[0] + m[1][1] * v[1] return [e1, e2] solutions = set() zeroDivCount = 0 giveUpCount = 0 for n1 in range(5): for n2 in range(5): n = [n1 / 4, n2 / 4] nPrior = [-1, -1] iterCount = 0 okToProceed = True while (not ZE(n[0] - nPrior[0])) or (not ZE(n[1] - nPrior[1])): try: nDelta = matMul(matInv(Jacobian(*n)), func(n)) except ZeroDivisionError: zeroDivCount += 1 okToProceed = False if not okToProceed: break nPrior = list(n) n[0] -= nDelta[0] n[1] -= nDelta[1] iterCount += 1 if iterCount > 10: giveUpCount += 1 okToProceed = False break if okToProceed: if not (ZE(n[0]) or ZE(n[0] - 1) or (0 < n[0] < 1)): continue if not (ZE(n[1]) or ZE(n[1] - 1) or (0 < n[1] < 1)): continue checkIt = func(n) if (not ZE(checkIt[0])) or (not ZE(checkIt[1])): continue n = (round(n[0], 6), round(n[1], 6)) solutions.add(n) return tuple( self.pointFromParametricValue(obj[0]) for obj in solutions) def isLine(self): """ Returns True if the two control points are colinear with the start and end points (or are both None), False otherwise. >>> _testingValues[1].isLine() False >>> _testingValues[2].isLine() True """ if self.control0 is None and self.control1 is None: return True L = line.Line(self.start, self.end) if L.parametricValueFromPoint(self.control0) is None: return False return L.parametricValueFromPoint(self.control1) is not None def magnified(self, xScale=1, yScale=1): """ Returns a new CSpline scaled as specified about the origin. """ if self.isLine(): return type(self)( self.start.magnified(xScale, yScale), None, None, self.end.magnified(xScale, yScale)) return type(self)( self.start.magnified(xScale, yScale), self.control0.magnified(xScale, yScale), self.control1.magnified(xScale, yScale), self.end.magnified(xScale, yScale)) def magnifiedAbout(self, about, xScale=1, yScale=1): """ Returns a new CSpline scaled as specified about the specified point. """ if self.isLine(): return type(self)( self.start.magnifiedAbout(about, xScale, yScale), None, None, self.end.magnifiedAbout(about, xScale, yScale)) return type(self)( self.start.magnifiedAbout(about, xScale, yScale), self.control0.magnifiedAbout(about, xScale, yScale), self.control1.magnifiedAbout(about, xScale, yScale), self.end.magnifiedAbout(about, xScale, yScale)) def moved(self, deltaX=0, deltaY=0): """ Returns a new CSpline moved by the specified amount. """ if self.isLine(): return type(self)( self.start.moved(deltaX, deltaY), None, None, self.end.moved(deltaX, deltaY)) return type(self)( self.start.moved(deltaX, deltaY), self.control0.moved(deltaX, deltaY), self.control1.moved(deltaX, deltaY), self.end.moved(deltaX, deltaY)) def parametricValueFromPoint(self, p): """ Returns the parametric t-value for the specified point p, or None if p does not lie on the spline. >>> obj = _testingValues[0] >>> print(obj.parametricValueFromPoint(point.Point(1, 2))) 0 >>> print(obj.parametricValueFromPoint(point.Point(10, 7))) 1 >>> print(obj.parametricValueFromPoint(point.Point(4, 5.25))) 0.5 >>> print(obj.parametricValueFromPoint(point.Point(4, -5.25))) None """ if self.isLine(): L = line.Line(self.start, self.end) return L.parametricValueFromPoint(p) a = self.start.x b = self.control0.x c = self.control1.x d = self.end.x roots = mathutilities.cubic( (-a + 3 * b - 3 * c + d), (3 * a - 6 * b + 3 * c), (-3 * a + 3 * b), (a - p.x)) # Check that the y-value for real roots is sufficiently close for root in roots: if not mathutilities.zeroEnough(root.imag): continue pTest = self.pointFromParametricValue(root) if mathutilities.closeEnough(p.y, pTest.y): return root return None def piece(self, t1, t2): """ Create and return a new CSpline object which maps t1 to t2 in the original to 0 to 1 in the new. Returns two things: the new CSpline, and an anonymous function which maps an old t-value to a new u-value. >>> origObj = _testingValues[0] >>> origObj.pprint() Start point: 0: 1 1: 2 First control point: 0: 2 1: 6 Second control point: 0: 5 1: 5 End point: 0: 10 1: 7 >>> newObj, f = origObj.piece(0.25, 0.75) >>> newObj.pprint() Start point: 0: 2.125 1: 4.1875 First control point: 0: 3.125 1: 5.1875 Second control point: 0: 4.625 1: 5.4375 End point: 0: 6.625 1: 5.9375 >>> print(origObj.pointFromParametricValue(0.25)) (2.125, 4.1875) >>> print(newObj.pointFromParametricValue(0)) (2.125, 4.1875) >>> print(origObj.pointFromParametricValue(0.75)) (6.625, 5.9375) >>> print(newObj.pointFromParametricValue(1)) (6.625, 5.9375) """ if self.isLine(): return line.Line(self.start, self.end).piece(t1, t2) A, B, C, D, E, F, G, H = self._coefficients() if t1 > t2: t1, t2 = t2, t1 a = t2 - t1 b = t1 A2 = A * (a ** 3) B2 = 3 * A * a * a * b + B * a * a C2 = 3 * A * a * b * b + 2 * B * a * b + C * a D2 = A * (b ** 3) + B * b * b + C * b + D E2 = E * (a ** 3) F2 = 3 * E * a * a * b + F * a * a G2 = 3 * E * a * b * b + 2 * F * a * b + G * a H2 = E * (b ** 3) + F * b * b + G * b + H pVec = self._parametersToPoints(A2, B2, C2, D2, E2, F2, G2, H2) newSpline = type(self)( point.Point(pVec[0], pVec[4]), point.Point(pVec[1], pVec[5]), point.Point(pVec[2], pVec[6]), point.Point(pVec[3], pVec[7])) return newSpline, lambda x: ((x - t1) / (t2 - t1)) def pointFromParametricValue(self, t): """ Returns a Point representing the specified parametric value. >>> obj = _testingValues[0] >>> print(obj.pointFromParametricValue(0)) (1, 2) >>> print(obj.pointFromParametricValue(1)) (10, 7) >>> print(obj.pointFromParametricValue(0.5)) (4.0, 5.25) >>> print(obj.pointFromParametricValue(0.25)) (2.125, 4.1875) >>> print(obj.pointFromParametricValue(1.5)) (19.0, 13.25) >>> print(obj.pointFromParametricValue(-0.75)) (2.125, -18.8125) """ if self.isLine(): L = line.Line(self.start, self.end) return L.pointFromParametricValue(t) if t == 0: return self.start if t == 1: return self.end a = self.start.x b = self.control0.x c = self.control1.x d = self.end.x e = self.start.y f = self.control0.y g = self.control1.y h = self.end.y x = ( (-a + 3 * b - 3 * c + d) * (t ** 3) + (3 * a - 6 * b + 3 * c) * (t ** 2) + (-3 * a + 3 * b) * t + a) y = ( (-e + 3 * f - 3 * g + h) * (t ** 3) + (3 * e - 6 * f + 3 * g) * (t ** 2) + (-3 * e + 3 * f) * t + e) return point.Point(x, y) def rotated(self, angleInDegrees=0): """ Returns a new CSpline rotated as specified about the origin. """ if self.isLine(): return type(self)( self.start.rotated(angleInDegrees), None, None, self.end.rotated(angleInDegrees)) return type(self)( self.start.rotated(angleInDegrees), self.control0.rotated(angleInDegrees), self.control1.rotated(angleInDegrees), self.end.rotated(angleInDegrees)) def rotatedAbout(self, about, angleInDegrees=0): """ Returns a new CSpline rotated as specified about the specified point. """ if self.isLine(): return type(self)( self.start.rotatedAbout(about, angleInDegrees), None, None, self.end.rotatedAbout(about, angleInDegrees)) return type(self)( self.start.rotatedAbout(about, angleInDegrees), self.control0.rotatedAbout(about, angleInDegrees), self.control1.rotatedAbout(about, angleInDegrees), self.end.rotatedAbout(about, angleInDegrees)) def slopeFromParametricValue(self, t): """ Returns the slope of the curve at the parametric value t. >>> obj = _testingValues[0] >>> print(obj.slopeFromParametricValue(0)) 4.0 >>> print(obj.slopeFromParametricValue(1)) 0.4 """ ZE = mathutilities.zeroEnough if self.isLine(): # The slope is constant in this case p1, p2 = self.start, self.end if ZE(p2.x - p1.x): return float("+inf") return (p2.y - p1.y) / (p2.x - p1.x) # Let a, b, c, d be the x-coordinates, in order. # Then x = a(1-t)^3 + 3bt(1-t)^2 + 3ct^2(1-t) + dt^3 # So x = # (-a + 3b - 3c + d)t^3 + # (3a - 6b + 3c)t^2 + # (-3a + 3b)t + # a # # Similarly, for e, f, g, h as the y-analogs, we get: # y = # (-e + 3f - 3g + h)t^3 + # (3e - 6f + 3g)t^2 + # (-3e + 3f)t + # e # # The slope is then the ratio of (dy / dt) / (dx / dt). a = self.start.x b = self.control0.x c = self.control1.x d = self.end.x denom = ( 3 * (-a + 3 * b - 3 * c + d) * (t ** 2) + 2 * (3 * a - 6 * b + 3 * c) * t + (-3 * a + 3 * b)) if ZE(denom): return float("+inf") e = self.start.y f = self.control0.y g = self.control1.y h = self.end.y numer = ( 3 * (-e + 3 * f - 3 * g + h) * (t ** 2) + 2 * (3 * e - 6 * f + 3 * g) * t + (-3 * e + 3 * f)) return numer / denom def transitions(self, xStart, xStop, yCoord, leftToRight): """ Finds the intersection of self and the horizontal line segment defined by xStart, xStop, and yCoord, then for each point in the intersection determines whether the curve passes by left-to-right or right-to-left when looked at in the specified leftToRight orientation. See the doctest examples below for a sense of how this works; basically, a dict is returned with True mapping to seen left-to-right transitions, and False mapping to seen right-to-left transitions. >>> obj = _testingValues[0] >>> print(obj.transitions(0, 10, 3, True)) {False: 1, True: 0} >>> print(obj.transitions(0, 10, 3, False)) {False: 0, True: 1} >>> obj = _testingValues[4] >>> print(obj.transitions(0, 5, 2, True)) {False: 1, True: 1} >>> print(obj.transitions(0, 5, 1, True)) {False: 2, True: 1} """ ray = type(self)( start = point.Point(xStart, yCoord), control0 = None, control1 = None, end = point.Point(xStop, yCoord)) sects = self.intersection(ray) r = {False: 0, True: 0} CE = mathutilities.closeEnough for p in sorted(sects, reverse=(not leftToRight)): if not isinstance(p, point.Point): continue t = self.parametricValueFromPoint(p) p2 = self.pointFromParametricValue(t - 0.001) p3 = self.pointFromParametricValue(t + 0.001) if CE(p2.y, p3.y): continue if p2.y > p3.y: r[leftToRight] += 1 else: r[not leftToRight] += 1 return r
class Line(object, metaclass=simplemeta.FontDataMetaclass): """ Objects representing finite line segments. These are simple objects with two attributes: p1 and p2, both of which are Point objects. """ # # Class definition variables # attrSpec = dict(p1=dict(attr_followsprotocol=True, attr_initfunc=(lambda: point.Point(0, 0))), p2=dict(attr_followsprotocol=True, attr_initfunc=(lambda: point.Point(0, 0)))) # # Methods # def __str__(self): """ Returns a nicely formatted string representing the Line. >>> P = point.Point >>> print(Line(P(3, 8), P(-1, 15.5))) Line from (3, 8) to (-1, 15.5) >>> print(Line(P(3, 8), P(3, 8))) Degenerate line at (3, 8) """ if self.isDegenerate(): return "Degenerate line at %s" % (self.p1, ) return "Line from %s to %s" % (self.p1, self.p2) def bounds(self): """ >>> P = point.Point >>> L1 = Line(P(1, 1), P(1, 9)) >>> print(L1.bounds()) Minimum X = 1, Minimum Y = 1, Maximum X = 1, Maximum Y = 9 """ xMin = min(self.p1.x, self.p2.x) yMin = min(self.p1.y, self.p2.y) xMax = max(self.p1.x, self.p2.x) yMax = max(self.p1.y, self.p2.y) return rectangle.Rectangle(xMin, yMin, xMax, yMax) def distanceToPoint(self, p): """ Returns an unsigned distance from the line to the point. This will be the smallest possible distance (i.e. the distance along the normal to the line's slope). >>> P = point.Point >>> L1 = Line(P(1, 1), P(1, 9)) >>> print(L1.distanceToPoint(P(1, 5))) 0.0 >>> print(L1.distanceToPoint(P(5, 5))) 4.0 >>> print(L1.distanceToPoint(P(-5, 15))) 6.0 >>> L3 = Line(P(0,0),P(0,0)) >>> print(L3.distanceToPoint(P(0, 0))) 0.0 >>> L2 = Line(P(3, 1), P(1, -6)) >>> print(L2.distanceToPoint(P(0, 0))) 2.609850715025092 """ if self.isDegenerate(): return p.distanceFrom(self.p1) t = self.parametricValueFromProjectedPoint(p) pOnLine = self.pointFromParametricValue(t) return p.distanceFrom(pOnLine) def equalEnough(self, other, delta=1.0e-5): """ Returns True if self's two points are within delta of other's two points. >>> P = point.Point >>> L1 = Line(P(3, -2), P(4, 4)) >>> L2 = Line(P(3.001, -1.999), P(4, 4.001)) >>> L1.equalEnough(L2) False >>> L1.equalEnough(L2, delta=0.01) True """ way1 = (self.p1.equalEnough(other.p1, delta) and self.p2.equalEnough(other.p2, delta)) way2 = (self.p1.equalEnough(other.p2, delta) and self.p2.equalEnough(other.p1, delta)) return way1 or way2 extrema = bounds # no difference for lines def intersection(self, other, delta=1.0e-5): """ Determines if self and other intersect, and returns an object representing that intersection. This could be None if they do not intersect, a Point object if they intersect in a single point, or a Line object if they are fully or partly coincident. >>> P = point.Point Simple point intersection cases: >>> print(Line(P(0, 0), P(0, 8)).intersection(Line(P(0, 4), P(8, 4)))) (0.0, 4.0) >>> print(Line(P(2, 1), P(5, 4)).intersection(Line(P(3, 3), P(11, 3)))) (4.0, 3.0) Non-parallel, out-of-bounds intersection case: >>> L1 = Line(P(2, 2), P(5, 5)) >>> L2 = Line(P(20000, 2), P(20004, 5)) >>> print(L1.intersection(L2)) None Parallel line cases, disjoint and otherwise: >>> print(Line(P(1, 1), P(5, 5)).intersection(Line(P(1, 2), P(5, 6)))) None >>> print(Line(P(1, 1), P(5, 5)).intersection(Line(P(2, 2), P(6, 6)))) Line from (2.0, 2.0) to (5, 5) >>> print(Line(P(0, 0), P(1000000, 1000000)).intersection(Line(P(999999, 999999), P(2000000, 2000000)))) (999999, 999999) >>> print(Line(P(1, 1), P(5, 5)).intersection(Line(P(6, 6), P(8, 8)))) None Degenerate line cases: >>> print(Line(P(4, 4), P(4, 4)).intersection(Line(P(0, 0), P(5, 5)))) (4, 4) >>> print(Line(P(4, 4), P(4, 4)).intersection(Line(P(1, 0), P(5, 5)))) None >>> print(Line(P(4, 4), P(4, 4)).intersection(Line(P(4, 4), P(4, 4.0000001)))) (4, 4) >>> print(Line(P(4, 4), P(4, 4)).intersection(Line(P(70000, 70000), P(70000, 70000)))) None >>> print(Line(P(1, 1), P(5, 5)).intersection(Line(P(3, 3), P(3, 3)))) (3, 3) >>> print(Line(P(1, 1), P(5, 5)).intersection(Line(P(8, 8), P(8, 8)))) None >>> print(Line(P(1, 1), P(5, 5)).intersection(Line(P(2, 3), P(2, 3)))) None """ CE = mathutilities.closeEnough CES = mathutilities.closeEnoughSpan IIP = mathutilities.intIfPossible if self.isDegenerate(delta): if other.isDegenerate(delta): if mathutilities.closeEnoughXY(self.p1, other.p1, delta): return self.p1.__copy__() return None t = other.parametricValueFromPoint(self.p1) if t is None: return t return self.p1.__copy__() elif other.isDegenerate(delta): t = self.parametricValueFromPoint(other.p1) if (t is None) or (not CES(t)): return None return other.p1.__copy__() if CE(self.slope(), other.slope()): # lines are either parallel or coincident t1 = self.parametricValueFromPoint(other.p1) if t1 is None: return None # lines are coincident, although the segments may not overlap t2 = self.parametricValueFromPoint(other.p2) if max(t1, t2) < 0 or min(t1, t2) > 1: return None t1 = IIP(max(0, t1)) t2 = IIP(min(1, t2)) if CE(t1, t2): return other.p1 return type(self)(self.pointFromParametricValue(t1), self.pointFromParametricValue(t2)) # the slopes differ, so solve the equations factorMatrix = matrix.Matrix( ((self.p2.x - self.p1.x, other.p1.x - other.p2.x, 0), (self.p2.y - self.p1.y, other.p1.y - other.p2.y, 0), (0, 0, 1))) fmInverse = factorMatrix.inverse() assert fmInverse is not None constMatrix = matrix.Matrix( ((other.p1.x - self.p1.x, 0, 0), (other.p1.y - self.p1.y, 0, 0), (0, 0, 1))) prod = fmInverse.multiply(constMatrix) t = IIP(prod[0][0]) u = IIP(prod[1][0]) if CES(t) and CES(u): return self.pointFromParametricValue(t) return None # no intersection within the actual length def isDegenerate(self, delta=1.0e-5): """ Returns True if the two points making up the Line are equal enough, using the specified delta. >>> P = point.Point >>> Line(P(5, 2), P(5.001, 2)).isDegenerate() False >>> Line(P(5, 2), P(5.001, 2)).isDegenerate(delta=0.01) True """ return self.p1.equalEnough(self.p2, delta=delta) def length(self): """ Returns the length of the Line. >>> P = point.Point >>> Line(P(0, 0), P(3, 4)).length() 5.0 Degenerate lines have zero length: >>> Line(P(2, -3), P(2, -3)).length() 0 """ if self.isDegenerate(): return 0 return math.sqrt(((self.p2.x - self.p1.x)**2) + ((self.p2.y - self.p1.y)**2)) def magnified(self, xScale=1, yScale=1): """ Returns a new Line scaled as specified about the origin. >>> P = point.Point >>> L = Line(P(3, -2), P(4, 4)) >>> print(L.magnified(1.75, -0.5)) Line from (5.25, 1.0) to (7.0, -2.0) """ return type(self)(self.p1.magnified(xScale, yScale), self.p2.magnified(xScale, yScale)) def magnifiedAbout(self, about, xScale=1, yScale=1): """ Returns a new Line scaled as specified about a specified point. >>> P = point.Point >>> L = Line(P(3, -2), P(4, 4)) >>> print(L.magnifiedAbout(P(1, -1), xScale=1.75, yScale=-0.5)) Line from (4.5, -0.5) to (6.25, -3.5) """ return type(self)(self.p1.magnifiedAbout(about, xScale, yScale), self.p2.magnifiedAbout(about, xScale, yScale)) def midpoint(self): """ Returns a Point representing the midpoint of the Line. >>> P = point.Point >>> print(Line(P(3, -2), P(4, 4)).midpoint()) (3.5, 1.0) """ return (self.p1 + self.p2) / 2 def moved(self, deltaX=0, deltaY=0): """ Returns a new Line moved by the specified amount. >>> P = point.Point >>> L = Line(P(3, -2), P(4, 4)) >>> print(L) Line from (3, -2) to (4, 4) >>> print(L.moved(-5, 10)) Line from (-2, 8) to (-1, 14) """ return type(self)(self.p1.moved(deltaX, deltaY), self.p2.moved(deltaX, deltaY)) def normalized(self, length=1): """ Returns a new Line whose first point is the same as self's, but whose second point is moved such that the length of the new Line is as specified. The new Line's second point will be in the same direction relative to the first point as the original Line's was. If self is degenerate it cannot be normalized to a non-zero length, so a copy of self will be returned if length is zero and None will be returned otherwise. Specifying a length of zero will return a degenerate line both of whose points are the same as self.p1. >>> P = point.Point >>> print(Line(P(0, 0), P(10, 0)).normalized()) Line from (0, 0) to (1.0, 0.0) >>> print(Line(P(0, 0), P(0, 10)).normalized()) Line from (0, 0) to (0, 1) >>> print(Line(P(0, 0), P(0, -10)).normalized()) Line from (0, 0) to (0, -1) >>> print(Line(P(1, -1), P(-2, -6)).normalized()) Line from (1, -1) to (0.48550424457247343, -1.8574929257125443) >>> print(Line(P(0, 0), P(10, 0)).normalized(length=5)) Line from (0, 0) to (5.0, 0.0) Degenerate cases: >>> LDeg = Line(P(2, -3), P(2, -3)) >>> LDeg.normalized() is None True >>> LDeg.normalized(0) == LDeg True >>> LDeg.normalized(0) is LDeg False """ p = self.p1 if not length: return type(self)(p, p) slope = self.slope() if slope is None: return (None if length else self.__copy__()) if abs(slope) == float("+inf"): # line is vertical sign = (-1 if self.p2.y < p.y else 1) return type(self)(p, point.Point(p.x, p.y + sign * length)) sign = (-1 if self.p2.x < p.x else 1) x = p.x + sign * math.sqrt(length**2 / (1 + slope**2)) y = p.y + slope * (x - p.x) return type(self)(p, point.Point(x, y)) def parametricValueFromProjectedPoint(self, p): """ Given a Point, not necessarily on the line, find the t-value representing the projection of that point onto the line. The returned t-value is not necessarily in the [0, 1] range. If self is degenerate, zero is returned. >>> P = point.Point >>> Line(P(0, 0), P(10, 0)).parametricValueFromProjectedPoint(P(2, 6)) 0.2 >>> Line(P(0, 0), P(0, 10)).parametricValueFromProjectedPoint(P(2, 6)) 0.6 >>> Line(P(1, 1), P(5, 4)).parametricValueFromProjectedPoint(P(2, 4)) 0.52 >>> Line(P(1, 1), P(5, 4)).parametricValueFromProjectedPoint(P(25, 4)) 4.2 Degenerate lines always return 0, regardless of the point: >>> Line(P(2, -3), P(2, -3)).parametricValueFromProjectedPoint(P(0, 0)) 0.0 """ m = self.slope() if m is None: return 0.0 if abs(m) == float("+inf"): # line is vertical return self.parametricValueFromPoint(point.Point(self.p1.x, p.y)) if mathutilities.zeroEnough(m): # line is horizontal return self.parametricValueFromPoint(point.Point(p.x, self.p1.y)) # line is neither vertical nor horizontal numer = self.p1.y - p.y - (m * self.p1.x) - ((1 / m) * p.x) denom = -m - (1 / m) x = numer / denom return (x - self.p1.x) / (self.p2.x - self.p1.x) def parametricValueFromPoint(self, p): """ Given a Point, returns the t value that satisfies the parametric equation: (1-t)p1 + (t)p2 == p. Returns None if p does not intersect self. >>> P = point.Point >>> L = Line(P(1, 1), P(3, 3)) >>> print(L.parametricValueFromPoint(P(1, 1))) 0.0 >>> print(L.parametricValueFromPoint(P(2, 2))) 0.5 >>> print(L.parametricValueFromPoint(P(2, 3))) None >>> L = Line(P(1, 1), P(3, 1)) # horizontal >>> print(L.parametricValueFromPoint(P(2, 1))) 0.5 >>> print(L.parametricValueFromPoint(P(2, 2))) None >>> L = Line(P(1, 1), P(1, 3)) # vertical >>> print(L.parametricValueFromPoint(P(1, 2))) 0.5 >>> print(L.parametricValueFromPoint(P(2, 2))) None >>> L = Line(P(1, 1), P(1, 1)) # degenerate (point) >>> print(L.parametricValueFromPoint(P(1, 1))) 0.0 >>> print(L.parametricValueFromPoint(P(2, 2))) None """ ce = mathutilities.closeEnough p1 = self.p1 p2 = self.p2 if ce(p1.x, p2.x): tx = None else: tx = (p.x - p1.x) / (p2.x - p1.x) if ce(p1.y, p2.y): ty = None else: ty = (p.y - p1.y) / (p2.y - p1.y) if tx is None and ty is None: # degenerate case: self is a Point return (0.0 if p1.equalEnough(p) else None) elif tx is None: return (ty if ce(p.x, p1.x) else None) elif ty is None: return (tx if ce(p.y, p1.y) else None) return (tx if ce(tx, ty) else None) def perpendicularTo(self, other): """ Returns True if the two Lines are orthogonal, False otherwise. >>> P = point.Point >>> Line(P(0, 0), P(1, 0)).perpendicularTo(Line(P(0, 0), P(0, 1))) True >>> Line(P(1, 1), P(2, 2)).perpendicularTo(Line(P(-6, 5), P(-7, 6))) True >>> Line(P(1, 1), P(2, 3)).perpendicularTo(Line(P(-6, 5), P(-7, 6))) False >>> Line(P(0, 0), P(0, 5)).perpendicularTo(Line(P(-5, 0), P(5, 0))) True >>> Line(P(0, 5), P(0, 0)).perpendicularTo(Line(P(-5, 0), P(5, 0))) True >>> Line(P(4, 4), P(4, 4)).perpendicularTo(Line(P(-5, 0), P(5, 1))) Traceback (most recent call last): ... ValueError: Cannot determine perpendicularity for degenerate lines! """ selfSlope = self.slope() otherSlope = other.slope() if selfSlope is None or otherSlope is None: raise ValueError( "Cannot determine perpendicularity for degenerate lines!") try: r = mathutilities.closeEnough(selfSlope, -1 / otherSlope) except ZeroDivisionError: r = abs(selfSlope) == float("+inf") return r def piece(self, t1, t2): """ Creates a new Line object which maps t1 to t2 in the original to 0 to 1 in the new. Returns two things: the new Line, and an anonymous function which maps an old t-value to a new u-value. >>> P = point.Point >>> L = Line(P(1, 1), P(5, 9)) >>> print(L) Line from (1, 1) to (5, 9) >>> L2, func = L.piece(0.25, 0.75) >>> print(L2) Line from (2.0, 3.0) to (4.0, 7.0) >>> func(0.25), func(0.5), func(0.75) (0.0, 0.5, 1.0) """ L = type(self)(self.pointFromParametricValue(t1), self.pointFromParametricValue(t2)) return L, lambda x: ((x - t1) / (t2 - t1)) def pointFromParametricValue(self, t): """ Given a parametric value, returns a Point on self at that value. >>> P = point.Point >>> L = Line(P(1, 1), P(5, 9)) >>> print(L.pointFromParametricValue(0.0)) (1.0, 1.0) >>> print(L.pointFromParametricValue(1.0)) (5.0, 9.0) >>> print(L.pointFromParametricValue(-2.0)) (-7.0, -15.0) >>> print(L.pointFromParametricValue(0.5)) (3.0, 5.0) """ return (self.p1 * (1 - t)) + (self.p2 * t) def rotated(self, angleInDegrees=0): """ Returns a new Line rotated by the specified angle (measured counter- clockwise) about the origin. >>> P = point.Point >>> L = Line(P(3, -2), P(4, 4)) >>> print(L.rotated(90)) Line from (2.0, 3.0) to (-4.0, 4.0) """ return type(self)(self.p1.rotated(angleInDegrees), self.p2.rotated(angleInDegrees)) def rotatedAbout(self, about, angleInDegrees=0): """ Returns a new Line rotated by the specified angle (measured counter- clockwise) about the specified point. >>> P = point.Point >>> L = Line(P(3, -2), P(4, 4)) >>> print(L.rotatedAbout(P(-3, 3), 90)) Line from (2.0, 9.0) to (-4.0, 10.0) """ return type(self)(self.p1.rotatedAbout(about, angleInDegrees), self.p2.rotatedAbout(about, angleInDegrees)) def slope(self): """ Returns the slope of the Line (potentially a signed infinity, where the sign reflects a movement from self.p1 to self.p2). If the Line is degenerate, the slope will be returned as None, but no error will be raised. >>> P = point.Point >>> print(Line(P(2, 4), P(1, 2)).slope()) 2.0 >>> print(Line(P(1, 2), P(2, 4)).slope()) 2.0 >>> print(Line(P(2, 4), P(2, 2)).slope()) -inf >>> print(Line(P(2, -4), P(2, 2)).slope()) inf >>> print(Line(P(2, -4), P(2, -4)).slope()) None """ if self.isDegenerate(): return None if mathutilities.closeEnough(self.p1.x, self.p2.x): if self.p2.y >= self.p1.y: return float("+inf") return float("-inf") return (self.p2.y - self.p1.y) / (self.p2.x - self.p1.x)
def _makePt(x, y): from fontio3.fontmath import point return point.Point(x, y)
class Triangle(object, metaclass=simplemeta.FontDataMetaclass): """ Objects representing triangles. These are simple objects with three attributes, p1, p2, and p3, representing the vertices. """ # # Class definition variables # attrSpec = dict(p1=dict(attr_followsprotocol=True, attr_initfunc=(lambda: point.Point(0, 0))), p2=dict(attr_followsprotocol=True, attr_initfunc=(lambda: point.Point(0, 0))), p3=dict(attr_followsprotocol=True, attr_initfunc=(lambda: point.Point(0, 0)))) # # Special methods # def __str__(self): """ Returns a string representation of the Triangle. >>> P = point.Point >>> print(Triangle(P(3, 5), P(0, 1), P(-1, 3))) Triangle with vertices (3, 5), (0, 1), and (-1, 3) """ return ("Triangle with vertices %s, %s, and %s" % (self.p1, self.p2, self.p3)) # # Public methods # def area(self): """ Returns the area of the Triangle. Since we know the three sides we use Heron's formula. >>> P = point.Point >>> Triangle(P(3, 5), P(0, 1), P(-1, 3)).area() 5.0 >>> print(Triangle(P(-10, 0), P(0, 0.000001), P(10, 0)).area()) 9.973764735866506e-06 """ Line = line.Line line12 = Line(self.p1, self.p2) line23 = Line(self.p2, self.p3) line31 = Line(self.p3, self.p1) a = line12.length() b = line23.length() c = line31.length() s = (a + b + c) / 2 radicand = s * (s - a) * (s - b) * (s - c) if radicand >= 0: return math.sqrt(radicand) return 0 def center(self): """ Returns a Point representing the center of the Triangle. >>> P = point.Point >>> print(Triangle(P(3, 5), P(0, 1), P(-1, 3)).center()) (0.6666666666666667, 3.0) >>> print(Triangle(P(-10, 0), P(0, 0.000001), P(10, 0)).center()) (0.0, 5e-07) """ if self.isDegenerate(): minX = min(self.p1.x, self.p2.x, self.p3.x) minY = min(self.p1.y, self.p2.y, self.p3.y) maxX = max(self.p1.x, self.p2.x, self.p3.x) maxY = max(self.p1.y, self.p2.y, self.p3.y) L = line.Line(point.Point(minX, minY), point.Point(maxX, maxY)) return L.midpoint() Line = line.Line line12 = Line(self.p1, self.p2) line23 = Line(self.p2, self.p3) testLine1 = Line(self.p3, line12.midpoint()) testLine2 = Line(self.p1, line23.midpoint()) return testLine1.intersection(testLine2) def isDegenerate(self, delta=1.0e-5): """ Returns True if the Triangle is degenerate (that is, if its area is essentially zero). >>> P = point.Point >>> Triangle(P(3, 5), P(0, 1), P(-1, 3)).isDegenerate() False >>> Triangle(P(3, 5), P(3.00000001, 5), P(-1, 3)).isDegenerate() True """ return mathutilities.zeroEnough(self.area(), delta=delta) def isRightTriangle(self): """ Returns True if the Triangle is a right triangle. Will always return False for a degenerate Triangle. >>> P = point.Point >>> Triangle(P(3, 5), P(0, 1), P(-1, 3)).isRightTriangle() True >>> Triangle(P(0, 0), P(3, 5), P(-3, 5)).isRightTriangle() False >>> Triangle(P(-10, 0), P(0, 0.0000001), P(10, 0)).isRightTriangle() False """ if self.isDegenerate(): return False Line = line.Line line12 = Line(self.p1, self.p2) line23 = Line(self.p2, self.p3) line31 = Line(self.p3, self.p1) return any([ line12.perpendicularTo(line23), line23.perpendicularTo(line31), line31.perpendicularTo(line12) ]) def magnified(self, xScale=1, yScale=1): """ Returns a new Triangle scaled as specified about the origin. >>> P = point.Point >>> T = Triangle(P(3, 5), P(0, 1), P(-1, 3)) >>> print(T) Triangle with vertices (3, 5), (0, 1), and (-1, 3) >>> print(T.magnified(2.5, -0.5)) Triangle with vertices (7.5, -2.5), (0.0, -0.5), and (-2.5, -1.5) """ return type(self)(self.p1.magnified(xScale, yScale), self.p2.magnified(xScale, yScale), self.p3.magnified(xScale, yScale)) def magnifiedAbout(self, about, xScale=1, yScale=1): """ Returns a new Triangle scaled as specified about a specified point. >>> P = point.Point >>> T = Triangle(P(3, 5), P(0, 1), P(-1, 3)) >>> print(T) Triangle with vertices (3, 5), (0, 1), and (-1, 3) >>> print(T.magnifiedAbout(P(1, -1), xScale=1.75, yScale=-0.5)) Triangle with vertices (4.5, -4.0), (-0.75, -2.0), and (-2.5, -3.0) """ return type(self)(self.p1.magnifiedAbout(about, xScale, yScale), self.p2.magnifiedAbout(about, xScale, yScale), self.p3.magnifiedAbout(about, xScale, yScale)) def moved(self, deltaX=0, deltaY=0): """ Returns a new Triangle moved by the specified amount. >>> P = point.Point >>> T = Triangle(P(3, 5), P(0, 1), P(-1, 3)) >>> print(T) Triangle with vertices (3, 5), (0, 1), and (-1, 3) >>> print(T.moved(-4, 4)) Triangle with vertices (-1, 9), (-4, 5), and (-5, 7) """ return type(self)(self.p1.moved(deltaX, deltaY), self.p2.moved(deltaX, deltaY), self.p3.moved(deltaX, deltaY)) def perimeter(self): """ Returns the perimeter of the Triangle. >>> P = point.Point >>> Triangle(P(3, 5), P(0, 1), P(-1, 3)).perimeter() 11.70820393249937 """ Line = line.Line return (Line(self.p1, self.p2).length() + Line(self.p2, self.p3).length() + Line(self.p3, self.p1).length()) def rotated(self, angleInDegrees=0): """ Returns a new Triangle rotated by the specified angle (measured counter-clockwise) about the origin. >>> P = point.Point >>> T = Triangle(P(3, 5), P(0, 1), P(-1, 3)) >>> print(T) Triangle with vertices (3, 5), (0, 1), and (-1, 3) >>> print(T.rotated(90)) Triangle with vertices (-5.0, 3.0), (-1.0, 0.0), and (-3.0, -1.0) """ return type(self)(self.p1.rotated(angleInDegrees), self.p2.rotated(angleInDegrees), self.p3.rotated(angleInDegrees)) def rotatedAbout(self, about, angleInDegrees=0): """ Returns a new Triangle rotated by the specified angle (measured counter-clockwise) about the specified point. >>> P = point.Point >>> T = Triangle(P(3, 5), P(0, 1), P(-1, 3)) >>> print(T) Triangle with vertices (3, 5), (0, 1), and (-1, 3) >>> print(T.rotatedAbout(P(-3, 3), 90)) Triangle with vertices (-5.0, 9.0), (-1.0, 6.0), and (-3.0, 5.0) """ return type(self)(self.p1.rotatedAbout(about, angleInDegrees), self.p2.rotatedAbout(about, angleInDegrees), self.p3.rotatedAbout(about, angleInDegrees))
class BSpline(object, metaclass=simplemeta.FontDataMetaclass): """ """ # # Class definition variables # attrSpec = dict(onCurve1=dict(attr_followsprotocol=True, attr_initfunc=(lambda: point.Point(0, 0))), offCurve=dict(attr_followsprotocol=True), onCurve2=dict(attr_followsprotocol=True, attr_initfunc=(lambda: point.Point(0, 0)))) attrSorted = ('onCurve1', 'onCurve2', 'offCurve') # # Methods # def __str__(self): """ Returns a nice string representation of the spline. This also works for degenerate splines. >>> P = point.Point >>> print(BSpline(P(4, -3), P(4, -3), None)) Point at (4, -3) >>> print(BSpline(P(1, 3), P(5, 0), None)) Line from (1, 3) to (5, 0) >>> print(BSpline(P(2, -3), P(4, 4), P(0, 1))) Curve from (2, -3) to (4, 4) with off-curve point (0, 1) """ on1 = self.onCurve1 on2 = self.onCurve2 if self.offCurve is None: if on1.equalEnough(on2): return "Point at %s" % (on1, ) return "Line from %s to %s" % (on1, on2) return ("Curve from %s to %s with off-curve point %s" % (on1, on2, self.offCurve)) def _intersection_curve(self, other): """ >>> P = point.Point >>> b = BSpline(P(2, -3), P(4, 4), P(0, 1)) >>> c = BSpline(P(2, 3), P(4, 4), P(0, 1)) >>> b._intersection_curve(c) (Point(x=4, y=4), Point(x=4, y=4)) """ ZE = mathutilities.zeroEnough CES = mathutilities.closeEnoughSpan A = self.onCurve1.x + self.onCurve2.x - (2 * self.offCurve.x) assert not ZE(A) B = 2 * (self.offCurve.x - self.onCurve1.x) C = self.onCurve1.x D = other.onCurve1.x + other.onCurve2.x - (2 * other.offCurve.x) E = 2 * (other.offCurve.x - other.onCurve1.x) F = other.onCurve1.x G = self.onCurve1.y + self.onCurve2.y - (2 * self.offCurve.y) assert not ZE(G) H = 2 * (self.offCurve.y - self.onCurve1.y) denom = B * G - A * H assert not ZE(denom) I = self.onCurve1.y J = other.onCurve1.y + other.onCurve2.y - (2 * other.offCurve.y) K = 2 * (other.offCurve.y - other.onCurve1.y) L = other.onCurve1.y # This is the full case, where we have to solve the following pair of # equations: # # At^2 + Bt + C = Du^2 + Eu + F # Gt^2 + Ht + I = Ju^2 + Ku + L # # Since we know both A and G are nonzero, we can multiply the first # equation by G and the second one by A, and then subtract the two # equations. Simplifying, we get this equation representing t in terms # of u: # # t = (DG-AJ)/(BG-AH) u^2 + # (EG-AK)/(BG-AH) u + # ((FG-AL)+(AI-CG))/(BG-AH) # # To simplify, we define the following three values: # # M = (DG-AJ)/(BG-AH) # N = (EG-AK)/(BG-AH) # P = ((FG-AL)+(AI-CG))/(BG-AH) # # This results in the simpler equation: # # t = Mu^2 + Nu + P # # This is then substituted back into the y-equation: # # Gt^2 + Ht + I = Ju^2 + Ku + L # # Resulting in the final quartic: # # (GM^2)u^4 + # (2GMN)u^3 + # (GN^2 + 2GMP + HM - J)u^2+ # (2GNP + HN - K)u + # (GP^2 + HP + I - L) = 0 M = (D * G - A * J) / denom N = (E * G - A * K) / denom P = ((F * G - A * L) + (A * I - C * G)) / denom roots = mathutilities.quartic(G * M * M, 2 * G * M * N, G * N * N + 2 * G * M * P + H * M - J, 2 * G * N * P + H * N - K, G * P * P + H * P + I - L) v = [] for u in roots: if not ZE(u.imag): continue u = u.real if CES(u): # a possible solution t = M * u * u + N * u + P if CES(t): # an actual solution v.append(other.pointFromParametricValue(u)) return tuple(v) def _intersection_curve_xspecial(self, other): ZE = mathutilities.zeroEnough CES = mathutilities.closeEnoughSpan A = self.onCurve1.x + self.onCurve2.x - (2 * self.offCurve.x) assert ZE(A) B = 2 * (self.offCurve.x - self.onCurve1.x) C = self.onCurve1.x D = other.onCurve1.x + other.onCurve2.x - (2 * other.offCurve.x) E = 2 * (other.offCurve.x - other.onCurve1.x) F = other.onCurve1.x G = self.onCurve1.y + self.onCurve2.y - (2 * self.offCurve.y) assert not ZE(G) H = 2 * (self.offCurve.y - self.onCurve1.y) I = self.onCurve1.y J = other.onCurve1.y + other.onCurve2.y - (2 * other.offCurve.y) K = 2 * (other.offCurve.y - other.onCurve1.y) L = other.onCurve1.y # Since A is zero, we have the following simplified equation: # # Bt + C = Du^2 + Eu + F # # This gives us a solution for t in terms of u: # # t = (Du^2 + Eu + (F - C)) / B # # To simplify, we define the following three values: # # M = D / B # N = E / B # P = (F - C) / B # # This results in the simpler equation: # # t = Mu^2 + Nu + P # # This is then substituted back into the y-equation: # # Gt^2 + Ht + I = Ju^2 + Ku + L # # Resulting in the final quartic: # # (GM^2)u^4 + # (2GMN)u^3 + # (GN^2 + 2GMP + HM - J)u^2+ # (2GNP + HN - K)u + # (GP^2 + HP + I - L) = 0 M = D / B N = E / B P = (F - C) / B roots = mathutilities.quartic(G * M * M, 2 * G * M * N, G * N * N + 2 * G * M * P + H * M - J, 2 * G * N * P + H * N - K, G * P * P + H * P + I - L) v = [] for u in roots: if not ZE(u.imag): continue u = u.real if CES(u): # a possible solution t = M * u * u + N * u + P if CES(t): # an actual solution v.append(other.pointFromParametricValue(u)) return tuple(v) def _intersection_curve_yspecial(self, other): ZE = mathutilities.zeroEnough CES = mathutilities.closeEnoughSpan A = self.onCurve1.x + self.onCurve2.x - (2 * self.offCurve.x) assert not ZE(A) B = 2 * (self.offCurve.x - self.onCurve1.x) C = self.onCurve1.x D = other.onCurve1.x + other.onCurve2.x - (2 * other.offCurve.x) E = 2 * (other.offCurve.x - other.onCurve1.x) F = other.onCurve1.x G = self.onCurve1.y + self.onCurve2.y - (2 * self.offCurve.y) assert ZE(G) H = 2 * (self.offCurve.y - self.onCurve1.y) I = self.onCurve1.y J = other.onCurve1.y + other.onCurve2.y - (2 * other.offCurve.y) K = 2 * (other.offCurve.y - other.onCurve1.y) L = other.onCurve1.y # Since G is zero, we have the following simplified equation: # # Ht + I = Ju^2 + Ku + L # # This gives us a solution for t in terms of u: # # t = (Ju^2 + Ku + (L - I)) / H # # To simplify, we define the following three values: # # M = J / H # N = K / H # P = (L - I) / H # # This results in the simpler equation: # # t = Mu^2 + Nu + P # # This is then substituted back into the x-equation: # # At^2 + Bt + C = Du^2 + Eu + F # # Resulting in the final quartic: # # (AM^2)u^4 + # (2AMN)u^3 + # (AN^2 + 2AMP + BM - D)u^2+ # (2ANP + BN - E)u + # (AP^2 + BP + C - F) = 0 M = J / H N = K / H P = (L - I) / H roots = mathutilities.quartic(A * M * M, 2 * A * M * N, A * N * N + 2 * A * M * P + B * M - D, 2 * A * N * P + B * N - E, A * P * P + B * P + C - F) v = [] for u in roots: if not ZE(u.imag): continue u = u.real if CES(u): # a possible solution t = M * u * u + N * u + P if CES(t): # an actual solution v.append(other.pointFromParametricValue(u)) return tuple(v) def _intersection_line(self, theLine): """ Utility function handling the line-curve intersection cases. Note that self.offCurve must not be None (that will have been filtered out in the higher-level code). >>> P = point.Point >>> a = BSpline(P(2, -3), P(4, 4), P(0, 1)) >>> b = line.Line(P(2, -3), P(4, -3)) >>> c = line.Line(P(4, -3), P(4, -3)) >>> d = line.Line(P(4, -3), P(6, 4)) >>> e = line.Line(P(2, 0), P(4, 0)) >>> a._intersection_line(c) () >>> a._intersection_line(b) (Point(x=2.0, y=-3.0),) >>> a._intersection_line(d) () >>> a._intersection_line(e) () """ assert self.offCurve is not None ZE = mathutilities.zeroEnough CES = mathutilities.closeEnoughSpan # Set up the coefficients for the x-equation: # At + B = Cu^2 + Du + E # and the y-equation: # Ft + G = Hu^2 + Iu + J A = theLine.p2.x - theLine.p1.x B = theLine.p1.x C = self.onCurve1.x + self.onCurve2.x - 2 * self.offCurve.x D = 2 * (self.offCurve.x - self.onCurve1.x) E = self.onCurve1.x F = theLine.p2.y - theLine.p1.y G = theLine.p1.y H = self.onCurve1.y + self.onCurve2.y - 2 * self.offCurve.y I = 2 * (self.offCurve.y - self.onCurve1.y) J = self.onCurve1.y if ZE(A) and ZE(F): # If both A and F are zero, the curve is degenerate (the two on- # curve points are essentially coincident). In this case, there is # only a single point intersection possible if either of the on- # curve points lies on the line. t = theLine.parametricValueFromPoint(self.onCurve1) return (() if t is None else (self.onCurve1.__copy__(), )) if ZE(A): # If A is zero and F is not, we solve the simpler quadratic: # Cu^2 + Du + (E-B) = 0 # and then plug that u-value into the y-equation to get t. root1, root2 = mathutilities.quadratic(C, D, E - B) if not ZE(root1.imag): return () v = [] for u in (root1, root2): if CES(u): # a possible solution t = (H * u * u + I * u + J - G) / F if CES(t): # an actual solution v.append(theLine.pointFromParametricValue(t)) return tuple(v) if ZE(F): # If F is zero and A is not, we solve the simpler quadratic: # Hu^2 + Iu + (J-G) = 0 # and then plug that u-value into the y-equation to get t. root1, root2 = mathutilities.quadratic(H, I, J - G) if not ZE(root1.imag): return () v = [] for u in (root1, root2): if CES(u): # a possible solution t = (C * u * u + D * u + E - B) / A if CES(t): # an actual solution v.append(theLine.pointFromParametricValue(t)) return tuple(v) # If we get here, both A and F are nonzero, so we solve the full # quadratic: # # (CF-AH)u^2 + (DF-AI)u + (EF+AG-AJ-BF) = 0 # # and then plug that u-value into the y-equation to get t. root1, root2 = mathutilities.quadratic(C * F - A * H, D * F - A * I, E * F + A * G - A * J - B * F) if not ZE(root1.imag): return () v = [] for u in (root1, root2): if CES(u): # a possible solution t = (C * u * u + D * u + E - B) / A if CES(t): # an actual solution v.append(theLine.pointFromParametricValue(t)) return tuple(v) def area(self): """ Analytical solution for the area bounded by the parabolic section and the straight line. >>> P = point.Point >>> round(BSpline(P(2, -3), P(4, 4), P(0, 1)).area(), 10) 20.6666666667 >>> P = point.Point >>> print(BSpline(P(2, -3), P(4, 4), None).area()) 0 >>> print(BSpline(P(2, -3), P(4, 4), P(4, 4)).area()) Traceback (most recent call last): ... ZeroDivisionError: division by zero >>> print(BSpline(P(2, -3), P(2, -3), P(3, 4)).area()) 0 """ if self.offCurve is None: return 0 if self.onCurve1.equalEnough(self.onCurve2): return 0 normalized = self.moved(-self.onCurve1.x, -self.onCurve1.y) x1, y1 = normalized.offCurve.x, normalized.offCurve.y x2, y2 = normalized.onCurve2.x, normalized.onCurve2.y slopeDenom = x2 * (y2 - 2 * y1) - y2 * (x2 - 2 * x1) # assert slopeDenom, "Zero slope denominator in BSpline.area!" tangentT = (y2 * x1 - x2 * y1) / slopeDenom tangentPoint = normalized.pointFromParametricValue(tangentT) t = triangle.Triangle(self.onCurve1, tangentPoint, self.onCurve2) return 4 * t.area() / 3 def bounds(self): """ Returns a Rectangle representing the actual curve bounds. This might be different than the extrema. >>> P = point.Point >>> print(BSpline(P(2, -3), P(4, 4), P(0, 1)).extrema()) Minimum X = 0, Minimum Y = -3, Maximum X = 4, Maximum Y = 4 >>> print(BSpline(P(2, -3), P(4, 4), P(0, 1)).bounds()) Minimum X = 1.3333333333333335, Minimum Y = -3, Maximum X = 4, Maximum Y = 4 >>> print(BSpline(P(2, -3), P(4, 4), None).bounds()) Minimum X = 2, Minimum Y = -3, Maximum X = 4, Maximum Y = 4 >>> print(BSpline(P(4, 4), P(4, 4), P(2, 4)).bounds()) Minimum X = 4, Minimum Y = 4, Maximum X = 4, Maximum Y = 4 >>> print(BSpline(P(2, 3.1), P(2, 3.11), P(2,3.105)).bounds()) Minimum X = 2, Minimum Y = 3.1, Maximum X = 2, Maximum Y = 3.11 >>> print(BSpline(P(10, 5.1), P(12, 3.11), P(-2,1.105)).bounds()) Minimum X = 4.461538461538462, Minimum Y = 2.439995833333333, Maximum X = 12, Maximum Y = 5.1 """ # We determine the bounds in x by differentiating the curve and finding # out where the slope zeroes out. Let a = self.onCurve1.x, # b = self.onCurve2.x, and c = self.offCurve.x. The equation for the # curve in x is: # # x = a(1-t)^2 + 2ct(1-t) + bt^2 # # Refactoring this into terms involving t, we get: # # x = (a+b-2c)t^2 + (2c-2a)t + a # # Differentiating this with respect to t gets us: # # x' = 2(a+b-2c)t + (2c-2a) # # When x' is zero the curve is vertical. Solving, we get: # # t = (a-c)/(a+b-2c) # # If the denominator is zero then there are no vertical tangents. If t # is not in [0,1] then there are no vertical tangents. In both of these # cases the endpoints will provide the bounds. # # If, on the other hand, t exists and is in [0,1] then we need to plug # that value for t back into the original equation to get the # corresponding value of x. # # The same logic applies, mutatis mutandis, to y. extremaRect = self.extrema(True) if self.offCurve is None: return extremaRect if self.onCurve1.equalEnough(self.onCurve2): return extremaRect xMin, yMin, xMax, yMax = extremaRect.asList() a = self.onCurve1.x b = self.onCurve2.x c = self.offCurve.x denom = a + b - (2 * c) if denom: t = (a - c) / denom if mathutilities.closeEnoughSpan(t): p = self.pointFromParametricValue(t) xMin = min(xMin, p.x) xMax = max(xMax, p.x) a = self.onCurve1.y b = self.onCurve2.y c = self.offCurve.y denom = a + b - (2 * c) if denom: t = (a - c) / denom if mathutilities.closeEnoughSpan(t): p = self.pointFromParametricValue(t) yMin = min(yMin, p.y) yMax = max(yMax, p.y) return rectangle.Rectangle(xMin, yMin, xMax, yMax) def distanceToPoint(self, p): """ Returns an unsigned distance from the point to the nearest position on the curve. >>> P = point.Point >>> obj = BSpline(P(2, -3), P(4, 4), P(0, 1)) >>> print(obj.distanceToPoint(P(3, 3))) 0.10068277482538779 >>> print(BSpline(P(2, -3), P(4, 4), None).distanceToPoint(P(2,-3))) 0.0 >>> print(BSpline(P(4, 4), P(4, 4), P(2, 4)).distanceToPoint(P(2,-3))) 7.280109889280518 """ if self.onCurve1.equalEnough(self.onCurve2): return p.distanceFrom(self.onCurve1) if self.offCurve is None: return line.Line(self.onCurve1, self.onCurve2).distanceToPoint(p) a = self.onCurve1.x b = self.onCurve2.x c = self.offCurve.x d = self.onCurve1.y e = self.onCurve2.y f = self.offCurve.y A = a + b - 2 * c B = c - a C = d + e - 2 * f D = f - d E = a - p.x F = d - p.y roots = mathutilities.cubic( 4 * (A * A + C * C), 12 * (A * B + C * D), 4 * (2 * B * B + 2 * D * D + A * E + C * F), 4 * (B * E + D * F)) ZE = mathutilities.zeroEnough v = [ p.distanceFrom(self.pointFromParametricValue(root)) for root in roots if ZE(root.imag) ] return utilities.safeMin(v) def extrema(self, excludeOffCurve=False): """ Returns a Rectangle representing the extrema (based on the actual coordinates, and not the curve itself). Normally the off-curve point is included in the calculations; to exclude it, set the excludeOffCurve flag to True. Note that you can check whether one or more extreme points are off- curve by comparing the results of this method with True and False as the flags -- if the resulting Rectangles differ, then at least one of the extreme points is off-curve. >>> P = point.Point >>> print(BSpline(P(2, -3), P(4, 4), P(0, 1)).extrema()) Minimum X = 0, Minimum Y = -3, Maximum X = 4, Maximum Y = 4 >>> print(BSpline(P(2, -3), P(4, 4), P(0, 1)).extrema(True)) Minimum X = 2, Minimum Y = -3, Maximum X = 4, Maximum Y = 4 """ if self.offCurve is None or self.onCurve1.equalEnough(self.onCurve2): return rectangle.Rectangle.frompoints(self.onCurve1, self.onCurve2) if excludeOffCurve: return rectangle.Rectangle(min(self.onCurve1.x, self.onCurve2.x), min(self.onCurve1.y, self.onCurve2.y), max(self.onCurve1.x, self.onCurve2.x), max(self.onCurve1.y, self.onCurve2.y)) return rectangle.Rectangle( min(self.onCurve1.x, self.onCurve2.x, self.offCurve.x), min(self.onCurve1.y, self.onCurve2.y, self.offCurve.y), max(self.onCurve1.x, self.onCurve2.x, self.offCurve.x), max(self.onCurve1.y, self.onCurve2.y, self.offCurve.y)) def intersection(self, other): """ Returns a tuple of objects representing the intersection of the two BSplines. >>> P = point.Point >>> b1 = BSpline(P(2, -3), P(4, 4), None) >>> b2 = BSpline(P(1, 0), P(4, -1), None) >>> for obj in b1.intersection(b2): print(obj) (2.6956521739130435, -0.5652173913043479) >>> for obj in b1.intersection(b2.moved(10)): print(obj) >>> b3 = BSpline(P(2, -3), P(4, 4), P(0, 1)) >>> for obj in b3.intersection(b2): print(obj) (1.345578690319357, -0.11519289677311899) >>> for obj in b2.intersection(b3): print(obj) (1.345578690319357, -0.11519289677311899) >>> for obj in b3.intersection(b2.moved(10)): print(obj) >>> for obj in b3.intersection(b1.moved(-1)): print(obj) (2.6484772346100725, 2.7696703211352536) (1.4424318562990182, -1.4514885029534366) >>> b4 = BSpline(P(-2, 0), P(2, 0), P(0, 2)) >>> for obj in b4.intersection(b3): print(obj) (1.4334792522721553, 0.4862843083263152) >>> b5 = BSpline(P(0, 3), P(0, -3), P(3, 0)) >>> for obj in b5.intersection(b3): print(obj) (1.448865608003318, 0.553901030852897) (1.3583316809062103, -0.92195982264009) >>> b6 = BSpline(P(2, -3), P(2, -3), None) >>> b7 = BSpline(P(1, 0), P(4, -1), None) >>> b8 = BSpline(P(0, 0), P(10, 10), P(5,5)) >>> b9 = BSpline(P(2.1, 1), P(20, 20), P(5,5)) >>> b6.intersection(b7) () >>> b7.intersection(b6) () >>> b8.intersection(b9) () """ if self.onCurve1.equalEnough(self.onCurve2): t = other.parametricValueFromPoint(self.onCurve1) return (() if t is None else (self.onCurve1, )) if other.onCurve1.equalEnough(other.onCurve2): t = self.parametricValueFromPoint(other.onCurve1) return (() if t is None else (other.onCurve1, )) self = self.normalized() other = other.normalized() CEXY = mathutilities.closeEnoughXY if self.offCurve is None and other.offCurve is None: # actually a line intersection case L1 = line.Line(self.onCurve1, self.onCurve2) L2 = line.Line(other.onCurve1, other.onCurve2) isect = L1.intersection(L2) return (() if isect is None else (isect, )) if self.offCurve is None or other.offCurve is None: # one is a line and one is a curve. check endpoints first. if (CEXY(self.onCurve1, other.onCurve1) or CEXY(self.onCurve1, other.onCurve2)): return (self.onCurve1.__copy__(), ) if (CEXY(self.onCurve2, other.onCurve1) or CEXY(self.onCurve2, other.onCurve2)): return (self.onCurve2.__copy__(), ) if self.offCurve is None: # self is line and other is curve; from 0-2 intersection points return other._intersection_line( line.Line(self.onCurve1, self.onCurve2)) # other is line and self is curve; from 0-2 intersection points return self._intersection_line( line.Line(other.onCurve1, other.onCurve2)) if CEXY(self.offCurve, other.offCurve): # If the two off-curve points coincide, then we check to see if # other's start and end points lie on self. If they do, the curves # overlap. tStart = self.parametricValueFromPoint(other.onCurve1) if tStart is not None: tEnd = self.parametricValueFromPoint(other.onCurve2) if tEnd is not None: return (self.piece(max(0, tStart), min(1, tEnd))[0], ) # don't need the function if CEXY(self.onCurve1, other.onCurve1) or CEXY(self.onCurve1, other.onCurve2): return (self.onCurve1.__copy__(), ) if CEXY(self.onCurve2, other.onCurve1) or CEXY(self.onCurve2, other.onCurve2): return (self.onCurve2.__copy__(), ) # At this point we know it's a full curve/curve intersection, so the # possible results are 0-2 points ZE = mathutilities.zeroEnough A = self.onCurve1.x + self.onCurve2.x - (2 * self.offCurve.x) B = 2 * (self.offCurve.x - self.onCurve1.x) G = self.onCurve1.y + self.onCurve2.y - (2 * self.offCurve.y) H = 2 * (self.offCurve.y - self.onCurve1.y) assert not (ZE(A) and ZE(G)) # linear case already handled if ZE(A): return self._intersection_curve_xspecial(other) if ZE(G): return self._intersection_curve_yspecial(other) if ZE(B * G - A * H): return other.intersection(self) return self._intersection_curve(other) def magnified(self, xScale=1, yScale=1): """ Returns a new BSpline scaled as specified about the origin. >>> P = point.Point >>> b = BSpline(P(2, -3), P(4, 4), P(0, 1)) >>> print(b) Curve from (2, -3) to (4, 4) with off-curve point (0, 1) >>> print(b.magnified(-2.5, 0.75)) Curve from (-5.0, -2.25) to (-10.0, 3.0) with off-curve point (-0.0, 0.75) >>> c = BSpline(P(2, -3), P(4, 4), None) >>> print(c.magnified(5, -5)) Line from (10, 15) to (20, -20) """ if self.offCurve is None: return type(self)(self.onCurve1.magnified(xScale, yScale), self.onCurve2.magnified(xScale, yScale), None) return type(self)(self.onCurve1.magnified(xScale, yScale), self.onCurve2.magnified(xScale, yScale), self.offCurve.magnified(xScale, yScale)) def magnifiedAbout(self, about, xScale=1, yScale=1): """ Returns a new BSpline scaled as specified about a specified point. >>> P = point.Point >>> b = BSpline(P(2, -3), P(4, 4), P(0, 1)) >>> print(b) Curve from (2, -3) to (4, 4) with off-curve point (0, 1) >>> print(b.magnifiedAbout(P(1, -1), -2.5, 0.75)) Curve from (-1.5, -2.5) to (-6.5, 2.75) with off-curve point (3.5, 0.5) >>> c = BSpline(P(2, -3), P(4, 4), None) >>> print(c.magnifiedAbout(P(-1,3), 5, -5)) Line from (14, 33) to (24, -2) """ if self.offCurve is None: return type(self)( self.onCurve1.magnifiedAbout(about, xScale, yScale), self.onCurve2.magnifiedAbout(about, xScale, yScale), None) return type(self)(self.onCurve1.magnifiedAbout(about, xScale, yScale), self.onCurve2.magnifiedAbout(about, xScale, yScale), self.offCurve.magnifiedAbout(about, xScale, yScale)) def moved(self, deltaX=0, deltaY=0): """ Returns a new BSpline moved by the specified amount. >>> P = point.Point >>> b = BSpline(P(2, -3), P(4, 4), P(0, 1)) >>> print(b) Curve from (2, -3) to (4, 4) with off-curve point (0, 1) >>> print(b.moved(-2, 3)) Curve from (0, 0) to (2, 7) with off-curve point (-2, 4) """ if self.offCurve is None: return type(self)(self.onCurve1.moved(deltaX, deltaY), self.onCurve2.moved(deltaX, deltaY), None) return type(self)(self.onCurve1.moved(deltaX, deltaY), self.onCurve2.moved(deltaX, deltaY), self.offCurve.moved(deltaX, deltaY)) def normal(self): """ Returns a new BSpline of unit length, starting from the point on the curve corresponding to a t-value of 0.5, and moving in a direction orthogonal to the tangent at that point, outwards (i.e. away from the line from self.onCurve1 to self.onCurve2). >>> P = point.Point >>> print(BSpline(P(2, -3), P(4, 4), P(0, 1)).normal()) Line from (1.5, 0.75) to (0.5384760523591766, 1.0247211278973771) """ startPt = self.pointFromParametricValue(0.5) refLine = line.Line(self.onCurve1, self.onCurve2) tProj = refLine.parametricValueFromProjectedPoint(startPt) tProjPt = refLine.pointFromParametricValue(tProj) unnormEndPt = (startPt * 2) - tProjPt unnormLine = line.Line(startPt, unnormEndPt) return unnormLine.normalized() def normalized(self): """ If self.offCurve is None, or if self.offCurve is not None and lies legitimately off the line from self.onCurve1 to self.onCurve2, then self is returned. Otherwise, a new BSpline is created with the off- curve point set appropriately. >>> P = point.Point >>> b = BSpline(P(2, -3), P(4, 4), P(0, 1)) >>> print(b) Curve from (2, -3) to (4, 4) with off-curve point (0, 1) >>> print(b.normalized()) Curve from (2, -3) to (4, 4) with off-curve point (0, 1) >>> b = BSpline(P(0, 0), P(10, 10), P(5, 5)) >>> print(b) Curve from (0, 0) to (10, 10) with off-curve point (5, 5) >>> print(b.normalized()) Line from (0, 0) to (10, 10) We also need to normalize the cases where the off-curve point is co-linear with the two on-curve points, but has a parametric value less than zero or greater than one: >>> print(BSpline(P(5, 5), P(10, 10), P(0, 0)).normalized()) Line from (0, 0) to (10, 10) >>> print(BSpline(P(0,0), P(10, 10), P(5,5)).normalized()) Line from (0, 0) to (10, 10) >>> print(BSpline(P(0,0), P(10, 10), P(9.0001,10.0001)).normalized()) Curve from (0, 0) to (10, 10) with off-curve point (9.0001, 10.0001) """ if self.offCurve is None or self.onCurve1.equalEnough(self.onCurve2): return self L = line.Line(self.onCurve1, self.onCurve2) t = L.parametricValueFromPoint(self.offCurve) if t is None: return self # If we get here, the supposed off-curve point is actually colinear # with the two on-curve points, so it's a straight line and needs to # reflect that fact. if mathutilities.closeEnoughSpan(t): return type(self)(self.onCurve1, self.onCurve2, None) if t < 0: return type(self)(self.offCurve, self.onCurve2, None) return type(self)(self.onCurve1, self.offCurve, None) def parametricValueFromPoint(self, p): """ Given a Point p, find the parametric value t which corresponds to the point along the curve. Returns None if the point does not lie on the curve. >>> P = point.Point >>> b = BSpline(P(2, -3), P(4, 4), P(0, 1)) >>> print(b.parametricValueFromPoint(b.pointFromParametricValue(0))) 0 >>> print(b.parametricValueFromPoint(b.pointFromParametricValue(1))) 1 >>> print(b.parametricValueFromPoint(b.pointFromParametricValue(0.5))) 0.5 >>> m = BSpline(P(2, -3), P(4, 4), P(0, 1)) >>> print(b.parametricValueFromPoint(b.pointFromParametricValue(0))) 0 >>> b = BSpline(P(5, 5), P(10, 10), None) >>> print(b.parametricValueFromPoint(P(0, 0))) -1.0 """ if self.offCurve is None: theLine = line.Line(self.onCurve1, self.onCurve2) return theLine.parametricValueFromPoint(p) a = self.onCurve1.x + self.onCurve2.x - 2 * self.offCurve.x b = 2 * (self.offCurve.x - self.onCurve1.x) c = self.onCurve1.x - p.x if a: tx1, tx2 = mathutilities.quadratic(a, b, c) elif b: tx1 = tx2 = -c / b else: """ >>> d = BSpline(P(1, 5), P(1, 10), P(1,-5)) >>> print(d.parametricValueFromPoint(P(0, 0))) None >>> e = BSpline(P(-9, 5), P(1, 10), P(-1,-5)) >>> print(e.parametricValueFromPoint(P(0, 0))) None """ return (None if c else 0) if tx1.imag: return None a = self.onCurve1.y + self.onCurve2.y - 2 * self.offCurve.y b = 2 * (self.offCurve.y - self.onCurve1.y) c = self.onCurve1.y - p.y if a: ty1, ty2 = mathutilities.quadratic(a, b, c) elif b: ty1 = ty2 = -c / b else: """ >>> c = BSpline(P(5, 1), P(10, 1), P(-5,1)) >>> print(c.parametricValueFromPoint(P(0, 0))) None """ return (None if c else 0) CE = mathutilities.closeEnough if CE(tx1, ty1) or CE(tx1, ty2): return tx1 if CE(tx2, ty1) or CE(tx2, ty2): return tx2 return None def piece(self, t1, t2): """ Create a new b-spline mapping the original parametric values in the range t1 to t2 into new values from 0 to 1. Returns two things: the new b-spline, and an anonymous function which maps an old t-value (i.e. a value in the range from t1 through t2) onto a new u-value from 0 through 1. >>> P = point.Point >>> b = BSpline(P(2, -3), P(4, 4), P(0, 1)) >>> bNew, f = b.piece(0.25, 0.75) >>> print(bNew) Curve from (1.375, -1.0625) to (2.375, 2.4375) with off-curve point (1.125, 0.8125) >>> for t in (0.0, 0.25, 0.5, 0.75, 1.0): ... tNew = f(t) ... print(b.pointFromParametricValue(t), bNew.pointFromParametricValue(tNew)) (2.0, -3.0) (2.0, -3.0) (1.375, -1.0625) (1.375, -1.0625) (1.5, 0.75) (1.5, 0.75) (2.375, 2.4375) (2.375, 2.4375) (4.0, 4.0) (4.0, 4.0) >>> c = BSpline(P(2, -3), P(4, 4), None) >>> c.piece(0,10)[0].pprint() onCurve1: 0: 2.0 1: -3.0 onCurve2: 0: 22.0 1: 67.0 offCurve: (no data) >>> c.piece(100,-10)[0].pprint() onCurve1: 0: -18.0 1: -73.0 onCurve2: 0: 202.0 1: 697.0 offCurve: (no data) """ if t1 > t2: t1, t2 = t2, t1 newP0 = self.pointFromParametricValue(t1) newP2 = self.pointFromParametricValue(t2) if self.offCurve is None: # linear case newSpline = BSpline(newP0, newP2) else: # quadratic case newP1 = self.pointFromParametricValue((t1 + t2) / 2) newP1x = 2 * (newP1.x - 0.25 * (newP0.x + newP2.x)) newP1y = 2 * (newP1.y - 0.25 * (newP0.y + newP2.y)) newSpline = BSpline(newP0, newP2, point.Point(newP1x, newP1y)) return newSpline, lambda x: ((x - t1) / (t2 - t1)) def pointFromParametricValue(self, t): """ Given a parametric value t, where t=0 maps to self.onCurve1 and t=1 maps to self.onCurve2, returns a Point representing that value. >>> P = point.Point >>> b = BSpline(P(2, -3), P(4, 4), P(0, 1)) >>> print(b.pointFromParametricValue(0)) (2, -3) >>> print(b.pointFromParametricValue(1)) (4, 4) >>> print(b.pointFromParametricValue(0.5)) (1.5, 0.75) """ if self.offCurve is None: # linear case x = (t * self.onCurve2.x) + ((1.0 - t) * self.onCurve1.x) y = (t * self.onCurve2.y) + ((1.0 - t) * self.onCurve1.y) else: # quadratic case factor0 = (1 - t)**2 factor1 = 2 * t * (1 - t) factor2 = t * t x = ((factor0 * self.onCurve1.x) + (factor1 * self.offCurve.x) + (factor2 * self.onCurve2.x)) y = ((factor0 * self.onCurve1.y) + (factor1 * self.offCurve.y) + (factor2 * self.onCurve2.y)) return point.Point(x, y) def rotated(self, angleInDegrees=0): """ Returns a new BSpline scaled as specified about the origin. >>> P = point.Point >>> b = BSpline(P(2, -3), P(4, 4), P(0, 1)) >>> print(b) Curve from (2, -3) to (4, 4) with off-curve point (0, 1) >>> print(b.rotated(90)) Curve from (3.0, 2.0) to (-4.0, 4.0) with off-curve point (-1.0, 0.0) """ if self.offCurve is None: return type(self)(self.onCurve1.rotated(angleInDegrees), self.onCurve2.rotated(angleInDegrees), None) return type(self)(self.onCurve1.rotated(angleInDegrees), self.onCurve2.rotated(angleInDegrees), self.offCurve.rotated(angleInDegrees)) def rotatedAbout(self, about, angleInDegrees=0): """ Returns a new BSpline scaled as specified about a specified point. >>> P = point.Point >>> b = BSpline(P(2, -3), P(4, 4), P(0, 1)) >>> print(b) Curve from (2, -3) to (4, 4) with off-curve point (0, 1) >>> print(b.rotatedAbout(P(1, -1), 90)) Curve from (3.0, 0.0) to (-4.0, 2.0) with off-curve point (-1.0, -2.0) """ if self.offCurve is None: return type(self)( self.onCurve1.rotatedAbout(about, angleInDegrees), self.onCurve2.rotatedAbout(about, angleInDegrees), None) return type(self)(self.onCurve1.rotatedAbout(about, angleInDegrees), self.onCurve2.rotatedAbout(about, angleInDegrees), self.offCurve.rotatedAbout(about, angleInDegrees)) def slopeFromParametricValue(self, t): """ Returns the slope of the curve at parametric value t. >>> P = point.Point >>> b = BSpline(P(2, -3), P(4, 4), P(0, 1)) >>> print(b.slopeFromParametricValue(0)) -2.0 >>> print(b.slopeFromParametricValue(0.5)) 3.5 >>> print(b.slopeFromParametricValue(1)) 0.75 """ # The slope is dy/dx, which can be expressed parametrically by: # # dy/dx = dy/dt divided by dx/dt # # Let a=onCurve1.x, b=offCurve.x, and c=onCurve2.x. The parametric # equation for x in terms of t is: # # x = a(1-t)^2 + 2bt(1-t) + ct^2 # # Expanding and differentiating with respect to t, we get: # # dx/dt = 2(a+c-2b)t + 2(b-a) # # Using d, e, and f for analogous equations in y, we get: # # dy/dt = 2(d+f-2e)t + 2(e-d) # # Dividing dy/dt by dx/dt gives us the result. if self.offCurve is None: return line.Line(self.onCurve1, self.onCurve2).slope() a = self.onCurve1.x b = self.offCurve.x c = self.onCurve2.x denom = (a + c - 2 * b) * t + (b - a) if mathutilities.zeroEnough(denom): return float("+inf") d = self.onCurve1.y e = self.offCurve.y f = self.onCurve2.y numer = (d + f - 2 * e) * t + (e - d) return numer / denom def transitions(self, xStart, xStop, yCoord, leftToRight): """ Finds the intersection of self and the horizontal line segment defined by xStart, xStop, and yCoord, then for each point in the intersection determines whether the curve passes by left-to-right or right-to-left when looked at in the specified leftToRight orientation. See the doctest examples below for a sense of how this works. >>> P = point.Point >>> b = BSpline(P(0, 0), P(10, 0), P(5, 20)) >>> print(b.transitions(0, 10, 4, True)) {False: 1, True: 1} >>> print(b.transitions(0, 10, 4, False)) {False: 1, True: 1} >>> print(b.transitions(5, 10, 4, True)) {False: 0, True: 1} >>> print(b.transitions(5, 10, 4, False)) {False: 1, True: 0} >>> print(b.transitions(0, 5, 4, True)) {False: 1, True: 0} >>> print(b.transitions(0, 5, 4, False)) {False: 0, True: 1} Note that tangent points are not considered intersections for the purposes of this method: >>> b = BSpline(P(0, 0), P(7, -1), P(7, 1)) >>> print(b.transitions(0, 7, 1/3, True)) {False: 0, True: 0} """ ray = type(self)(onCurve1=point.Point(xStart, yCoord), onCurve2=point.Point(xStop, yCoord), offCurve=None) sects = self.intersection(ray) r = {False: 0, True: 0} CE = mathutilities.closeEnough for p in sorted(sects, reverse=(not leftToRight)): if isinstance(p, line.Line): continue t = self.parametricValueFromPoint(p) p2 = self.pointFromParametricValue(t - 0.001) p3 = self.pointFromParametricValue(t + 0.001) if CE(p2.y, p3.y): continue if p2.y > p3.y: r[leftToRight] += 1 else: r[not leftToRight] += 1 return r
def transitions(self, xStart, xStop, yCoord, leftToRight): """ Finds the intersection of self and the horizontal line segment defined by xStart, xStop, and yCoord, then for each point in the intersection determines whether the curve passes by left-to-right or right-to-left when looked at in the specified leftToRight orientation. See the doctest examples below for a sense of how this works. >>> P = point.Point >>> b = BSpline(P(0, 0), P(10, 0), P(5, 20)) >>> print(b.transitions(0, 10, 4, True)) {False: 1, True: 1} >>> print(b.transitions(0, 10, 4, False)) {False: 1, True: 1} >>> print(b.transitions(5, 10, 4, True)) {False: 0, True: 1} >>> print(b.transitions(5, 10, 4, False)) {False: 1, True: 0} >>> print(b.transitions(0, 5, 4, True)) {False: 1, True: 0} >>> print(b.transitions(0, 5, 4, False)) {False: 0, True: 1} Note that tangent points are not considered intersections for the purposes of this method: >>> b = BSpline(P(0, 0), P(7, -1), P(7, 1)) >>> print(b.transitions(0, 7, 1/3, True)) {False: 0, True: 0} """ ray = type(self)(onCurve1=point.Point(xStart, yCoord), onCurve2=point.Point(xStop, yCoord), offCurve=None) sects = self.intersection(ray) r = {False: 0, True: 0} CE = mathutilities.closeEnough for p in sorted(sects, reverse=(not leftToRight)): if isinstance(p, line.Line): continue t = self.parametricValueFromPoint(p) p2 = self.pointFromParametricValue(t - 0.001) p3 = self.pointFromParametricValue(t + 0.001) if CE(p2.y, p3.y): continue if p2.y > p3.y: r[leftToRight] += 1 else: r[not leftToRight] += 1 return r