diff --git a/beziers/affinetransformation.py b/beziers/affinetransformation.py index 44dc48a..0dde73f 100644 --- a/beziers/affinetransformation.py +++ b/beziers/affinetransformation.py @@ -1,8 +1,13 @@ import math +from typing import Optional + +from beziers.point import Point from beziers.utils import isclose class AffineTransformation(object): + """A 2D affine transformation represented as a 3x3 matrix.""" + def __init__(self, matrix=None): if not matrix: self.matrix = [[1, 0, 0], [0, 1, 0], [0, 0, 1]] @@ -23,7 +28,8 @@ def __str__(self): m[2][2], ) - def apply(self, other): + def apply(self, other: "AffineTransformation") -> None: + """Modify this transformation to have the effect of self x other.""" m1 = self.matrix m2 = other.matrix self.matrix = [ @@ -44,7 +50,8 @@ def apply(self, other): ], ] - def apply_backwards(self, other): + def apply_backwards(self, other: "AffineTransformation") -> None: + """Modify this transformation to have the effect of other x self.""" m2 = self.matrix m1 = other.matrix self.matrix = [ @@ -66,30 +73,39 @@ def apply_backwards(self, other): ] @classmethod - def translation(klass, vector): + def translation(klass, vector: Point) -> "AffineTransformation": + """Create a transformation that translates by the given vector.""" return klass([[1, 0, vector.x], [0, 1, vector.y], [0, 0, 1]]) - def translate(self, vector): + def translate(self, vector: Point): + """Modify this transformation to include a translation by the given vector.""" self.apply_backwards(type(self).translation(vector)) @classmethod - def scaling(klass, factorX, factorY=None): - if not factorY: - factorY = factorX - return klass([[factorX, 0, 0], [0, factorY, 0], [0, 0, 1]]) + def scaling( + klass, factor_x: float, factor_y: Optional[float] = None + ) -> "AffineTransformation": + """Create a transformation that scales by the given factor(s).""" + if not factor_y: + factor_y = factor_x + return klass([[factor_x, 0, 0], [0, factor_y, 0], [0, 0, 1]]) - def scale(self, factorX, factorY=None): - self.apply_backwards(type(self).scaling(factorX, factorY)) + def scale(self, factor_x: float, factor_y: Optional[float] = None) -> None: + """Modify this transformation to include a scaling by the given factor(s).""" + self.apply_backwards(type(self).scaling(factor_x, factor_y)) @classmethod - def reflection(klass): + def reflection(klass) -> "AffineTransformation": + """Create a transformation that reflects across the x-axis.""" return klass([[-1, 0, 0], [0, 1, 0], [0, 0, 1]]) - def reflect(self): + def reflect(self) -> None: + """Modify this transformation to include a reflection across the x-axis.""" self.apply_backwards(type(self).reflection) @classmethod - def rotation(klass, angle): + def rotation(klass, angle: float) -> "AffineTransformation": + """Create a transformation that rotates by the given angle (in radians).""" return klass( [ [math.cos(-angle), math.sin(-angle), 0], @@ -98,10 +114,12 @@ def rotation(klass, angle): ] ) - def rotate(self, angle): + def rotate(self, angle: float): + """Modify this transformation to include a rotation by the given angle (in radians).""" self.apply_backwards(type(self).rotation(angle)) - def invert(self): + def invert(self) -> None: + """Modify this transformation to be its inverse.""" m = self.matrix det = ( m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) diff --git a/beziers/boundingbox.py b/beziers/boundingbox.py index 9a75ecc..8ffb607 100644 --- a/beziers/boundingbox.py +++ b/beziers/boundingbox.py @@ -1,64 +1,74 @@ +from typing import Protocol, Union + from beziers.point import Point +class SupportsBounds(Protocol): + def bounds(self) -> "BoundingBox": ... + + class BoundingBox: """A representation of a rectangle within the Beziers world, - used to store bounding boxes.""" + used to store bounding boxes. + + Args: + bl (Point): The bottom-left corner of the bounding box. + tr (Point): The top-right corner of the bounding box. + """ def __init__(self): self.bl = None self.tr = None def __str__(self): - return "BB[%s -> %s]" % (self.bl, self.tr) - - """Determine the bounding box - returns the bounding box itself.""" + return f"BB[{self.bl} -> {self.tr}]" - def bounds(self): + def bounds(self) -> "BoundingBox": + """Determine the bounding box - returns the bounding box itself.""" return self @property - def area(self): + def area(self) -> float: """Returns the area of the bounding box.""" vec = self.tr - self.bl return vec.x * vec.y @property - def left(self): + def left(self) -> float: """Returns the X coordinate of the left edge of the box.""" return self.bl.x @property - def right(self): + def right(self) -> float: """Returns the X coordinate of the right edge of the box.""" return self.tr.x @property - def top(self): + def top(self) -> float: """Returns the Y coordinate of the top edge of the box.""" return self.tr.y @property - def bottom(self): + def bottom(self) -> float: """Returns the Y coordinate of the bottom edge of the box.""" return self.bl.y @property - def width(self): + def width(self) -> float: """Returns the width of the box.""" return self.tr.x - self.bl.x @property - def height(self): + def height(self) -> float: """Returns the height of the box.""" return self.tr.y - self.bl.y @property - def centroid(self): + def centroid(self) -> Point: """Returns a `Point` representing the centroid of the box.""" return Point((self.left + self.right) * 0.5, (self.top + self.bottom) * 0.5) - def extend(self, other): + def extend(self, other: Union["BoundingBox", Point, SupportsBounds]) -> None: """Add an object to the bounding box. Object can be a `Point`, another `BoundingBox`, or something which has a `bounds()` method.""" if isinstance(other, Point): @@ -81,14 +91,14 @@ def extend(self, other): # Try getting its bb self.extend(other.bounds()) - def translated(self, point): + def translated(self, point: Point) -> "BoundingBox": """Returns a new BoundingBox translated by the vector""" bb2 = BoundingBox() bb2.bl = self.bl + point bb2.tr = self.tr + point return bb2 - def includes(self, point): + def includes(self, point: Point) -> bool: """Returns True if the point is included in this bounding box.""" return ( self.bl.x >= point.x @@ -97,7 +107,7 @@ def includes(self, point): and self.tr.y <= point.y ) - def overlaps(self, other): + def overlaps(self, other: "BoundingBox") -> bool: """Returns True if the given bounding box overlaps with this bounding box.""" if other.left > self.right: return False @@ -109,7 +119,7 @@ def overlaps(self, other): return False return True - def addMargin(self, size): + def addMargin(self, size: float) -> None: """Adds a few units of margin around the edges of the bounding box.""" self.bl = self.bl + Point(-size, -size) self.tr = self.tr + Point(size, size) diff --git a/beziers/cubicbezier.py b/beziers/cubicbezier.py index 0cb039c..f973c4a 100644 --- a/beziers/cubicbezier.py +++ b/beziers/cubicbezier.py @@ -1,16 +1,26 @@ -from beziers.segment import Segment +import math +from typing import List, Tuple + from beziers.line import Line from beziers.point import Point from beziers.quadraticbezier import QuadraticBezier -from beziers.utils.arclengthmixin import ArcLengthMixin - -import math -from beziers.utils.legendregauss import Tvalues, Cvalues +from beziers.segment import Segment from beziers.utils import quadraticRoots +from beziers.utils.arclengthmixin import ArcLengthMixin class CubicBezier(ArcLengthMixin, Segment): - def __init__(self, start, c1, c2, end): + """A representation of a cubic bezier curve.""" + + def __init__(self, start: Point, c1: Point, c2: Point, end: Point): + """Create a new cubic bezier curve. + + Args: + start (Point): The starting point of the curve. + c1 (Point): The first control point. + c2 (Point): The second control point. + end (Point): The ending point of the curve. + """ self.points = [start, c1, c2, end] self._range = [0, 1] @@ -18,7 +28,8 @@ def __repr__(self): return "B<%s-%s-%s-%s>" % (self[0], self[1], self[2], self[3]) @classmethod - def fromRepr(klass, text): + def fromRepr(klass, text: str): + """Create a new cubic bezier curve from a string representation.""" import re p = re.compile("^B<(<.*?>)-(<.*?>)-(<.*?>)-(<.*?>)>$") @@ -26,7 +37,7 @@ def fromRepr(klass, text): points = [Point.fromRepr(m.group(t)) for t in range(1, 5)] return klass(*points) - def pointAtTime(self, t): + def pointAtTime(self, t: float) -> Point: """Returns the point at time t (0->1) along the curve.""" x = ( (1 - t) * (1 - t) * (1 - t) * self[0].x @@ -42,7 +53,8 @@ def pointAtTime(self, t): ) return Point(x, y) - def tOfPoint(self, p): + def tOfPoint(self, p: Point) -> float: + """Returns the time t (0->1) of a point on the curve.""" precision = 1.0 / 50.0 bestDist = float("inf") bestT = -1 @@ -70,7 +82,7 @@ def tOfPoint(self, p): bestDist = rdist return bestT - def splitAtTime(self, t): + def splitAtTime(self, t: float) -> Tuple["CubicBezier", "CubicBezier"]: """Returns two segments, dividing the given segment at a point t (0->1) along the curve.""" p4 = self[0].lerp(self[1], t) p5 = self[1].lerp(self[2], t) @@ -82,19 +94,24 @@ def splitAtTime(self, t): def join(self, other): """Not currently implemented: join two `CubicBezier` together.""" - raise "Not implemented" + raise NotImplementedError def toQuadratic(self): """Not currently implemented: reduce this to a `QuadraticBezier`.""" - raise "Not implemented" + raise NotImplementedError - def derivative(self): + def derivative(self) -> QuadraticBezier: """Returns a `QuadraticBezier` representing the derivative of this curve.""" return QuadraticBezier( (self[1] - self[0]) * 3, (self[2] - self[1]) * 3, (self[3] - self[2]) * 3 ) - def flatten(self, degree=8): + def flatten(self, degree=8) -> List[Line]: + """Flattens the curve into a list of `Line` segments. + + Args: + degree (int): The degree of flattening to perform. + """ ss = [] if self.length < degree: return [Line(self[0], self[3])] @@ -105,7 +122,7 @@ def flatten(self, degree=8): ss.append(l) return ss - def _findRoots(self, dimension): + def _findRoots(self, dimension: str) -> List[float]: def cuberoot(v): if v < 0: return -math.pow(-v, 1 / 3.0) @@ -163,7 +180,7 @@ def cuberoot(v): root1 = u1 - v1 - a / 3 return [x for x in [root1] if x >= 0 and x <= 1] - def _findDRoots(self): + def _findDRoots(self) -> List[float]: d = self.derivative() roots = [] @@ -177,7 +194,7 @@ def _findDRoots(self): ) return roots - def findExtremes(self, inflections=False): + def findExtremes(self, inflections=False) -> List[float]: """Returns a list of time `t` values for extremes of the curve.""" r = self._findDRoots() if inflections: @@ -185,8 +202,8 @@ def findExtremes(self, inflections=False): r.sort() return [root for root in r if root >= 0.01 and root <= 0.99] - def curvatureAtTime(self, t): - """Returns the C curvature at time `t`..""" + def curvatureAtTime(self, t: float) -> float: + """Returns the C curvature at time `t`.""" d = self.derivative() d2 = d.derivative() return ( @@ -195,7 +212,7 @@ def curvatureAtTime(self, t): ) @property - def tunniPoint(self): + def tunniPoint(self) -> Point: """Returns the Tunni point of this Bezier (the intersection of the handles).""" h1 = Line(self[0], self[1]) @@ -209,7 +226,7 @@ def tunniPoint(self): else: return i - def balance(self): + def balance(self) -> None: """Perform Tunni balancing on this Bezier.""" p = self.tunniPoint if not p: @@ -228,7 +245,8 @@ def balance(self): self[2] = self[3].lerp(p, avg) @property - def hasLoop(self): + def hasLoop(self) -> bool: + """Returns True if the curve has a loop.""" a1 = ( self[0].x * (self[3].y - self[2].y) + self[0].y * (self[2].x - self[3].x) @@ -265,8 +283,8 @@ def hasLoop(self): return ((d2 + f1) / f2, (d2 - f1) / f2) @property - def area(self): - """Returns the signed rea between the curve and the y-axis""" + def area(self) -> float: + """Returns the signed area between the curve and the y-axis""" return ( 10 * (self[3].x * self[3].y - self[0].x * self[0].y) + 6 diff --git a/beziers/line.py b/beziers/line.py index 808b487..b264f3d 100644 --- a/beziers/line.py +++ b/beziers/line.py @@ -1,15 +1,22 @@ -from beziers.segment import Segment -from beziers.point import Point -from beziers.utils import isclose - import math import sys +from typing import List, Tuple + +from beziers.point import Point +from beziers.segment import Segment +from beziers.utils import isclose class Line(Segment): """Represents a line segment within a Bezier path.""" - def __init__(self, start, end): + def __init__(self, start: Point, end: Point): + """Create a new line segment. + + Args: + start (Point): The starting point of the line. + end (Point): The ending point of the line. + """ self.points = [start, end] self._orig = None @@ -17,39 +24,41 @@ def __repr__(self): return "L<%s--%s>" % (self.points[0], self.points[1]) @classmethod - def fromRepr(klass, text): + def fromRepr(klass, text: str) -> "Line": + """Create a new line segment from a string representation.""" import re p = re.compile("^L<(<.*?>)--(<.*?>)>$") m = p.match(text) return klass(Point.fromRepr(m.group(1)), Point.fromRepr(m.group(2))) - def pointAtTime(self, t): + def pointAtTime(self, t: float) -> Point: """Returns the point at time t (0->1) along the line.""" return self.start.lerp(self.end, t) # XXX One of these is wrong - def tangentAtTime(self, t): + def tangentAtTime(self, t: float) -> Point: """Returns the tangent at time t (0->1) along the line.""" return Point.fromAngle( math.atan2(self.end.y - self.start.y, self.end.x - self.start.x) ) - def normalAtTime(self, t): + def normalAtTime(self, t: float) -> Point: """Returns the normal at time t (0->1) along the line.""" return self.tangentAtTime(t).rotated(Point(0, 0), math.pi / 2) - def curvatureAtTime(self, t): + def curvatureAtTime(self, _t: float) -> float: + """Returns the C curvature at time `t`.""" return sys.float_info.epsilon # Avoid divide-by-zero - def splitAtTime(self, t): + def splitAtTime(self, t: float) -> Tuple["Line", "Line"]: """Returns two segments, dividing the given segment at a point t (0->1) along the line.""" return ( Line(self.start, self.pointAtTime(t)), Line(self.pointAtTime(t), self.end), ) - def _findRoots(self, dimension): + def _findRoots(self, dimension: str) -> List[float]: if dimension == "x": t = ( self[0].x / (self[0].x - self[1].x) @@ -68,7 +77,7 @@ def _findRoots(self, dimension): return [t] return [] - def tOfPoint(self, point, its_on_the_line_i_swear=False): + def tOfPoint(self, point: Point, its_on_the_line_i_swear=False) -> float: """Returns the t (0->1) value of the given point, assuming it lies on the line, or -1 if it does not.""" # Just find one and hope the other fits # point = self.start * (1-t) + self.end * t @@ -83,27 +92,32 @@ def tOfPoint(self, point, its_on_the_line_i_swear=False): return t return -1 - def flatten(self, degree=8): + def flatten(self, _degree=8) -> List["Line"]: return [self] @property - def slope(self): + def slope(self) -> float: + """Returns the slope of the line.""" v = self[1] - self[0] if v.x == 0: return 0 return v.y / v.x @property - def intercept(self): + def intercept(self) -> float: + """Returns the y-intercept of the line.""" return self[1].y - self.slope * self[1].x @property - def length(self): + def length(self) -> float: + """Returns the length of the line.""" return self[0].distanceFrom(self[1]) - def findExtremes(self): + def findExtremes(self) -> List[Point]: + """Returns the extrema of the line.""" return [] @property - def area(self): + def area(self) -> float: + """Returns the signed area of the line.""" return 0.5 * (self[1].x - self[0].x) * (self[0].y + self[1].y) diff --git a/beziers/path/__init__.py b/beziers/path/__init__.py index 5a91dee..7bf6eb7 100644 --- a/beziers/path/__init__.py +++ b/beziers/path/__init__.py @@ -1,14 +1,15 @@ +import math +from typing import Iterator, List, Optional, Tuple + +from beziers.boundingbox import BoundingBox +from beziers.cubicbezier import CubicBezier +from beziers.line import Line +from beziers.path.representations.Nodelist import Node, NodelistRepresentation from beziers.path.representations.Segment import SegmentRepresentation -from beziers.path.representations.Nodelist import NodelistRepresentation, Node from beziers.point import Point -from beziers.boundingbox import BoundingBox -from beziers.utils.samplemixin import SampleMixin -from beziers.utils.booleanoperationsmixin import BooleanOperationsMixin from beziers.segment import Segment -from beziers.line import Line -from beziers.cubicbezier import CubicBezier - -import math +from beziers.utils.booleanoperationsmixin import BooleanOperationsMixin +from beziers.utils.samplemixin import SampleMixin if not hasattr(math, "isclose"): @@ -83,7 +84,7 @@ def fromPoints(self, points, error=50.0, cornerTolerance=20.0, maxSegments=20): return path @classmethod - def fromSegments(klass, array): + def fromSegments(klass, array: List[Segment]): """Construct a path from an array of Segment objects.""" self = klass() for a in array: @@ -92,7 +93,7 @@ def fromSegments(klass, array): return self @classmethod - def fromNodelist(klass, array, closed=True): + def fromNodelist(klass, array: List[Node], closed=True): """Construct a path from an array of Node objects.""" self = klass() for a in array: @@ -103,7 +104,7 @@ def fromNodelist(klass, array, closed=True): return self @classmethod - def fromGlyphsLayer(klass, layer): + def fromGlyphsLayer(klass, layer: "GSLayer"): """Returns an *array of BezierPaths* from a Glyphs GSLayer object.""" from beziers.path.representations.GSPath import GSPathRepresentation @@ -134,8 +135,8 @@ def fromFonttoolsGlyph(klass, font, glyphname): glyph.draw(pen) return pen.paths - def asSegments(self): - """Return the path as an array of segments (either Line, CubicBezier, + def asSegments(self) -> List[Segment]: + """Return the path as a list of segments (either Line, CubicBezier, or, if you are exceptionally unlucky, QuadraticBezier objects).""" if not isinstance(self.activeRepresentation, SegmentRepresentation): nl = self.activeRepresentation.toNodelist() @@ -143,15 +144,15 @@ def asSegments(self): self.activeRepresentation = SegmentRepresentation.fromNodelist(self, nl) return self.activeRepresentation.data() - def asNodelist(self): - """Return the path as an array of Node objects.""" + def asNodelist(self) -> List[Node]: + """Return the path as a list 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): + def asSVGPath(self) -> str: """Return the path as a string suitable for a SVG "BezierPath": """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): + def round(self) -> None: """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): + def bounds(self) -> BoundingBox: """Determine the bounding box of the path, returned as a `BoundingBox` object.""" bbox = BoundingBox() @@ -286,7 +286,16 @@ def bounds(self): bbox.extend(seg) return bbox - def splitAtPoints(self, splitlist): + def splitAtPoints(self, splitlist: List[Tuple[Segment, float]]): + """Split the path at the given points. The splitlist is a list of + tuples, each containing a segment and a time (0->1) along that + segment. For instance, to split a path at the midpoint of the first + segment, you would do:: + + path.splitAtPoints([(path.asSegments()[0], 0.5)]) + + """ + def mapx(v, ds): return (v - ds) / (1 - ds) @@ -295,7 +304,7 @@ def mapx(v, ds): # Cluster splitlist by seg newsplitlist = {} for seg, t in splitlist: - if not seg in newsplitlist: + if seg not in newsplitlist: newsplitlist[seg] = [] newsplitlist[seg].append(t) for k in newsplitlist: @@ -316,12 +325,8 @@ def mapx(v, ds): newsegs.append(seg) self.activeRepresentation = SegmentRepresentation(self, newsegs) - def addExtremes(self): + def addExtremes(self) -> "BezierPath": """Add extreme points to the path.""" - - def mapx(v, ds): - return (v - ds) / (1 - ds) - segs = self.asSegments() splitlist = [] for seg in segs: @@ -331,7 +336,7 @@ def mapx(v, ds): return self @property - def length(self): + def length(self) -> float: """Returns the length of the whole path.""" segs = self.asSegments() length = 0 @@ -339,7 +344,7 @@ def length(self): length += s.length return length - def pointAtTime(self, t): + def pointAtTime(self, t: float) -> Point: """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: @@ -348,7 +353,7 @@ def pointAtTime(self, t): seg = segs[int(math.floor(t))] return seg.pointAtTime(t - math.floor(t)) - def lengthAtTime(self, t): + def lengthAtTime(self, t: float) -> float: """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() @@ -361,7 +366,7 @@ def lengthAtTime(self, t): length += s1.length return length - def offset(self, vector, rotateVector=True): + def offset(self, vector: Point, rotateVector=True) -> "BezierPath": """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 @@ -412,7 +417,7 @@ def finishPoints(newsegs, points): newpath.activeRepresentation = SegmentRepresentation(newpath, newsegs) return newpath - def append(self, other, joinType="line"): + def append(self, other: "BezierPath", joinType="line") -> "BezierPath": """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.""" @@ -441,31 +446,31 @@ def append(self, other, joinType="line"): self.activeRepresentation = SegmentRepresentation(self, segs1) return self - def reverse(self): + def reverse(self) -> "BezierPath": """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): + def translate(self, vector: Point) -> "BezierPath": """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): + def rotate(self, about: Point, angle: float) -> "BezierPath": """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): + def scale(self, by: float) -> "BezierPath": """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): + def balance(self) -> None: """Performs Tunni balancing on the path.""" segs = self.asSegments() for x in segs: @@ -476,14 +481,13 @@ def balance(self): def findDiscontinuities(self): """Not implemented yet""" - - def harmonize(self): - """Not implemented yet""" + raise NotImplementedError def roundCorners(self): """Not implemented yet""" + raise NotImplementedError - def dash(self, lineLength=50, gapLength=None): + def dash(self, lineLength=50, gapLength=None) -> List["BezierPath"]: """Returns a list of BezierPath objects created by chopping this path into a dashed line:: @@ -511,7 +515,8 @@ def dash(self, lineLength=50, gapLength=None): points.append(self.pointAtTime(t)) return newpaths - def segpairs(self): + def segpairs(self) -> Iterator[Tuple[Segment, Segment]]: + """Returns an iterator of pairs of segments.""" segs = self.asSegments() for i in range(0, len(segs) - 1): yield (segs[i], segs[i + 1]) @@ -533,13 +538,15 @@ def harmonize(self, seg1, seg2): seg1[2] += fixup seg2[1] += fixup - def flatten(self, degree=8): + def flatten(self, degree=8) -> "BezierPath": + """Returns a Path made up of line segments that approximate the path.""" segs = [] for s in self.asSegments(): segs.extend(s.flatten(degree)) return BezierPath.fromSegments(segs) - def windingNumberOfPoint(self, pt): + def windingNumberOfPoint(self, pt: Point) -> int: + """Returns the winding number of a point with respect to the path.""" bounds = self.bounds() bounds.addMargin(10) ray1 = Line(Point(bounds.left, pt.y), pt) @@ -571,7 +578,7 @@ def windingNumberOfPoint(self, pt): # print("Left winding: %i right winding: %i " % (leftWinding,rightWinding)) return max(abs(leftWinding), abs(rightWinding)) - def pointIsInside(self, pt): + def pointIsInside(self, pt: Point) -> bool: """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.""" @@ -579,7 +586,7 @@ def pointIsInside(self, pt): return li % 2 == 1 @property - def signed_area(self): + def signed_area(self) -> float: """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.""" @@ -591,24 +598,27 @@ def signed_area(self): return area @property - def area(self): + def area(self) -> float: """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): + def direction(self) -> int: """Returns the direction of the path: -1 for clockwise and 1 for counterclockwise.""" return math.copysign(1, self.signed_area) @property - def centroid(self): + def centroid(self) -> Optional[Point]: + """Returns the centroid of the path's bounding box, or + None if the path is open. + """ if not self.closed: return None return self.bounds().centroid # Really? - def drawWithBrush(self, other): + def drawWithBrush(self, other: "BezierPath") -> List["BezierPath"]: """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. @@ -663,7 +673,7 @@ def pairwise(iterable): return paths - def quadraticsToCubics(self): + def quadraticsToCubics(self) -> None: """Converts all quadratic segments in the path to cubic Beziers.""" segs = self.asSegments() for i, s in enumerate(segs): @@ -671,7 +681,7 @@ def quadraticsToCubics(self): segs[i] = s.toCubicBezier() return self - def thicknessAtX(path, x): + def thicknessAtX(path, x: float) -> Optional[float]: """Returns the thickness of the path at x-coordinate ``x``.""" bounds = path.bounds() bounds.addMargin(10) @@ -706,7 +716,7 @@ def thicknessAtX(path, x): # # Find the tangent at that time # inorm2 = i2.seg1.normalAtTime(i2.t1) - def distanceToPath(self, other, samples=10): + def distanceToPath(self, other: "BezierPath", samples=10) -> float: """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. @@ -731,7 +741,7 @@ def distanceToPath(self, other, samples=10): c = curveDistance(closestPair[0], closestPair[1]) return (c[0], c[1], c[2], closestPair[0], closestPair[1]) - def tidy(self, **kwargs): + def tidy(self, **kwargs) -> None: """Tidies a curve by adding extremes, and then running ``removeIrrelevantSegments`` and ``smooth``. ``relLength``, ``absLength``, ``maxCollectionSize``, ``lengthLimit`` and diff --git a/beziers/path/geometricshapes.py b/beziers/path/geometricshapes.py index e9394c4..1bf44cb 100644 --- a/beziers/path/geometricshapes.py +++ b/beziers/path/geometricshapes.py @@ -1,8 +1,9 @@ -from beziers.point import Point -from beziers.path import BezierPath +import math + from beziers.cubicbezier import CubicBezier from beziers.line import Line -import math +from beziers.path import BezierPath +from beziers.point import Point CIRCULAR_SUPERNESS = 4.0 / 3.0 * (math.sqrt(2) - 1) diff --git a/beziers/path/representations/Segment.py b/beziers/path/representations/Segment.py index b21f32a..3d3ffed 100644 --- a/beziers/path/representations/Segment.py +++ b/beziers/path/representations/Segment.py @@ -1,8 +1,8 @@ -from beziers.path.representations.Nodelist import Node -from beziers.line import Line from beziers.cubicbezier import CubicBezier -from beziers.quadraticbezier import QuadraticBezier +from beziers.line import Line +from beziers.path.representations.Nodelist import Node from beziers.point import Point +from beziers.quadraticbezier import QuadraticBezier from beziers.utils import isclose diff --git a/beziers/path/representations/fontparts.py b/beziers/path/representations/fontparts.py index 3cd9fc6..cde1f14 100644 --- a/beziers/path/representations/fontparts.py +++ b/beziers/path/representations/fontparts.py @@ -1,7 +1,7 @@ -from beziers.line import Line from beziers.cubicbezier import CubicBezier +from beziers.line import Line from beziers.path import BezierPath -from beziers.path.representations.Nodelist import NodelistRepresentation, Node +from beziers.path.representations.Nodelist import Node, NodelistRepresentation class FontParts: diff --git a/beziers/point.py b/beziers/point.py index ecb30a5..88b49d4 100644 --- a/beziers/point.py +++ b/beziers/point.py @@ -40,7 +40,8 @@ def __repr__(self): return "<%s,%s>" % (self.x, self.y) @classmethod - def fromRepr(klass, text): + def fromRepr(klass, text: str) -> "Point": + """Create a new point from a string representation.""" import re p = re.compile("^<([^,]+),([^>]+)>$") @@ -57,10 +58,11 @@ def __hash__(self): return hash(self.x) << 32 ^ hash(self.y) def __mul__(self, other): - """Multiply a point by a scalar.""" + """Multiply a point (assumed to represent a vector) by a scalar.""" return Point(self.x * other, self.y * other) def __div__(self, other): + """Divide a point (assumed to represent a vector) by a scalar.""" return Point(self.x / other, self.y / other) def __truediv__(self, other): @@ -85,32 +87,33 @@ def __isub__(self, other): def __matmul__(self, other): # Dot product. Abusing overloading. Sue me. return self.dot(other) - def dot(self, other): + def dot(self, other: "Point") -> float: + """Compute the dot product of two points, interpreted as vectors.""" return self.x * other.x + self.y * other.y - def clone(self): + def clone(self) -> "Point": """Clone a point, returning a new object with the same co-ordinates.""" return Point(self.x, self.y) - def rounded(self): + def rounded(self) -> "Point": """Return a point with the co-ordinates truncated to integers""" return Point(int(self.x), int(self.y)) - def lerp(self, other, t): + def lerp(self, other: "Point", t: float) -> "Point": """Interpolate between two points, at time t.""" return self * (1 - t) + other * (t) @property - def squareMagnitude(self): + def squareMagnitude(self) -> float: """Interpreting this point as a vector, returns the squared magnitude (Euclidean length) of the vector.""" return self.x * self.x + self.y * self.y @property - def magnitude(self): + def magnitude(self) -> float: """Interpreting this point as a vector, returns the magnitude (Euclidean length) of the vector.""" return math.sqrt(self.squareMagnitude) - def toUnitVector(self): + def toUnitVector(self) -> "Point": """Divides this point by its magnitude, returning a vector of length 1.""" mag = self.magnitude if mag == 0.0: @@ -118,23 +121,23 @@ def toUnitVector(self): return Point(self.x / mag, self.y / mag) @property - def angle(self): + def angle(self) -> float: """Interpreting this point as a vector, returns the angle in radians of the vector.""" return math.atan2(self.y, self.x) @property - def slope(self): + def slope(self) -> float: """Returns slope y/x""" if self.x == 0: return 0 return self.y / self.x @classmethod - def fromAngle(self, angle): + def fromAngle(self, angle: float) -> "Point": """Given an angle in radians, return a unit vector representing that angle.""" return Point(math.cos(angle), math.sin(angle)).toUnitVector() - def rotated(self, around, by): + def rotated(self, around: "Point", by: float) -> "Point": """Return a new point found by rotating this point around another point, by an angle given in radians.""" delta = around - self oldangle = delta.angle @@ -143,23 +146,24 @@ def rotated(self, around, by): new = around - unitvector * delta.magnitude return new - def rotate(self, around, by): + def rotate(self, around: "Point", by: float): """Mutate this point by rotating it around another point, by an angle given in radians.""" new = self.rotated(around, by) self.x = new.x self.y = new.y - def squareDistanceFrom(self, other): + def squareDistanceFrom(self, other: "Point") -> float: """Returns the squared Euclidean distance between this point and another.""" return (self.x - other.x) * (self.x - other.x) + (self.y - other.y) * ( self.y - other.y ) - def distanceFrom(self, other): + def distanceFrom(self, other: "Point") -> float: """Returns the Euclidean distance between this point and another.""" return math.sqrt(self.squareDistanceFrom(other)) - def transformed(self, transformation): + def transformed(self, transformation: "AffineTransformation") -> "Point": + """Returns a new point, transformed by the given transformation.""" m = transformation.matrix x, y = self.x, self.y a1, a2, b1 = m[0] @@ -168,7 +172,8 @@ def transformed(self, transformation): yPrime = a3 * x + a4 * y + b2 return Point(xPrime, yPrime) - def transform(self, transformation): + def transform(self, transformation: "AffineTransformation"): + """Mutate this point by transforming it by the given transformation.""" new = self.transformed(transformation) self.x = new.x self.y = new.y diff --git a/beziers/quadraticbezier.py b/beziers/quadraticbezier.py index f4df5f0..ad6ba2c 100644 --- a/beziers/quadraticbezier.py +++ b/beziers/quadraticbezier.py @@ -1,7 +1,7 @@ -from beziers.segment import Segment from beziers.line import Line from beziers.point import Point -from beziers.utils import quadraticRoots, isclose +from beziers.segment import Segment +from beziers.utils import quadraticRoots from beziers.utils.arclengthmixin import ArcLengthMixin my_epsilon = 2e-7 diff --git a/beziers/segment.py b/beziers/segment.py index db2de45..e5830ff 100644 --- a/beziers/segment.py +++ b/beziers/segment.py @@ -1,12 +1,11 @@ -from beziers.point import Point from beziers.affinetransformation import AffineTransformation -from beziers.utils.samplemixin import SampleMixin -from beziers.utils.intersectionsmixin import IntersectionsMixin from beziers.boundingbox import BoundingBox +from beziers.point import Point +from beziers.utils.intersectionsmixin import IntersectionsMixin +from beziers.utils.samplemixin import SampleMixin class Segment(IntersectionsMixin, SampleMixin, object): - """A segment is part of a path. Although this package is called `beziers.py`, it's really for font people, and paths in the font world are made up of cubic Bezier curves, lines and (if you're @@ -60,47 +59,50 @@ def __hash__(self): def __ne__(self, other): return not self.__eq__(other) - def clone(self): + def clone(self) -> "Segment": """Returns a new Segment which is a copy of this segment.""" klass = self.__class__ return klass(*[p.clone() for p in self.points]) - def round(self): + def round(self) -> None: """Rounds the points of segment to integer coordinates.""" self.points = [p.rounded() for p in self.points] @property - def order(self): + def order(self) -> int: + """Returns the order of the segment. i.e. 2 for a line, 3 for a quadratic, 4 for a cubic.""" return len(self.points) @property - def start(self): + def start(self) -> Point: """Returns a Point object representing the start of this segment.""" return self.points[0] @property - def end(self): + def end(self) -> Point: """Returns a Point object representing the end of this segment.""" return self.points[-1] @property - def startAngle(self): + def startAngle(self) -> float: + """Returns the angle of the start of the segment in radians.""" return (self.points[1] - self.points[0]).angle @property - def endAngle(self): + def endAngle(self) -> float: + """Returns the angle of the end of the segment in radians.""" return (self.points[-1] - self.points[-2]).angle - def tangentAtTime(self, t): + def tangentAtTime(self, t: float) -> Point: """Returns a `Point` representing the unit vector of tangent at time `t`.""" return self.derivative().pointAtTime(t).toUnitVector() - def normalAtTime(self, t): + def normalAtTime(self, t: float) -> Point: """Returns a `Point` representing the normal (rotated tangent) at time `t`.""" tan = self.tangentAtTime(t) return Point(-tan.y, tan.x) - def translated(self, vector): + def translated(self, vector: Point) -> "Segment": """Returns a *new Segment object* representing the translation of this segment by the given vector. i.e.:: @@ -115,7 +117,7 @@ def translated(self, vector): klass = self.__class__ return klass(*[p + vector for p in self.points]) - def rotated(self, around, by): + def rotated(self, around: Point, by: float): """Returns a *new Segment object* representing the rotation of this segment around the given point and by the given angle. i.e.:: @@ -131,7 +133,7 @@ def rotated(self, around, by): p.rotate(around, by) return klass(*pNew) - def scaled(self, bx): + def scaled(self, bx: float) -> "Segment": """Returns a *new Segment object* representing the scaling of this segment by the given magnification. i.e.:: @@ -145,7 +147,7 @@ def scaled(self, bx): pNew = [p * bx for p in self.points] return klass(*pNew) - def transformed(self, transformation): + def transformed(self, transformation: AffineTransformation) -> "Segment": """Returns a *new Segment object* transformed by the given AffineTransformation matrix.""" klass = self.__class__ pNew = [p.clone() for p in self.points] @@ -153,30 +155,33 @@ def transformed(self, transformation): p.transform(transformation) return klass(*pNew) - def alignmentTransformation(self): + def alignmentTransformation(self) -> AffineTransformation: + """Returns an AffineTransformation object representing the transformation + required to align the segment to the origin. i.e. with the first point + translated to the origin (0,0) and the last point with y=0.""" m = AffineTransformation.translation(self.start * -1) m.rotate((self.end.transformed(m)).angle * -1) return m - def aligned(self): + def aligned(self) -> "Segment": """Returns a *new Segment object* aligned to the origin. i.e. with the first point translated to the origin (0,0) and the last point with y=0. Obviously, for a `Line` this is a bit pointless, but it's quite handy for higher-order curves.""" return self.transformed(self.alignmentTransformation()) - def lengthAtTime(self, t): + def lengthAtTime(self, t: float) -> float: """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.""" s1, _ = self.splitAtTime(t) return s1.length - def reversed(self): + def reversed(self) -> "Segment": """Returns a new segment with the points reversed.""" klass = self.__class__ return klass(*list(reversed(self.points))) - def bounds(self): + def bounds(self) -> BoundingBox: """Returns a BoundingBox object for this segment.""" bounds = BoundingBox() ex = self.findExtremes() @@ -187,6 +192,6 @@ def bounds(self): return bounds @property - def hasLoop(self): + def hasLoop(self) -> bool: """Returns True if the segment has a loop. (Only possible for cubics.)""" return False diff --git a/beziers/utils/alphashape.py b/beziers/utils/alphashape.py index 15c6bdb..e93e43c 100644 --- a/beziers/utils/alphashape.py +++ b/beziers/utils/alphashape.py @@ -1,8 +1,7 @@ -from shapely.ops import cascaded_union, polygonize -from scipy.spatial import Delaunay -import shapely.geometry as geometry import numpy as np -import math +import shapely.geometry as geometry +from scipy.spatial import Delaunay +from shapely.ops import cascaded_union, polygonize def alpha_shape(points, alpha): diff --git a/beziers/utils/arclengthmixin.py b/beziers/utils/arclengthmixin.py index 2e9a78e..9e14c06 100644 --- a/beziers/utils/arclengthmixin.py +++ b/beziers/utils/arclengthmixin.py @@ -1,6 +1,7 @@ -from beziers.utils.legendregauss import Tvalues, Cvalues import math +from beziers.utils.legendregauss import Cvalues, Tvalues + class ArcLengthMixin: @property diff --git a/beziers/utils/booleanoperationsmixin.py b/beziers/utils/booleanoperationsmixin.py index 7a5d211..85aa080 100644 --- a/beziers/utils/booleanoperationsmixin.py +++ b/beziers/utils/booleanoperationsmixin.py @@ -1,10 +1,11 @@ -import sys -from beziers.path.representations.Segment import SegmentRepresentation -from beziers.utils.intersectionsmixin import Intersection import logging + import pyclipper + from beziers.line import Line +from beziers.path.representations.Segment import SegmentRepresentation from beziers.point import Point +from beziers.utils.intersectionsmixin import Intersection class BooleanOperationsMixin: diff --git a/beziers/utils/curvedistance.py b/beziers/utils/curvedistance.py index 21ca4be..dfb88ba 100644 --- a/beziers/utils/curvedistance.py +++ b/beziers/utils/curvedistance.py @@ -1,8 +1,8 @@ +import math + from beziers.cubicbezier import CubicBezier -from beziers.quadraticbezier import QuadraticBezier from beziers.line import Line from beziers.point import Point -import math """ This implements (possibly badly) the algorithm in "Computing the diff --git a/beziers/utils/curvefitter.py b/beziers/utils/curvefitter.py index a266d6a..b690f59 100644 --- a/beziers/utils/curvefitter.py +++ b/beziers/utils/curvefitter.py @@ -1,7 +1,8 @@ -from beziers.point import Point -from beziers.cubicbezier import CubicBezier -import sys import math +import sys + +from beziers.cubicbezier import CubicBezier +from beziers.point import Point def B0(u): diff --git a/beziers/utils/intersectionsmixin.py b/beziers/utils/intersectionsmixin.py index 7afc57f..26ad1a6 100644 --- a/beziers/utils/intersectionsmixin.py +++ b/beziers/utils/intersectionsmixin.py @@ -1,7 +1,7 @@ -import sys +from decimal import Decimal + from beziers.point import Point from beziers.utils import isclose -from decimal import Decimal my_epsilon = 2e-7 diff --git a/beziers/utils/pens.py b/beziers/utils/pens.py index 6201521..e06465e 100644 --- a/beziers/utils/pens.py +++ b/beziers/utils/pens.py @@ -1,6 +1,7 @@ from fontTools.pens.basePen import BasePen -from beziers.path.representations.Nodelist import NodelistRepresentation, Node + from beziers.path import BezierPath +from beziers.path.representations.Nodelist import Node, NodelistRepresentation class BezierPathCreatingPen(BasePen):