def fromSegments(klass, array): """Construct a path from an array of Segment objects.""" self = klass() for a in array: assert (isinstance(a, Segment)) self.activeRepresentation = SegmentRepresentation(self, array) return self
def removeIrrelevantSegments(self, relLength=1 / 50000, absLength=0): """Removes small and collinear line segments. Collinear line segments are adjacent line segments which are heading in the same direction, and hence can be collapsed into a single segment. Small segments (those less than ``absLength`` units, or less than ``relLength`` as a fraction of the path's total length) are removed entirely.""" segs = self.asSegments() newsegs = [segs[0]] smallLength = self.length * relLength for i in range(1, len(segs)): prev = newsegs[-1] this = segs[i] if this.length < smallLength or this.length < absLength: this[0] = prev[0] newsegs[-1] = this continue if len(prev) == 2 and len(this) == 2: if math.isclose( prev.tangentAtTime(0).angle, this.tangentAtTime(0).angle): this[0] = prev[0] newsegs[-1] = this continue newsegs.append(this) self.activeRepresentation = SegmentRepresentation(self, newsegs) return self
def test_cubic_cubic(self): # q1 = Bezier(10,100, 90,30, 40,140, 220,220) # q2 = Bezier(5,150, 180,20, 80,250, 210,190) # console.log(q1.intersects(q2)) q1 = CubicBezier(Point(10, 100), Point(90, 30), Point(40, 140), Point(220, 220)) q2 = CubicBezier(Point(5, 150), Point(180, 20), Point(80, 250), Point(210, 190)) i = q1.intersections(q2) # self.assertEqual(len(i),3) # self.assertAlmostEqual(i[0].point.x,81.7904225873) # self.assertAlmostEqual(i[0].point.y,109.899396337) # self.assertAlmostEqual(i[1].point.x,133.186831292) # self.assertAlmostEqual(i[1].point.y,167.148173322) # self.assertAlmostEqual(i[2].point.x,179.869157678) # self.assertAlmostEqual(i[2].point.y,199.661989162) import matplotlib.pyplot as plt fig, ax = plt.subplots() path = BezierPath() path.closed = False path.activeRepresentation = SegmentRepresentation(path, [q1]) path.plot(ax) path.activeRepresentation = SegmentRepresentation(path, [q2]) path.plot(ax) for n in i: circle = plt.Circle((n.point.x, n.point.y), 2, fill=True, color="red") ax.add_artist(circle)
def splitAtPoints(self, splitlist): def mapx(v, ds): return (v - ds) / (1 - ds) segs = self.asSegments() newsegs = [] # Cluster splitlist by seg newsplitlist = {} for (seg, t) in splitlist: if not seg in newsplitlist: newsplitlist[seg] = [] newsplitlist[seg].append(t) for k in newsplitlist: newsplitlist[k] = sorted(newsplitlist[k]) # Now walk the path for seg in segs: if seg in newsplitlist: tList = newsplitlist[seg] while len(tList) > 0: t = tList.pop(0) if t < 1e-8: continue seg1, seg2 = seg.splitAtTime(t) newsegs.append(seg1) seg = seg2 for i in range(0, len(tList)): tList[i] = mapx(tList[i], t) newsegs.append(seg) self.activeRepresentation = SegmentRepresentation(self, newsegs)
def append(self, other, joinType="line"): """Append another path to this one. If the end point of the first path is not the same as the start point of the other path, a line will be drawn between them.""" segs1 = self.asSegments() segs2 = other.asSegments() if len(segs1) < 1: self.activeRepresentation = SegmentRepresentation(self, segs2) return if len(segs2) < 1: self.activeRepresentation = SegmentRepresentation(self, segs1) return # Which way around should they go? dist1 = segs1[-1].end.distanceFrom(segs2[0].start) dist2 = segs1[-1].end.distanceFrom(segs2[-1].end) if dist2 > 2 * dist1: segs2 = list(reversed([ x.reversed() for x in segs2])) # Add a line between if they don't match up if segs1[-1].end != segs2[0].start: segs1.append(Line(segs1[-1].end,segs2[0].start)) # XXX Check for discontinuities and harmonize if needed segs1.extend(segs2) self.activeRepresentation = SegmentRepresentation(self, segs1)
def balance(self): """Performs Tunni balancing on the path.""" segs = self.asSegments() for x in segs: if isinstance(x, CubicBezier): x.balance() self.activeRepresentation = SegmentRepresentation(self, segs) return self
def removeOverlap(self): """Resolves a path's self-intersections by 'walking around the outside'.""" if not self.closed: raise "Can only remove overlap on closed paths" splitlist = [] splitpoints = {} def roundoff(point): return (int(point.x * 1), int(point.y * 1)) for i in self.getSelfIntersections(): splitlist.append((i.seg1, i.t1)) splitlist.append((i.seg2, i.t2)) splitpoints[roundoff(i.point)] = {"in": [], "out": []} self.splitAtPoints(splitlist) # Trace path segs = self.asSegments() for i in range(0, len(segs)): seg = segs[i] if i < len(segs) - 1: seg.next = segs[i + 1] else: seg.next = segs[0] seg.visited = False segWinding = self.windingNumberOfPoint(seg.pointAtTime(0.5)) seg.windingNumber = segWinding if roundoff(seg.end) in splitpoints: splitpoints[roundoff(seg.end)]["in"].append(seg) if roundoff(seg.start) in splitpoints: splitpoints[roundoff(seg.start)]["out"].append(seg) newsegs = [] copying = True logging.debug("Split points:", splitpoints) seg = segs[0] while not seg.visited: logging.debug("Starting at %s, visiting %s" % (seg.start, seg)) newsegs.append(seg) seg.visited = True if roundoff(seg.end) in splitpoints and len(splitpoints[roundoff( seg.end)]["out"]) > 0: logging.debug("\nI am at %s and have a decision: " % seg.end) inAngle = seg.tangentAtTime(1).angle logging.debug("My angle is %s" % inAngle) # logging.debug("Options are: ") # for s in splitpoints[roundoff(seg.end)]["out"]: # logging.debug(s.end, s.tangentAtTime(0).angle, self.windingNumberOfPoint(s.pointAtTime(0.5))) # Filter out the inside points splitpoints[roundoff(seg.end)]["out"] = [ o for o in splitpoints[roundoff(seg.end)]["out"] if o.windingNumber < 2 ] splitpoints[roundoff(seg.end)]["out"].sort( key=lambda x: x.tangentAtTime(0).angle - inAngle) seg = splitpoints[roundoff(seg.end)]["out"].pop(-1) # seg = seg.next # logging.debug("I chose %s\n" % seg) else: seg = seg.next self.activeRepresentation = SegmentRepresentation(self, newsegs)
def asNodelist(self): """Return the path as an array of Node objects.""" if not isinstance(self.activeRepresentation, NodelistRepresentation): nl = self.activeRepresentation.toNodelist() assert isinstance(nl, list) self.activeRepresentation = NodelistRepresentation(self,nl) return self.activeRepresentation.data()
def fromPoints(self, points, error=50.0, cornerTolerance=20.0, maxSegments=20): """Fit a poly-bezier curve to the points given. This operation should be familiar from 'pencil' tools in a vector drawing application: the application samples points where your mouse pointer has been dragged, and then turns the sketch into a Bezier path. The goodness of fit can be controlled by tuning the `error` parameter. Corner detection can be controlled with `cornerTolerance`. Here are some points fit with `error=100.0`: .. figure:: curvefit1.png :scale: 75 % :alt: curvefit1 And with `error=10.0`: .. figure:: curvefit2.png :scale: 75 % :alt: curvefit1 """ from beziers.utils.curvefitter import CurveFit segs = CurveFit.fitCurve(points, error, cornerTolerance, maxSegments) path = BezierPath() path.closed = False path.activeRepresentation = SegmentRepresentation(path, segs) return path
def asNodelist(self): """Return the path as an array of Node objects.""" if not isinstance(self.activeRepresentation, NodelistRepresentation): nl = self.activeRepresentation.toNodelist() assert isinstance(nl, list) self.activeRepresentation = NodelistRepresentation(self, nl) return self.activeRepresentation.data()
def fromNodelist(klass, array, closed=True): """Construct a path from an array of Node objects.""" self = klass() for a in array: assert(isinstance(a, Node)) self.closed = closed self.activeRepresentation = NodelistRepresentation(self,array) self.asSegments() # Resolves a few problems return self
def asSegments(self): """Return the path as an array of segments (either Line, CubicBezier, or, if you are exceptionally unlucky, QuadraticBezier objects).""" if not isinstance(self.activeRepresentation, SegmentRepresentation): nl = self.activeRepresentation.toNodelist() assert isinstance(nl, list) self.activeRepresentation = SegmentRepresentation.fromNodelist(self,nl) return self.activeRepresentation.data()
def smooth(self, maxCollectionSize=30, lengthLimit=20, cornerTolerance=10): """Smooths a curve, by collating lists of small (at most ``lengthLimit`` units long) segments at most ``maxCollectionSize`` segments at a time, and running them through a curve fitting algorithm. The list collation also stops when one segment turns more than ``cornerTolerance`` degrees away from the previous one, so that corners are not smoothed.""" smallLineLength = lengthLimit segs = self.asSegments() i = 0 collection = [] while i < len(segs): s = segs[i] if s.length < smallLineLength and len( collection) <= maxCollectionSize: collection.append(s) else: corner = False if len(collection) > 1: last = collection[-1] if abs( last.tangentAtTime(1).angle - s.tangentAtTime(0).angle) > math.radians( cornerTolerance): corner = True if len(collection ) > maxCollectionSize or corner or i == len(segs) - 2: points = [x.start for x in collection] bp = BezierPath.fromPoints(points) if len(bp.asSegments()) > 0: segs[i - len(collection):i] = bp.asSegments() i -= len(collection) collection = [] i += 1 if len(collection) > 0: points = [x.start for x in collection] bp = BezierPath.fromPoints(points) if len(bp.asSegments()) > 0: segs[i - (1 + len(collection)):i - 1] = bp.asSegments() self.activeRepresentation = SegmentRepresentation(self, segs) return self
def test_cubic_line(self): q = CubicBezier(Point(100, 240), Point(30, 60), Point(210, 230), Point(160, 30)) l = Line(Point(25, 260), Point(230, 20)) path = BezierPath() path.closed = False path.activeRepresentation = SegmentRepresentation(path, [q]) i = q.intersections(l) self.assertEqual(len(i), 3) self.assertEqual(i[0].point, q.pointAtTime(0.117517031451)) self.assertEqual(i[1].point, q.pointAtTime(0.518591792307)) self.assertEqual(i[2].point, q.pointAtTime(0.867886610031))
def test_addextremes(self): q = CubicBezier(Point(42, 135), Point(129, 242), Point(167, 77), Point(65, 59)) ex = q.findExtremes() self.assertEqual(len(ex), 2) path = BezierPath() path.closed = False path.activeRepresentation = SegmentRepresentation(path, [q]) path.addExtremes() path.balance() segs = path.asSegments() self.assertEqual(len(segs), 3)
def append(self, other, joinType="line"): """Append another path to this one. If the end point of the first path is not the same as the start point of the other path, a line will be drawn between them.""" segs1 = self.asSegments() segs2 = other.asSegments() if len(segs1) < 1: self.activeRepresentation = SegmentRepresentation(self, segs2) return if len(segs2) < 1: self.activeRepresentation = SegmentRepresentation(self, segs1) return # Which way around should they go? dist1 = segs1[-1].end.distanceFrom(segs2[0].start) dist2 = segs1[-1].end.distanceFrom(segs2[-1].end) if dist2 > 2 * dist1: segs2 = list(reversed([x.reversed() for x in segs2])) # Add a line between if they don't match up if segs1[-1].end != segs2[0].start: segs1.append(Line(segs1[-1].end, segs2[0].start)) # XXX Check for discontinuities and harmonize if needed segs1.extend(segs2) self.activeRepresentation = SegmentRepresentation(self, segs1) return self
def offset(self, vector, rotateVector=True): """Returns a new BezierPath which approximates offsetting the current Bezier path by the given vector. Note that the vector will be rotated around the normal of the curve so that the offsetting always happens on the same 'side' of the curve: .. figure:: offset1.png :scale: 75 % :alt: offset1 If you don't want that and you want 'straight' offsetting instead (which may intersect with the original curve), pass `rotateVector=False`: .. figure:: offset2.png :scale: 75 % :alt: offset1 """ # Method 1 - curve fit newsegs = [] points = [] def finishPoints(newsegs, points): if len(points) > 0: bp = BezierPath.fromPoints(points, error=0.1, cornerTolerance=1) newsegs.extend(bp.asSegments()) while len(points) > 0: points.pop() for seg in self.asSegments(): if isinstance(seg, Line): finishPoints(newsegs, points) newsegs.append(seg.translated(vector)) else: t = 0.0 while t < 1.0: if rotateVector: points.append( seg.pointAtTime(t) + vector.rotated(Point(0, 0), seg.normalAtTime(t).angle)) else: points.append(seg.pointAtTime(t) + vector) step = max(abs(seg.curvatureAtTime(t)), 0.1) t = t + min(seg.length / step, 0.1) finishPoints(newsegs, points) newpath = BezierPath() newpath.activeRepresentation = SegmentRepresentation(newpath, newsegs) return newpath
def splitAtPoints(self,splitlist): def mapx(v,ds): return (v-ds)/(1-ds) segs = self.asSegments() newsegs = [] # Cluster splitlist by seg newsplitlist = {} for (seg,t) in splitlist: if not seg in newsplitlist: newsplitlist[seg] = [] newsplitlist[seg].append(t) for k in newsplitlist: newsplitlist[k] = sorted(newsplitlist[k]) # Now walk the path for seg in segs: if seg in newsplitlist: tList = newsplitlist[seg] while len(tList) > 0: t = tList.pop(0) if t < 1e-8: continue seg1,seg2 = seg.splitAtTime(t) newsegs.append(seg1) seg = seg2 for i in range(0,len(tList)): tList[i] = mapx(tList[i],t) newsegs.append(seg) self.activeRepresentation = SegmentRepresentation(self,newsegs)
def translate(self, vector): """Translates the path by a given vector.""" seg2 = [x.translated(vector) for x in self.asSegments()] self.activeRepresentation = SegmentRepresentation(self, seg2) return self
def reverse(self): """Reverse this path (mutates path).""" seg2 = [x.reversed() for x in self.asSegments()] self.activeRepresentation = SegmentRepresentation( self, list(reversed(seg2))) return self
def round(self): """Rounds the points of this path to integer coordinates.""" segs = self.asSegments() for s in segs: s.round() self.activeRepresentation = SegmentRepresentation(self, segs)
def translate(self, vector): """Translates the path by a given vector.""" seg2 = [ x.translated(vector) for x in self.asSegments()] self.activeRepresentation = SegmentRepresentation(self, seg2)
class BezierPath(BooleanOperationsMixin,SampleMixin,object): """`BezierPath` represents a collection of `Segment` objects - the curves and lines that make up a path. One of the really fiddly things about manipulating Bezier paths in a computer is that there are various ways to represent them. Different applications prefer different representations. For instance, when you're drawing a path on a canvas, you often want a list of nodes like so:: { "x":255.0, "y":20.0, "type":"curve"}, { "x":385.0, "y":20.0, "type":"offcurve"}, { "x":526.0, "y":79.0, "type":"offcurve"}, { "x":566.0, "y":135.0, "type":"curve"}, { "x":585.0, "y":162.0, "type":"offcurve"}, { "x":566.0, "y":260.0, "type":"offcurve"}, { "x":484.0, "y":281.0, "type":"curve"}, ... But when you're doing Clever Bezier Mathematics, you generally want a list of segments instead:: [ (255.0,20.0), (385.0,20.0), (526.0,79.0), (566.0,135.0)], [ (566.0,135.0), (585.0,162.0), (566.0,260.0), (484.0,281.0)], The Beziers module is designed to allow you to move fluidly between these different representations depending on what you're wanting to do. """ def __init__(self): self.activeRepresentation = None self.closed = True @classmethod def fromPoints(self, points, error = 50.0, cornerTolerance = 20.0, maxSegments = 20): """Fit a poly-bezier curve to the points given. This operation should be familiar from 'pencil' tools in a vector drawing application: the application samples points where your mouse pointer has been dragged, and then turns the sketch into a Bezier path. The goodness of fit can be controlled by tuning the `error` parameter. Corner detection can be controlled with `cornerTolerance`. Here are some points fit with `error=100.0`: .. figure:: curvefit1.png :scale: 75 % :alt: curvefit1 And with `error=10.0`: .. figure:: curvefit2.png :scale: 75 % :alt: curvefit1 """ from beziers.utils.curvefitter import CurveFit segs = CurveFit.fitCurve(points, error, cornerTolerance, maxSegments) path = BezierPath() path.closed = False path.activeRepresentation = SegmentRepresentation(path,segs) return path @classmethod def fromSegments(klass, array): """Construct a path from an array of Segment objects.""" self = klass() for a in array: assert(isinstance(a, Segment)) self.activeRepresentation = SegmentRepresentation(self,array) return self @classmethod def fromNodelist(klass, array, closed=True): """Construct a path from an array of Node objects.""" self = klass() for a in array: assert(isinstance(a, Node)) self.closed = closed self.activeRepresentation = NodelistRepresentation(self,array) self.asSegments() # Resolves a few problems return self @classmethod def fromFonttoolsGlyph(klass, glyph, glyphset, glyfTable): """Returns an *array of BezierPaths* from a FontTools glyph object. You must also provide the glyphset and glyf table objects to allow glyph decomposition.""" from fontTools.pens.basePen import BasePen class MyPen(BasePen): def __init__(self, glyphSet=None): super(MyPen, self).__init__(glyphSet) self.paths = [] self.path = BezierPath() self.nodeList = [] def _moveTo(self, p): self.nodeList = [Node(p[0], p[1], "move")] def _lineTo(self, p): self.nodeList.append(Node(p[0], p[1], "line")) def _curveToOne(self, p1, p2, p3): self.nodeList.append(Node(p1[0], p1[1], "offcurve")) self.nodeList.append(Node(p2[0], p2[1], "offcurve")) self.nodeList.append(Node(p3[0], p3[1], "curve")) def _qCurveToOne(self, p1, p2): self.nodeList.append(Node(p1[0], p1[1], "offcurve")) self.nodeList.append(Node(p2[0], p2[1], "curve")) def _closePath(self): self.path.closed = True self.path.activeRepresentation = NodelistRepresentation(self.path, self.nodeList) self.paths.append(self.path) self.path = BezierPath() pen = MyPen(glyphset) glyph.draw(pen, glyfTable) return pen.paths def asSegments(self): """Return the path as an array of segments (either Line, CubicBezier, or, if you are exceptionally unlucky, QuadraticBezier objects).""" if not isinstance(self.activeRepresentation, SegmentRepresentation): nl = self.activeRepresentation.toNodelist() assert isinstance(nl, list) self.activeRepresentation = SegmentRepresentation.fromNodelist(self,nl) return self.activeRepresentation.data() def asNodelist(self): """Return the path as an array of Node objects.""" if not isinstance(self.activeRepresentation, NodelistRepresentation): nl = self.activeRepresentation.toNodelist() assert isinstance(nl, list) self.activeRepresentation = NodelistRepresentation(self,nl) return self.activeRepresentation.data() def asMatplot(self): from matplotlib.path import Path nl = self.asNodelist() verts = [(nl[0].x,nl[0].y)] codes = [Path.MOVETO] for i in range(1,len(nl)): n = nl[i] verts.append((n.x,n.y)) if n.type == "offcurve": if nl[i+1].type == "offcurve" or nl[i-1].type == "offcurve": codes.append(Path.CURVE4) else: codes.append(Path.CURVE3) elif n.type == "curve": if nl[i-1].type == "offcurve" and i >2 and nl[i-2].type == "offcurve": codes.append(Path.CURVE4) else: codes.append(Path.CURVE3) elif n.type == "line": codes.append(Path.LINETO) else: raise "Unknown node type" if self.closed: verts.append((nl[0].x,nl[0].y)) codes.append(Path.CLOSEPOLY) return Path(verts, codes) def plot(self,ax, **kwargs): """Plot the path on a Matplot subplot which you supply :: import matplotlib.pyplot as plt fig, ax = plt.subplots() path.plot(ax) """ import matplotlib.pyplot as plt from matplotlib.lines import Line2D from matplotlib.path import Path import matplotlib.patches as patches path = self.asMatplot() if not "lw" in kwargs: kwargs["lw"] = 2 if not "fill" in kwargs: kwargs["fill"] = False drawNodes = (not("drawNodes" in kwargs) or kwargs["drawNodes"] != False) if "drawNodes" in kwargs: kwargs.pop("drawNodes") patch = patches.PathPatch(path, **kwargs) ax.add_patch(patch) left, right = ax.get_xlim() top, bottom = ax.get_ylim() bounds = self.bounds() bounds.addMargin(5) if not (left == 0.0 and right == 1.0 and top == 0.0 and bottom == 1.0): bounds.extend(Point(left,top)) bounds.extend(Point(right,bottom)) ax.set_xlim(bounds.left,bounds.right) ax.set_ylim(bounds.bottom,bounds.top) if drawNodes: nl = self.asNodelist() for i in range(0,len(nl)): n = nl[i] if n.type =="offcurve": circle = plt.Circle((n.x, n.y), 1, fill=False) ax.add_artist(circle) if i+1 < len(nl) and nl[i+1].type != "offcurve": l = Line2D([n.x, nl[i+1].x], [n.y, nl[i+1].y]) ax.add_artist(l) if i-0 >= 0 and nl[i-1].type != "offcurve": l = Line2D([n.x, nl[i-1].x], [n.y, nl[i-1].y]) ax.add_artist(l) else: circle = plt.Circle((n.x, n.y), 2) ax.add_artist(circle) def clone(self): """Return a new path which is an exact copy of this one""" return BezierPath.fromSegments(self.asSegments()) def round(self): """Rounds the points of this path to integer coordinates.""" segs = self.asSegments() for s in segs: s.round() self.activeRepresentation = SegmentRepresentation(self,segs) def bounds(self): """Determine the bounding box of the path, returned as a `BoundingBox` object.""" bbox = BoundingBox() for seg in self.asSegments(): bbox.extend(seg) return bbox def splitAtPoints(self,splitlist): def mapx(v,ds): return (v-ds)/(1-ds) segs = self.asSegments() newsegs = [] # Cluster splitlist by seg newsplitlist = {} for (seg,t) in splitlist: if not seg in newsplitlist: newsplitlist[seg] = [] newsplitlist[seg].append(t) for k in newsplitlist: newsplitlist[k] = sorted(newsplitlist[k]) # Now walk the path for seg in segs: if seg in newsplitlist: tList = newsplitlist[seg] while len(tList) > 0: t = tList.pop(0) if t < 1e-8: continue seg1,seg2 = seg.splitAtTime(t) newsegs.append(seg1) seg = seg2 for i in range(0,len(tList)): tList[i] = mapx(tList[i],t) newsegs.append(seg) self.activeRepresentation = SegmentRepresentation(self,newsegs) def addExtremes(self): """Add extreme points to the path.""" def mapx(v,ds): return (v-ds)/(1-ds) segs = self.asSegments() splitlist = [] for seg in segs: for t in seg.findExtremes(): splitlist.append((seg,t)) self.splitAtPoints(splitlist) @property def length(self): """Returns the length of the whole path.""" segs = self.asSegments() length = 0 for s in segs: length += s.length return length def pointAtTime(self,t): """Returns the point at time t (0->1) along the curve, where 1 is the end of the whole curve.""" segs = self.asSegments() if t == 1.0: return segs[-1].pointAtTime(1) t *= len(segs) seg = segs[int(math.floor(t))] return seg.pointAtTime(t-math.floor(t)) def lengthAtTime(self,t): """Returns the length of the subset of the path from the start up to the point t (0->1), where 1 is the end of the whole curve.""" segs = self.asSegments() t *= len(segs) length = 0 for s in segs[:int(math.floor(t))]: length += s.length seg = segs[int(math.floor(t))] s1,s2 = seg.splitAtTime(t-math.floor(t)) length += s1.length return length def offset(self, vector, rotateVector = True): """Returns a new BezierPath which approximates offsetting the current Bezier path by the given vector. Note that the vector will be rotated around the normal of the curve so that the offsetting always happens on the same 'side' of the curve: .. figure:: offset1.png :scale: 75 % :alt: offset1 If you don't want that and you want 'straight' offsetting instead (which may intersect with the original curve), pass `rotateVector=False`: .. figure:: offset2.png :scale: 75 % :alt: offset1 """ # Method 1 - curve fit newsegs = [] points = [] def finishPoints(newsegs, points): if len(points) > 0: bp = BezierPath.fromPoints(points, error=0.1, cornerTolerance= 1) newsegs.extend(bp.asSegments()) while len(points)>0:points.pop() for seg in self.asSegments(): if isinstance(seg,Line): finishPoints(newsegs,points) newsegs.append(seg.translated(vector)) else: t = 0.0 while t <1.0: if rotateVector: points.append( seg.pointAtTime(t) + vector.rotated(Point(0,0), seg.normalAtTime(t).angle)) else: points.append( seg.pointAtTime(t) + vector) step = max(abs(seg.curvatureAtTime(t)),0.1) t = t + min(seg.length / step,0.1) finishPoints(newsegs,points) newpath = BezierPath() newpath.activeRepresentation = SegmentRepresentation(newpath, newsegs) return newpath def append(self, other, joinType="line"): """Append another path to this one. If the end point of the first path is not the same as the start point of the other path, a line will be drawn between them.""" segs1 = self.asSegments() segs2 = other.asSegments() if len(segs1) < 1: self.activeRepresentation = SegmentRepresentation(self, segs2) return if len(segs2) < 1: self.activeRepresentation = SegmentRepresentation(self, segs1) return # Which way around should they go? dist1 = segs1[-1].end.distanceFrom(segs2[0].start) dist2 = segs1[-1].end.distanceFrom(segs2[-1].end) if dist2 > 2 * dist1: segs2 = list(reversed([ x.reversed() for x in segs2])) # Add a line between if they don't match up if segs1[-1].end != segs2[0].start: segs1.append(Line(segs1[-1].end,segs2[0].start)) # XXX Check for discontinuities and harmonize if needed segs1.extend(segs2) self.activeRepresentation = SegmentRepresentation(self, segs1) def reverse(self): """Reverse this path (mutates path).""" seg2 = [ x.reversed() for x in self.asSegments()] self.activeRepresentation = SegmentRepresentation(self, list(reversed(seg2))) def translate(self, vector): """Translates the path by a given vector.""" seg2 = [ x.translated(vector) for x in self.asSegments()] self.activeRepresentation = SegmentRepresentation(self, seg2) def rotate(self, about, angle): """Rotate the path by a given vector.""" seg2 = [ x.rotated(about, angle) for x in self.asSegments()] self.activeRepresentation = SegmentRepresentation(self, seg2) def scale(self, by): """Scales the path by a given magnitude.""" seg2 = [ x.scaled(by) for x in self.asSegments()] self.activeRepresentation = SegmentRepresentation(self, seg2) def balance(self): """Performs Tunni balancing on the path.""" segs = self.asSegments() for x in segs: if isinstance(x, CubicBezier): x.balance() self.activeRepresentation = SegmentRepresentation(self, segs) def findDiscontinuities(self): """Not implemented yet""" def harmonize(self): """Not implemented yet""" def roundCorners(self): """Not implemented yet""" def dash(self, lineLength = 50, gapLength = None): """Returns a list of BezierPath objects created by chopping this path into a dashed line:: paths = path.dash(lineLength = 20, gapLength = 50) .. figure:: dash.png :scale: 75 % :alt: path.dash(lineLength = 20, gapLength = 50) """ if not gapLength: gapLength = lineLength granularity = self.length newpaths = [] points = [] for t in self.regularSampleTValue(granularity): lenSoFar = self.lengthAtTime(t) # Super inefficient. But simple! lenSoFar = lenSoFar % (lineLength + gapLength) if lenSoFar >= lineLength and len(points) > 0: # When all you have is a hammer... bp = BezierPath.fromPoints(points, error=1, cornerTolerance= 1) points = [] if len(bp.asSegments()) > 0: newpaths.append(bp) elif lenSoFar <= lineLength: points.append(self.pointAtTime(t)) return newpaths def segpairs(self): segs = self.asSegments() for i in range(0,len(segs)-1): yield (segs[i],segs[i+1]) def harmonize(self, seg1, seg2): if seg1.end.x != seg2.start.x or seg1.end.y != seg2.start.y: return a1, a2 = seg1[1], seg1[2] b1, b2 = seg2[1], seg2[2] intersections = Line(a1,a2).intersections(Line(b1,b2),limited=False) if not intersections[0]: return p0 = a1.distanceFrom(a2) / a2.distanceFrom(intersections[0].point) p1 = b1.distanceFrom(intersections[0].point) / b1.distanceFrom(b2) r = math.sqrt(p0 * p1) t = r / (r+1) newA3 = a2.lerp(b1, t) fixup = seg2.start - newA3 seg1[2] += fixup seg2[1] += fixup def flatten(self,degree=8): segs = [] for s in self.asSegments(): segs.extend(s.flatten(degree)) return BezierPath.fromSegments(segs) def windingNumberOfPoint(self,pt): bounds = self.bounds() bounds.addMargin(10) ray1 = Line(Point(bounds.left,pt.y),pt) ray2 = Line(Point(bounds.right,pt.y),pt) leftIntersections = {} rightIntersections = {} leftWinding = 0 rightWinding = 0 for s in self.asSegments(): for i in s.intersections(ray1): # print("Found left intersection with %s: %s" % (ray1, i.point)) leftIntersections[i.point] = i for i in s.intersections(ray2): rightIntersections[i.point] = i for i in leftIntersections.values(): # XXX tangents here are all positive? Really? # print(i.seg1, i.t1, i.point) tangent = s.tangentAtTime(i.t1) # print("Tangent at left intersection %s is %f" % (i.point,tangent.y)) leftWinding += int(math.copysign(1,tangent.y)) for i in rightIntersections.values(): tangent = s.tangentAtTime(i.t1) # print("Tangent at right intersection %s is %f" % (i.point,tangent.y)) rightWinding += int(math.copysign(1,tangent.y)) # print("Left winding: %i right winding: %i " % (leftWinding,rightWinding)) return max(abs(leftWinding),abs(rightWinding)) def pointIsInside(self,pt): """Returns true if the given point lies on the "inside" of the path, assuming an 'even-odd' winding rule where self-intersections are considered outside.""" li = self.windingNumberOfPoint(pt) return li % 2 == 1 @property def area(self): """Approximates the area under a closed path by flattening and treating as a polygon.""" flat = self.flatten() area = 0 for s in flat.asSegments(): area = area + (s.start.x+s.end.x) * (s.end.y - s.start.y) area = area / 2.0 return abs(area)
def fromSegments(klass, array): """Construct a path from an array of Segment objects.""" self = klass() for a in array: assert(isinstance(a, Segment)) self.activeRepresentation = SegmentRepresentation(self,array) return self
def rotate(self, about, angle): """Rotate the path by a given vector.""" seg2 = [ x.rotated(about, angle) for x in self.asSegments()] self.activeRepresentation = SegmentRepresentation(self, seg2)
def rotate(self, about, angle): """Rotate the path by a given vector.""" seg2 = [x.rotated(about, angle) for x in self.asSegments()] self.activeRepresentation = SegmentRepresentation(self, seg2) return self
def round(self): """Rounds the points of this path to integer coordinates.""" segs = self.asSegments() for s in segs: s.round() self.activeRepresentation = SegmentRepresentation(self,segs)
def scale(self, by): """Scales the path by a given magnitude.""" seg2 = [x.scaled(by) for x in self.asSegments()] self.activeRepresentation = SegmentRepresentation(self, seg2) return self
def reverse(self): """Reverse this path (mutates path).""" seg2 = [ x.reversed() for x in self.asSegments()] self.activeRepresentation = SegmentRepresentation(self, list(reversed(seg2)))
def scale(self, by): """Scales the path by a given magnitude.""" seg2 = [ x.scaled(by) for x in self.asSegments()] self.activeRepresentation = SegmentRepresentation(self, seg2)
class BezierPath(BooleanOperationsMixin, SampleMixin, object): """`BezierPath` represents a collection of `Segment` objects - the curves and lines that make up a path. One of the really fiddly things about manipulating Bezier paths in a computer is that there are various ways to represent them. Different applications prefer different representations. For instance, when you're drawing a path on a canvas, you often want a list of nodes like so:: { "x":255.0, "y":20.0, "type":"curve"}, { "x":385.0, "y":20.0, "type":"offcurve"}, { "x":526.0, "y":79.0, "type":"offcurve"}, { "x":566.0, "y":135.0, "type":"curve"}, { "x":585.0, "y":162.0, "type":"offcurve"}, { "x":566.0, "y":260.0, "type":"offcurve"}, { "x":484.0, "y":281.0, "type":"curve"}, ... But when you're doing Clever Bezier Mathematics, you generally want a list of segments instead:: [ (255.0,20.0), (385.0,20.0), (526.0,79.0), (566.0,135.0)], [ (566.0,135.0), (585.0,162.0), (566.0,260.0), (484.0,281.0)], The Beziers module is designed to allow you to move fluidly between these different representations depending on what you're wanting to do. """ def __init__(self): self.activeRepresentation = None self.closed = True @classmethod def fromPoints(self, points, error=50.0, cornerTolerance=20.0, maxSegments=20): """Fit a poly-bezier curve to the points given. This operation should be familiar from 'pencil' tools in a vector drawing application: the application samples points where your mouse pointer has been dragged, and then turns the sketch into a Bezier path. The goodness of fit can be controlled by tuning the `error` parameter. Corner detection can be controlled with `cornerTolerance`. Here are some points fit with `error=100.0`: .. figure:: curvefit1.png :scale: 75 % :alt: curvefit1 And with `error=10.0`: .. figure:: curvefit2.png :scale: 75 % :alt: curvefit1 """ from beziers.utils.curvefitter import CurveFit segs = CurveFit.fitCurve(points, error, cornerTolerance, maxSegments) path = BezierPath() path.closed = False path.activeRepresentation = SegmentRepresentation(path, segs) return path @classmethod def fromSegments(klass, array): """Construct a path from an array of Segment objects.""" self = klass() for a in array: assert (isinstance(a, Segment)) self.activeRepresentation = SegmentRepresentation(self, array) return self @classmethod def fromNodelist(klass, array, closed=True): """Construct a path from an array of Node objects.""" self = klass() for a in array: assert (isinstance(a, Node)) self.closed = closed self.activeRepresentation = NodelistRepresentation(self, array) self.asSegments() # Resolves a few problems return self @classmethod def fromFonttoolsGlyph(klass, font, glyphname): """Returns an *array of BezierPaths* from a FontTools font object and glyph name.""" glyphset = font.getGlyphSet() from fontTools.pens.basePen import BasePen class MyPen(BasePen): def __init__(self, glyphSet=None): super(MyPen, self).__init__(glyphSet) self.paths = [] self.path = BezierPath() self.nodeList = [] def _moveTo(self, p): self.nodeList = [Node(p[0], p[1], "move")] def _lineTo(self, p): self.nodeList.append(Node(p[0], p[1], "line")) def _curveToOne(self, p1, p2, p3): self.nodeList.append(Node(p1[0], p1[1], "offcurve")) self.nodeList.append(Node(p2[0], p2[1], "offcurve")) self.nodeList.append(Node(p3[0], p3[1], "curve")) def _qCurveToOne(self, p1, p2): self.nodeList.append(Node(p1[0], p1[1], "offcurve")) self.nodeList.append(Node(p2[0], p2[1], "curve")) def _closePath(self): self.path.closed = True self.path.activeRepresentation = NodelistRepresentation( self.path, self.nodeList) self.paths.append(self.path) self.path = BezierPath() pen = MyPen(glyphset) _glyph = font.getGlyphSet()[glyphname]._glyph if "glyf" in font: _glyph.draw(pen, font["glyf"]) else: _glyph.draw(pen) return pen.paths def asSegments(self): """Return the path as an array of segments (either Line, CubicBezier, or, if you are exceptionally unlucky, QuadraticBezier objects).""" if not isinstance(self.activeRepresentation, SegmentRepresentation): nl = self.activeRepresentation.toNodelist() assert isinstance(nl, list) self.activeRepresentation = SegmentRepresentation.fromNodelist( self, nl) return self.activeRepresentation.data() def asNodelist(self): """Return the path as an array of Node objects.""" if not isinstance(self.activeRepresentation, NodelistRepresentation): nl = self.activeRepresentation.toNodelist() assert isinstance(nl, list) self.activeRepresentation = NodelistRepresentation(self, nl) return self.activeRepresentation.data() def asSVGPath(self): """Return the path as a string suitable for a SVG <path d="..."? element.""" segs = self.asSegments() pathParts = ["M %f %f" % (segs[0][0].x, segs[0][0].y)] operators = "xxLQC" for s in segs: op = operators[len(s)] + " " for pt in s[1:]: op = op + "%f %f " % (pt.x, pt.y) pathParts.append(op) if self.closed: pathParts.append("Z") return " ".join(pathParts) def asMatplot(self): from matplotlib.path import Path nl = self.asNodelist() verts = [(nl[0].x, nl[0].y)] codes = [Path.MOVETO] for i in range(1, len(nl)): n = nl[i] verts.append((n.x, n.y)) if n.type == "offcurve": if nl[i + 1].type == "offcurve" or nl[i - 1].type == "offcurve": codes.append(Path.CURVE4) else: codes.append(Path.CURVE3) elif n.type == "curve": if nl[i - 1].type == "offcurve" and i > 2 and nl[ i - 2].type == "offcurve": codes.append(Path.CURVE4) else: codes.append(Path.CURVE3) elif n.type == "line": codes.append(Path.LINETO) else: raise "Unknown node type" if self.closed: verts.append((nl[0].x, nl[0].y)) codes.append(Path.CLOSEPOLY) return Path(verts, codes) def plot(self, ax, **kwargs): """Plot the path on a Matplot subplot which you supply :: import matplotlib.pyplot as plt fig, ax = plt.subplots() path.plot(ax) """ import matplotlib.pyplot as plt from matplotlib.lines import Line2D from matplotlib.path import Path import matplotlib.patches as patches path = self.asMatplot() if not "lw" in kwargs: kwargs["lw"] = 2 if not "fill" in kwargs: kwargs["fill"] = False drawNodes = (not ("drawNodes" in kwargs) or kwargs["drawNodes"] != False) if "drawNodes" in kwargs: kwargs.pop("drawNodes") patch = patches.PathPatch(path, **kwargs) ax.add_patch(patch) left, right = ax.get_xlim() top, bottom = ax.get_ylim() bounds = self.bounds() bounds.addMargin(50) if not (left == 0.0 and right == 1.0 and top == 0.0 and bottom == 1.0): bounds.extend(Point(left, top)) bounds.extend(Point(right, bottom)) ax.set_xlim(bounds.left, bounds.right) ax.set_ylim(bounds.bottom, bounds.top) if drawNodes: nl = self.asNodelist() for i in range(0, len(nl)): n = nl[i] if n.type == "offcurve": circle = plt.Circle((n.x, n.y), 2, fill=True, color="black", alpha=0.5) ax.add_artist(circle) if i + 1 < len(nl) and nl[i + 1].type != "offcurve": l = Line2D([n.x, nl[i + 1].x], [n.y, nl[i + 1].y], linewidth=2, color="black", alpha=0.3) ax.add_artist(l) if i - 0 >= 0 and nl[i - 1].type != "offcurve": l = Line2D([n.x, nl[i - 1].x], [n.y, nl[i - 1].y], linewidth=2, color="black", alpha=0.3) ax.add_artist(l) else: circle = plt.Circle((n.x, n.y), 3, color="black", alpha=0.3) ax.add_artist(circle) def clone(self): """Return a new path which is an exact copy of this one""" p = BezierPath.fromSegments(self.asSegments()) p.closed = self.closed return p def round(self): """Rounds the points of this path to integer coordinates.""" segs = self.asSegments() for s in segs: s.round() self.activeRepresentation = SegmentRepresentation(self, segs) def bounds(self): """Determine the bounding box of the path, returned as a `BoundingBox` object.""" bbox = BoundingBox() for seg in self.asSegments(): bbox.extend(seg) return bbox def splitAtPoints(self, splitlist): def mapx(v, ds): return (v - ds) / (1 - ds) segs = self.asSegments() newsegs = [] # Cluster splitlist by seg newsplitlist = {} for (seg, t) in splitlist: if not seg in newsplitlist: newsplitlist[seg] = [] newsplitlist[seg].append(t) for k in newsplitlist: newsplitlist[k] = sorted(newsplitlist[k]) # Now walk the path for seg in segs: if seg in newsplitlist: tList = newsplitlist[seg] while len(tList) > 0: t = tList.pop(0) if t < 1e-8: continue seg1, seg2 = seg.splitAtTime(t) newsegs.append(seg1) seg = seg2 for i in range(0, len(tList)): tList[i] = mapx(tList[i], t) newsegs.append(seg) self.activeRepresentation = SegmentRepresentation(self, newsegs) def addExtremes(self): """Add extreme points to the path.""" def mapx(v, ds): return (v - ds) / (1 - ds) segs = self.asSegments() splitlist = [] for seg in segs: for t in seg.findExtremes(): splitlist.append((seg, t)) self.splitAtPoints(splitlist) @property def length(self): """Returns the length of the whole path.""" segs = self.asSegments() length = 0 for s in segs: length += s.length return length def pointAtTime(self, t): """Returns the point at time t (0->1) along the curve, where 1 is the end of the whole curve.""" segs = self.asSegments() if t == 1.0: return segs[-1].pointAtTime(1) t *= len(segs) seg = segs[int(math.floor(t))] return seg.pointAtTime(t - math.floor(t)) def lengthAtTime(self, t): """Returns the length of the subset of the path from the start up to the point t (0->1), where 1 is the end of the whole curve.""" segs = self.asSegments() t *= len(segs) length = 0 for s in segs[:int(math.floor(t))]: length += s.length seg = segs[int(math.floor(t))] s1, s2 = seg.splitAtTime(t - math.floor(t)) length += s1.length return length def offset(self, vector, rotateVector=True): """Returns a new BezierPath which approximates offsetting the current Bezier path by the given vector. Note that the vector will be rotated around the normal of the curve so that the offsetting always happens on the same 'side' of the curve: .. figure:: offset1.png :scale: 75 % :alt: offset1 If you don't want that and you want 'straight' offsetting instead (which may intersect with the original curve), pass `rotateVector=False`: .. figure:: offset2.png :scale: 75 % :alt: offset1 """ # Method 1 - curve fit newsegs = [] points = [] def finishPoints(newsegs, points): if len(points) > 0: bp = BezierPath.fromPoints(points, error=0.1, cornerTolerance=1) newsegs.extend(bp.asSegments()) while len(points) > 0: points.pop() for seg in self.asSegments(): if isinstance(seg, Line): finishPoints(newsegs, points) newsegs.append(seg.translated(vector)) else: t = 0.0 while t < 1.0: if rotateVector: points.append( seg.pointAtTime(t) + vector.rotated(Point(0, 0), seg.normalAtTime(t).angle)) else: points.append(seg.pointAtTime(t) + vector) step = max(abs(seg.curvatureAtTime(t)), 0.1) t = t + min(seg.length / step, 0.1) finishPoints(newsegs, points) newpath = BezierPath() newpath.activeRepresentation = SegmentRepresentation(newpath, newsegs) return newpath def append(self, other, joinType="line"): """Append another path to this one. If the end point of the first path is not the same as the start point of the other path, a line will be drawn between them.""" segs1 = self.asSegments() segs2 = other.asSegments() if len(segs1) < 1: self.activeRepresentation = SegmentRepresentation(self, segs2) return if len(segs2) < 1: self.activeRepresentation = SegmentRepresentation(self, segs1) return # Which way around should they go? dist1 = segs1[-1].end.distanceFrom(segs2[0].start) dist2 = segs1[-1].end.distanceFrom(segs2[-1].end) if dist2 > 2 * dist1: segs2 = list(reversed([x.reversed() for x in segs2])) # Add a line between if they don't match up if segs1[-1].end != segs2[0].start: segs1.append(Line(segs1[-1].end, segs2[0].start)) # XXX Check for discontinuities and harmonize if needed segs1.extend(segs2) self.activeRepresentation = SegmentRepresentation(self, segs1) return self def reverse(self): """Reverse this path (mutates path).""" seg2 = [x.reversed() for x in self.asSegments()] self.activeRepresentation = SegmentRepresentation( self, list(reversed(seg2))) return self def translate(self, vector): """Translates the path by a given vector.""" seg2 = [x.translated(vector) for x in self.asSegments()] self.activeRepresentation = SegmentRepresentation(self, seg2) return self def rotate(self, about, angle): """Rotate the path by a given vector.""" seg2 = [x.rotated(about, angle) for x in self.asSegments()] self.activeRepresentation = SegmentRepresentation(self, seg2) return self def scale(self, by): """Scales the path by a given magnitude.""" seg2 = [x.scaled(by) for x in self.asSegments()] self.activeRepresentation = SegmentRepresentation(self, seg2) return self def balance(self): """Performs Tunni balancing on the path.""" segs = self.asSegments() for x in segs: if isinstance(x, CubicBezier): x.balance() self.activeRepresentation = SegmentRepresentation(self, segs) return self def findDiscontinuities(self): """Not implemented yet""" def harmonize(self): """Not implemented yet""" def roundCorners(self): """Not implemented yet""" def dash(self, lineLength=50, gapLength=None): """Returns a list of BezierPath objects created by chopping this path into a dashed line:: paths = path.dash(lineLength = 20, gapLength = 50) .. figure:: dash.png :scale: 75 % :alt: path.dash(lineLength = 20, gapLength = 50) """ if not gapLength: gapLength = lineLength granularity = self.length newpaths = [] points = [] for t in self.regularSampleTValue(granularity): lenSoFar = self.lengthAtTime(t) # Super inefficient. But simple! lenSoFar = lenSoFar % (lineLength + gapLength) if lenSoFar >= lineLength and len(points) > 0: # When all you have is a hammer... bp = BezierPath.fromPoints(points, error=1, cornerTolerance=1) points = [] if len(bp.asSegments()) > 0: newpaths.append(bp) elif lenSoFar <= lineLength: points.append(self.pointAtTime(t)) return newpaths def segpairs(self): segs = self.asSegments() for i in range(0, len(segs) - 1): yield (segs[i], segs[i + 1]) def harmonize(self, seg1, seg2): if seg1.end.x != seg2.start.x or seg1.end.y != seg2.start.y: return a1, a2 = seg1[1], seg1[2] b1, b2 = seg2[1], seg2[2] intersections = Line(a1, a2).intersections(Line(b1, b2), limited=False) if not intersections[0]: return p0 = a1.distanceFrom(a2) / a2.distanceFrom(intersections[0].point) p1 = b1.distanceFrom(intersections[0].point) / b1.distanceFrom(b2) r = math.sqrt(p0 * p1) t = r / (r + 1) newA3 = a2.lerp(b1, t) fixup = seg2.start - newA3 seg1[2] += fixup seg2[1] += fixup def flatten(self, degree=8): segs = [] for s in self.asSegments(): segs.extend(s.flatten(degree)) return BezierPath.fromSegments(segs) def windingNumberOfPoint(self, pt): bounds = self.bounds() bounds.addMargin(10) ray1 = Line(Point(bounds.left, pt.y), pt) ray2 = Line(Point(bounds.right, pt.y), pt) leftIntersections = {} rightIntersections = {} leftWinding = 0 rightWinding = 0 for s in self.asSegments(): for i in s.intersections(ray1): # print("Found left intersection with %s: %s" % (ray1, i.point)) leftIntersections[i.point] = i for i in s.intersections(ray2): rightIntersections[i.point] = i for i in leftIntersections.values(): # XXX tangents here are all positive? Really? # print(i.seg1, i.t1, i.point) tangent = s.tangentAtTime(i.t1) # print("Tangent at left intersection %s is %f" % (i.point,tangent.y)) leftWinding += int(math.copysign(1, tangent.y)) for i in rightIntersections.values(): tangent = s.tangentAtTime(i.t1) # print("Tangent at right intersection %s is %f" % (i.point,tangent.y)) rightWinding += int(math.copysign(1, tangent.y)) # print("Left winding: %i right winding: %i " % (leftWinding,rightWinding)) return max(abs(leftWinding), abs(rightWinding)) def pointIsInside(self, pt): """Returns true if the given point lies on the "inside" of the path, assuming an 'even-odd' winding rule where self-intersections are considered outside.""" li = self.windingNumberOfPoint(pt) return li % 2 == 1 @property def area(self): """Approximates the area under a closed path by flattening and treating as a polygon.""" flat = self.flatten() area = 0 for s in flat.asSegments(): area = area + (s.start.x * s.end.y) - (s.start.y * s.end.x) area = area / 2.0 return abs(area) @property def centroid(self): if not self.closed: return None return self.bounds().centroid # Really? def drawWithBrush(self, other, pathSmoothness=50, brushSmoothness=20, alpha=0.15): """Assuming that `other` is a closed Bezier path representing a pen or brush of a certain shape and that `self` is an open path, this method traces the brush along the path, returning an array of Bezier paths. `other` may also be a function which, given a time `t` (0-1), returns a closed path representing the shape of the brush at the given time. This requires the `shapely` and `scipy` libraries to be installed, and is very, very slow. """ samples = self.sample(int(self.length * (pathSmoothness / 100.0))) self.closed = False def constantBrush(t): return other brush = other if not callable(brush): brush = constantBrush c = brush(0).centroid points = [] from shapely.geometry import Point as ShapelyPoint t = 0 for n in samples: brushHere = brush(t).clone() brushHere.translate(n - brushHere.centroid) brushsamples = brushHere.sample( int(brushHere.length * (brushSmoothness / 100.0))) points.extend([ShapelyPoint(s.x, s.y) for s in brushsamples]) t = t + 1.0 / len(samples) from beziers.utils.alphashape import alpha_shape concave_hull, edge_points = alpha_shape(points, alpha=alpha) points = [Point(p[0], p[1]) for p in concave_hull.exterior.coords] path = BezierPath.fromPoints(points, error=5, maxSegments=100) return path def quadraticsToCubics(self): """Converts all quadratic segments in the path to cubic Beziers.""" segs = self.asSegments() for i, s in enumerate(segs): if len(s) == 3: segs[i] = s.toCubicBezier() return self
def balance(self): """Performs Tunni balancing on the path.""" segs = self.asSegments() for x in segs: if isinstance(x, CubicBezier): x.balance() self.activeRepresentation = SegmentRepresentation(self, segs)
class BezierPath(BooleanOperationsMixin, SampleMixin, object): """`BezierPath` represents a collection of `Segment` objects - the curves and lines that make up a path. One of the really fiddly things about manipulating Bezier paths in a computer is that there are various ways to represent them. Different applications prefer different representations. For instance, when you're drawing a path on a canvas, you often want a list of nodes like so:: { "x":255.0, "y":20.0, "type":"curve"}, { "x":385.0, "y":20.0, "type":"offcurve"}, { "x":526.0, "y":79.0, "type":"offcurve"}, { "x":566.0, "y":135.0, "type":"curve"}, { "x":585.0, "y":162.0, "type":"offcurve"}, { "x":566.0, "y":260.0, "type":"offcurve"}, { "x":484.0, "y":281.0, "type":"curve"}, ... But when you're doing Clever Bezier Mathematics, you generally want a list of segments instead:: [ (255.0,20.0), (385.0,20.0), (526.0,79.0), (566.0,135.0)], [ (566.0,135.0), (585.0,162.0), (566.0,260.0), (484.0,281.0)], The Beziers module is designed to allow you to move fluidly between these different representations depending on what you're wanting to do. """ def __init__(self): self.activeRepresentation = None self.closed = True @classmethod def fromPoints(self, points, error=50.0, cornerTolerance=20.0, maxSegments=20): """Fit a poly-bezier curve to the points given. This operation should be familiar from 'pencil' tools in a vector drawing application: the application samples points where your mouse pointer has been dragged, and then turns the sketch into a Bezier path. The goodness of fit can be controlled by tuning the `error` parameter. Corner detection can be controlled with `cornerTolerance`. Here are some points fit with `error=100.0`: .. figure:: curvefit1.png :scale: 75 % :alt: curvefit1 And with `error=10.0`: .. figure:: curvefit2.png :scale: 75 % :alt: curvefit1 """ from beziers.utils.curvefitter import CurveFit segs = CurveFit.fitCurve(points, error, cornerTolerance, maxSegments) path = BezierPath() path.closed = False path.activeRepresentation = SegmentRepresentation(path, segs) return path @classmethod def fromSegments(klass, array): """Construct a path from an array of Segment objects.""" self = klass() for a in array: assert (isinstance(a, Segment)) self.activeRepresentation = SegmentRepresentation(self, array) return self @classmethod def fromNodelist(klass, array, closed=True): """Construct a path from an array of Node objects.""" self = klass() for a in array: assert (isinstance(a, Node)) self.closed = closed self.activeRepresentation = NodelistRepresentation(self, array) self.asSegments() # Resolves a few problems return self @classmethod def fromGlyphsLayer(klass, layer): """Returns an *array of BezierPaths* from a Glyphs GSLayer object.""" from beziers.path.representations.GSPath import GSPathRepresentation bezpaths = [] for p in layer.paths: path = BezierPath() path.activeRepresentation = GSPathRepresentation(path, p) bezpaths.append(path) return bezpaths @classmethod def fromDrawable(klass, layer, *penArgs, **penKwargs): """Returns an *array of BezierPaths* from any object conforming to the pen protocol.""" from beziers.utils.pens import BezierPathCreatingPen pen = BezierPathCreatingPen(*penArgs, **penKwargs) layer.draw(pen) return pen.paths @classmethod def fromFonttoolsGlyph(klass, font, glyphname): """Returns an *array of BezierPaths* from a FontTools font object and glyph name.""" glyphset = font.getGlyphSet() from beziers.utils.pens import BezierPathCreatingPen pen = BezierPathCreatingPen(glyphset) _glyph = font.getGlyphSet()[glyphname]._glyph if "glyf" in font: _glyph.draw(pen, font["glyf"]) else: _glyph.draw(pen) return pen.paths def asSegments(self): """Return the path as an array of segments (either Line, CubicBezier, or, if you are exceptionally unlucky, QuadraticBezier objects).""" if not isinstance(self.activeRepresentation, SegmentRepresentation): nl = self.activeRepresentation.toNodelist() assert isinstance(nl, list) self.activeRepresentation = SegmentRepresentation.fromNodelist( self, nl) return self.activeRepresentation.data() def asNodelist(self): """Return the path as an array of Node objects.""" if not isinstance(self.activeRepresentation, NodelistRepresentation): nl = self.activeRepresentation.toNodelist() assert isinstance(nl, list) self.activeRepresentation = NodelistRepresentation(self, nl) return self.activeRepresentation.data() def asSVGPath(self): """Return the path as a string suitable for a SVG <path d="..."? element.""" segs = self.asSegments() pathParts = ["M %f %f" % (segs[0][0].x, segs[0][0].y)] operators = "xxLQC" for s in segs: op = operators[len(s)] + " " for pt in s[1:]: op = op + "%f %f " % (pt.x, pt.y) pathParts.append(op) if self.closed: pathParts.append("Z") return " ".join(pathParts) def asMatplot(self): from matplotlib.path import Path nl = self.asNodelist() verts = [(nl[0].x, nl[0].y)] codes = [Path.MOVETO] for i in range(1, len(nl)): n = nl[i] verts.append((n.x, n.y)) if n.type == "offcurve": if nl[i + 1].type == "offcurve" or nl[i - 1].type == "offcurve": codes.append(Path.CURVE4) else: codes.append(Path.CURVE3) elif n.type == "curve": if nl[i - 1].type == "offcurve" and i > 2 and nl[ i - 2].type == "offcurve": codes.append(Path.CURVE4) else: codes.append(Path.CURVE3) elif n.type == "line": codes.append(Path.LINETO) else: raise "Unknown node type" if self.closed: verts.append((nl[0].x, nl[0].y)) codes.append(Path.CLOSEPOLY) return Path(verts, codes) def plot(self, ax, **kwargs): """Plot the path on a Matplot subplot which you supply :: import matplotlib.pyplot as plt fig, ax = plt.subplots() path.plot(ax) """ import matplotlib.pyplot as plt from matplotlib.lines import Line2D from matplotlib.path import Path import matplotlib.patches as patches path = self.asMatplot() if not "lw" in kwargs: kwargs["lw"] = 2 if not "fill" in kwargs: kwargs["fill"] = False drawNodes = (not ("drawNodes" in kwargs) or kwargs["drawNodes"] != False) if "drawNodes" in kwargs: kwargs.pop("drawNodes") patch = patches.PathPatch(path, **kwargs) ax.add_patch(patch) left, right = ax.get_xlim() top, bottom = ax.get_ylim() bounds = self.bounds() bounds.addMargin(50) if not (left == 0.0 and right == 1.0 and top == 0.0 and bottom == 1.0): bounds.extend(Point(left, top)) bounds.extend(Point(right, bottom)) ax.set_xlim(bounds.left, bounds.right) ax.set_ylim(bounds.bottom, bounds.top) if drawNodes: nl = self.asNodelist() for i in range(0, len(nl)): n = nl[i] if n.type == "offcurve": circle = plt.Circle((n.x, n.y), 2, fill=True, color="black", alpha=0.5) ax.add_artist(circle) if i + 1 < len(nl) and nl[i + 1].type != "offcurve": l = Line2D([n.x, nl[i + 1].x], [n.y, nl[i + 1].y], linewidth=2, color="black", alpha=0.3) ax.add_artist(l) if i - 0 >= 0 and nl[i - 1].type != "offcurve": l = Line2D([n.x, nl[i - 1].x], [n.y, nl[i - 1].y], linewidth=2, color="black", alpha=0.3) ax.add_artist(l) else: circle = plt.Circle((n.x, n.y), 3, color="black", alpha=0.3) ax.add_artist(circle) def clone(self): """Return a new path which is an exact copy of this one""" p = BezierPath.fromSegments(self.asSegments()) p.closed = self.closed return p def round(self): """Rounds the points of this path to integer coordinates.""" segs = self.asSegments() for s in segs: s.round() self.activeRepresentation = SegmentRepresentation(self, segs) def bounds(self): """Determine the bounding box of the path, returned as a `BoundingBox` object.""" bbox = BoundingBox() for seg in self.asSegments(): bbox.extend(seg) return bbox def splitAtPoints(self, splitlist): def mapx(v, ds): return (v - ds) / (1 - ds) segs = self.asSegments() newsegs = [] # Cluster splitlist by seg newsplitlist = {} for (seg, t) in splitlist: if not seg in newsplitlist: newsplitlist[seg] = [] newsplitlist[seg].append(t) for k in newsplitlist: newsplitlist[k] = sorted(newsplitlist[k]) # Now walk the path for seg in segs: if seg in newsplitlist: tList = newsplitlist[seg] while len(tList) > 0: t = tList.pop(0) if t < 1e-8: continue seg1, seg2 = seg.splitAtTime(t) newsegs.append(seg1) seg = seg2 for i in range(0, len(tList)): tList[i] = mapx(tList[i], t) newsegs.append(seg) self.activeRepresentation = SegmentRepresentation(self, newsegs) def addExtremes(self): """Add extreme points to the path.""" def mapx(v, ds): return (v - ds) / (1 - ds) segs = self.asSegments() splitlist = [] for seg in segs: for t in seg.findExtremes(): splitlist.append((seg, t)) self.splitAtPoints(splitlist) return self @property def length(self): """Returns the length of the whole path.""" segs = self.asSegments() length = 0 for s in segs: length += s.length return length def pointAtTime(self, t): """Returns the point at time t (0->1) along the curve, where 1 is the end of the whole curve.""" segs = self.asSegments() if t == 1.0: return segs[-1].pointAtTime(1) t *= len(segs) seg = segs[int(math.floor(t))] return seg.pointAtTime(t - math.floor(t)) def lengthAtTime(self, t): """Returns the length of the subset of the path from the start up to the point t (0->1), where 1 is the end of the whole curve.""" segs = self.asSegments() t *= len(segs) length = 0 for s in segs[:int(math.floor(t))]: length += s.length seg = segs[int(math.floor(t))] s1, s2 = seg.splitAtTime(t - math.floor(t)) length += s1.length return length def offset(self, vector, rotateVector=True): """Returns a new BezierPath which approximates offsetting the current Bezier path by the given vector. Note that the vector will be rotated around the normal of the curve so that the offsetting always happens on the same 'side' of the curve: .. figure:: offset1.png :scale: 75 % :alt: offset1 If you don't want that and you want 'straight' offsetting instead (which may intersect with the original curve), pass `rotateVector=False`: .. figure:: offset2.png :scale: 75 % :alt: offset1 """ # Method 1 - curve fit newsegs = [] points = [] def finishPoints(newsegs, points): if len(points) > 0: bp = BezierPath.fromPoints(points, error=0.1, cornerTolerance=1) newsegs.extend(bp.asSegments()) while len(points) > 0: points.pop() for seg in self.asSegments(): if isinstance(seg, Line): finishPoints(newsegs, points) newsegs.append(seg.translated(vector)) else: t = 0.0 while t < 1.0: if rotateVector: points.append( seg.pointAtTime(t) + vector.rotated(Point(0, 0), seg.normalAtTime(t).angle)) else: points.append(seg.pointAtTime(t) + vector) step = max(abs(seg.curvatureAtTime(t)), 0.1) t = t + min(seg.length / step, 0.1) finishPoints(newsegs, points) newpath = BezierPath() newpath.activeRepresentation = SegmentRepresentation(newpath, newsegs) return newpath def append(self, other, joinType="line"): """Append another path to this one. If the end point of the first path is not the same as the start point of the other path, a line will be drawn between them.""" segs1 = self.asSegments() segs2 = other.asSegments() if len(segs1) < 1: self.activeRepresentation = SegmentRepresentation(self, segs2) return if len(segs2) < 1: self.activeRepresentation = SegmentRepresentation(self, segs1) return # Which way around should they go? dist1 = segs1[-1].end.distanceFrom(segs2[0].start) dist2 = segs1[-1].end.distanceFrom(segs2[-1].end) if dist2 > 2 * dist1: segs2 = list(reversed([x.reversed() for x in segs2])) # Add a line between if they don't match up if segs1[-1].end != segs2[0].start: segs1.append(Line(segs1[-1].end, segs2[0].start)) # XXX Check for discontinuities and harmonize if needed segs1.extend(segs2) self.activeRepresentation = SegmentRepresentation(self, segs1) return self def reverse(self): """Reverse this path (mutates path).""" seg2 = [x.reversed() for x in self.asSegments()] self.activeRepresentation = SegmentRepresentation( self, list(reversed(seg2))) return self def translate(self, vector): """Translates the path by a given vector.""" seg2 = [x.translated(vector) for x in self.asSegments()] self.activeRepresentation = SegmentRepresentation(self, seg2) return self def rotate(self, about, angle): """Rotate the path by a given vector.""" seg2 = [x.rotated(about, angle) for x in self.asSegments()] self.activeRepresentation = SegmentRepresentation(self, seg2) return self def scale(self, by): """Scales the path by a given magnitude.""" seg2 = [x.scaled(by) for x in self.asSegments()] self.activeRepresentation = SegmentRepresentation(self, seg2) return self def balance(self): """Performs Tunni balancing on the path.""" segs = self.asSegments() for x in segs: if isinstance(x, CubicBezier): x.balance() self.activeRepresentation = SegmentRepresentation(self, segs) return self def findDiscontinuities(self): """Not implemented yet""" def harmonize(self): """Not implemented yet""" def roundCorners(self): """Not implemented yet""" def dash(self, lineLength=50, gapLength=None): """Returns a list of BezierPath objects created by chopping this path into a dashed line:: paths = path.dash(lineLength = 20, gapLength = 50) .. figure:: dash.png :scale: 75 % :alt: path.dash(lineLength = 20, gapLength = 50) """ if not gapLength: gapLength = lineLength granularity = self.length newpaths = [] points = [] for t in self.regularSampleTValue(granularity): lenSoFar = self.lengthAtTime(t) # Super inefficient. But simple! lenSoFar = lenSoFar % (lineLength + gapLength) if lenSoFar >= lineLength and len(points) > 0: # When all you have is a hammer... bp = BezierPath.fromPoints(points, error=1, cornerTolerance=1) points = [] if len(bp.asSegments()) > 0: newpaths.append(bp) elif lenSoFar <= lineLength: points.append(self.pointAtTime(t)) return newpaths def segpairs(self): segs = self.asSegments() for i in range(0, len(segs) - 1): yield (segs[i], segs[i + 1]) def harmonize(self, seg1, seg2): if seg1.end.x != seg2.start.x or seg1.end.y != seg2.start.y: return a1, a2 = seg1[1], seg1[2] b1, b2 = seg2[1], seg2[2] intersections = Line(a1, a2).intersections(Line(b1, b2), limited=False) if not intersections[0]: return p0 = a1.distanceFrom(a2) / a2.distanceFrom(intersections[0].point) p1 = b1.distanceFrom(intersections[0].point) / b1.distanceFrom(b2) r = math.sqrt(p0 * p1) t = r / (r + 1) newA3 = a2.lerp(b1, t) fixup = seg2.start - newA3 seg1[2] += fixup seg2[1] += fixup def flatten(self, degree=8): segs = [] for s in self.asSegments(): segs.extend(s.flatten(degree)) return BezierPath.fromSegments(segs) def windingNumberOfPoint(self, pt): bounds = self.bounds() bounds.addMargin(10) ray1 = Line(Point(bounds.left, pt.y), pt) ray2 = Line(Point(bounds.right, pt.y), pt) leftIntersections = {} rightIntersections = {} leftWinding = 0 rightWinding = 0 for s in self.asSegments(): for i in s.intersections(ray1): # print("Found left intersection with %s: %s" % (ray1, i.point)) leftIntersections[i.point] = i for i in s.intersections(ray2): rightIntersections[i.point] = i for i in leftIntersections.values(): # XXX tangents here are all positive? Really? # print(i.seg1, i.t1, i.point) tangent = s.tangentAtTime(i.t1) # print("Tangent at left intersection %s is %f" % (i.point,tangent.y)) leftWinding += int(math.copysign(1, tangent.y)) for i in rightIntersections.values(): tangent = s.tangentAtTime(i.t1) # print("Tangent at right intersection %s is %f" % (i.point,tangent.y)) rightWinding += int(math.copysign(1, tangent.y)) # print("Left winding: %i right winding: %i " % (leftWinding,rightWinding)) return max(abs(leftWinding), abs(rightWinding)) def pointIsInside(self, pt): """Returns true if the given point lies on the "inside" of the path, assuming an 'even-odd' winding rule where self-intersections are considered outside.""" li = self.windingNumberOfPoint(pt) return li % 2 == 1 @property def signed_area(self): """Approximates the area under a closed path by flattening and treating as a polygon. Returns the signed area; positive means the path is counter-clockwise, negative means it is clockwise.""" flat = self.flatten() area = 0 for s in flat.asSegments(): area = area + (s.start.x * s.end.y) - (s.start.y * s.end.x) area = area / 2.0 return area @property def area(self): """Approximates the area under a closed path by flattening and treating as a polygon. Returns the unsigned area. Use :py:meth:`signed_area` if you want the signed area.""" return abs(self.signed_area) @property def direction(self): """Returns the direction of the path: -1 for clockwise and 1 for counterclockwise.""" return math.copysign(1, self.signed_area) @property def centroid(self): if not self.closed: return None return self.bounds().centroid # Really? def drawWithBrush(self, other): """Assuming that `other` is a closed Bezier path representing a pen or brush of a certain shape and that `self` is an open path, this method traces the brush along the path, returning an array of Bezier paths. `other` may also be a function which, given a time `t` (0-1), returns a closed path representing the shape of the brush at the given time. This requires the `shapely` library to be installed. """ from shapely.geometry import Polygon from shapely.ops import unary_union polys = [] samples = self.sample(self.length / 2) def constantBrush(t): return other brush = other if not callable(brush): brush = constantBrush c = brush(0).centroid from itertools import tee def pairwise(iterable): "s -> (s0,s1), (s1,s2), (s2, s3), ..." a, b = tee(iterable) next(b, None) return zip(a, b) t = 0 for n in samples: brushHere = brush(t).clone().flatten() brushHere.translate(n - brushHere.centroid) polys.append( Polygon([(x[0].x, x[0].y) for x in brushHere.asSegments()])) t = t + 1.0 / len(samples) concave_hull = unary_union(polys) ll = [] for x, y in pairwise(concave_hull.exterior.coords): l = Line(Point(x[0], x[1]), Point(y[0], y[1])) ll.append(l) paths = [BezierPath.fromSegments(ll)] for interior in concave_hull.interiors: ll = [] for x, y in pairwise(interior.coords): l = Line(Point(x[0], x[1]), Point(y[0], y[1])) ll.append(l) paths.append(BezierPath.fromSegments(ll)) return paths def quadraticsToCubics(self): """Converts all quadratic segments in the path to cubic Beziers.""" segs = self.asSegments() for i, s in enumerate(segs): if len(s) == 3: segs[i] = s.toCubicBezier() return self def thicknessAtX(path, x): """Returns the thickness of the path at x-coordinate ``x``.""" bounds = path.bounds() bounds.addMargin(10) ray = Line(Point(x - 0.1, bounds.bottom), Point(x + 0.1, bounds.top)) intersections = [] for seg in path.asSegments(): intersections.extend(seg.intersections(ray)) if len(intersections) < 2: return None intersections = list(sorted(intersections, key=lambda i: i.point.y)) i1, i2 = intersections[0:2] inorm1 = i1.seg1.normalAtTime(i1.t1) ray1 = Line(i1.point + (inorm1 * 1000), i1.point + (inorm1 * -1000)) iii = i2.seg1.intersections(ray1) if iii: ll1 = i1.point.distanceFrom(iii[0].point) else: # Simple, vertical version return abs(i1.point.y - i2.point.y) inorm2 = i2.seg1.normalAtTime(i2.t1) ray2 = Line(i2.point + (inorm2 * 1000), i2.point + (inorm2 * -1000)) iii = i1.seg1.intersections(ray2) if iii: ll2 = i2.point.distanceFrom(iii[0].point) return (ll1 + ll2) * 0.5 else: return ll1 # midpoint = (i1.point + i2.point) / 2 # # Find closest path to midpoint # # Find the tangent at that time # inorm2 = i2.seg1.normalAtTime(i2.t1) def distanceToPath(self, other, samples=10): """Finds the distance to the other curve at its closest point, along with the t values for the closest point at each segment and the relevant segments. Returns: ``distance, t1, t2, seg1, seg2``.""" from beziers.utils.curvedistance import curveDistance segs1 = self.asSegments() segs2 = other.asSegments() minDistance = None # Find closest segment pair. for s1 in segs1: samples1 = s1.sample(samples) for s2 in segs2: samples2 = s2.sample(samples) d = min([ p1.squareDistanceFrom(p2) for p1 in samples1 for p2 in samples2 ]) if not minDistance or d < minDistance: minDistance = d closestPair = (s1, s2) c = curveDistance(closestPair[0], closestPair[1]) return (c[0], c[1], c[2], closestPair[0], closestPair[1]) def tidy(self, **kwargs): """Tidies a curve by adding extremes, and then running ``removeIrrelevantSegments`` and ``smooth``. ``relLength``, ``absLength``, ``maxCollectionSize``, ``lengthLimit`` and ``cornerTolerance`` parameters are passed to the relevant routine.""" self.addExtremes() self.removeIrrelevantSegments(**{ k: v for k, v in kwargs.items() if k in ["relLength", "absLength"] }) self.smooth( **{ k: v for k, v in kwargs.items() if k in ["maxCollectionSize", "lengthLimit", "cornerTolerance"] }) def removeIrrelevantSegments(self, relLength=1 / 50000, absLength=0): """Removes small and collinear line segments. Collinear line segments are adjacent line segments which are heading in the same direction, and hence can be collapsed into a single segment. Small segments (those less than ``absLength`` units, or less than ``relLength`` as a fraction of the path's total length) are removed entirely.""" segs = self.asSegments() newsegs = [segs[0]] smallLength = self.length * relLength for i in range(1, len(segs)): prev = newsegs[-1] this = segs[i] if this.length < smallLength or this.length < absLength: this[0] = prev[0] newsegs[-1] = this continue if len(prev) == 2 and len(this) == 2: if math.isclose( prev.tangentAtTime(0).angle, this.tangentAtTime(0).angle): this[0] = prev[0] newsegs[-1] = this continue newsegs.append(this) self.activeRepresentation = SegmentRepresentation(self, newsegs) return self def smooth(self, maxCollectionSize=30, lengthLimit=20, cornerTolerance=10): """Smooths a curve, by collating lists of small (at most ``lengthLimit`` units long) segments at most ``maxCollectionSize`` segments at a time, and running them through a curve fitting algorithm. The list collation also stops when one segment turns more than ``cornerTolerance`` degrees away from the previous one, so that corners are not smoothed.""" smallLineLength = lengthLimit segs = self.asSegments() i = 0 collection = [] while i < len(segs): s = segs[i] if s.length < smallLineLength and len( collection) <= maxCollectionSize: collection.append(s) else: corner = False if len(collection) > 1: last = collection[-1] if abs( last.tangentAtTime(1).angle - s.tangentAtTime(0).angle) > math.radians( cornerTolerance): corner = True if len(collection ) > maxCollectionSize or corner or i == len(segs) - 2: points = [x.start for x in collection] bp = BezierPath.fromPoints(points) if len(bp.asSegments()) > 0: segs[i - len(collection):i] = bp.asSegments() i -= len(collection) collection = [] i += 1 if len(collection) > 0: points = [x.start for x in collection] bp = BezierPath.fromPoints(points) if len(bp.asSegments()) > 0: segs[i - (1 + len(collection)):i - 1] = bp.asSegments() self.activeRepresentation = SegmentRepresentation(self, segs) return self