"""This submodule contains the class definitions of the the main five classes svgpathtools is built around: Path, Line, QuadraticBezier, CubicBezier, and Arc.""" # External dependencies from __future__ import division, absolute_import, print_function from math import sqrt, cos, sin, acos, asin, degrees, radians, log, pi, ceil from cmath import exp, sqrt as csqrt, phase try: from collections.abc import MutableSequence # noqa except ImportError: from collections import MutableSequence # noqa from warnings import warn from operator import itemgetter import numpy as np try: from scipy.integrate import quad _quad_available = True except: _quad_available = False # Internal dependencies from .bezier import (bezier_intersections, bezier_bounding_box, split_bezier, bezier_by_line_intersections, polynomial2bezier, bezier2polynomial) from .misctools import BugException from .polytools import rational_limit, polyroots, polyroots01, imag, real # Default Parameters ########################################################## # path segment .length() parameters for arc length computation LENGTH_MIN_DEPTH = 5 LENGTH_ERROR = 1e-12 USE_SCIPY_QUAD = True # for elliptic Arc segment arc length computation # path segment .ilength() parameters for inverse arc length computation ILENGTH_MIN_DEPTH = 5 ILENGTH_ERROR = 1e-12 ILENGTH_S_TOL = 1e-12 ILENGTH_MAXITS = 10000 # compatibility/implementation related warnings and parameters CLOSED_WARNING_ON = True _NotImplemented4ArcException = \ Exception("This method has not yet been implemented for Arc objects.") # _NotImplemented4QuadraticException = \ # Exception("This method has not yet been implemented for QuadraticBezier " # "objects.") _is_smooth_from_warning = \ ("The name of this method is somewhat misleading (yet kept for " "compatibility with scripts created using svg.path 2.0). This method " "is meant only for d-string creation and should NOT be used to check " "for kinks. To check a segment for differentiability, use the " "joins_smoothly_with() method instead or the kinks() function (in " "smoothing.py).\nTo turn off this warning, set " "warning_on=False.") # Miscellaneous ############################################################### def bezier_segment(*bpoints): if len(bpoints) == 2: return Line(*bpoints) elif len(bpoints) == 4: return CubicBezier(*bpoints) elif len(bpoints) == 3: return QuadraticBezier(*bpoints) else: assert len(bpoints) in (2, 3, 4) def is_bezier_segment(seg): return (isinstance(seg, Line) or isinstance(seg, QuadraticBezier) or isinstance(seg, CubicBezier)) def is_path_segment(seg): return is_bezier_segment(seg) or isinstance(seg, Arc) def is_bezier_path(path): """Checks that all segments in path are a Line, QuadraticBezier, or CubicBezier object.""" return isinstance(path, Path) and all(map(is_bezier_segment, path)) def concatpaths(list_of_paths): """Takes in a sequence of paths and returns their concatenations into a single path (following the order of the input sequence).""" return Path(*[seg for path in list_of_paths for seg in path]) def bbox2path(xmin, xmax, ymin, ymax): """Converts a bounding box 4-tuple to a Path object.""" b = Line(xmin + 1j*ymin, xmax + 1j*ymin) t = Line(xmin + 1j*ymax, xmax + 1j*ymax) r = Line(xmax + 1j*ymin, xmax + 1j*ymax) l = Line(xmin + 1j*ymin, xmin + 1j*ymax) return Path(b, r, t.reversed(), l.reversed()) def polyline(*points): """Converts a list of points to a Path composed of lines connecting those points (i.e. a linear spline or polyline). See also `polygon()`.""" return Path(*[Line(points[i], points[i+1]) for i in range(len(points) - 1)]) def polygon(*points): """Converts a list of points to a Path composed of lines connecting those points, then closes the path by connecting the last point to the first. See also `polyline()`.""" return Path(*[Line(points[i], points[(i + 1) % len(points)]) for i in range(len(points))]) # Conversion################################################################### def bpoints2bezier(bpoints): """Converts a list of length 2, 3, or 4 to a CubicBezier, QuadraticBezier, or Line object, respectively. See also: poly2bez.""" order = len(bpoints) - 1 if order == 3: return CubicBezier(*bpoints) elif order == 2: return QuadraticBezier(*bpoints) elif order == 1: return Line(*bpoints) else: assert len(bpoints) in {2, 3, 4} def poly2bez(poly, return_bpoints=False): """Converts a cubic or lower order Polynomial object (or a sequence of coefficients) to a CubicBezier, QuadraticBezier, or Line object as appropriate. If return_bpoints=True then this will instead only return the control points of the corresponding Bezier curve. Note: The inverse operation is available as a method of CubicBezier, QuadraticBezier and Line objects.""" bpoints = polynomial2bezier(poly) if return_bpoints: return bpoints else: return bpoints2bezier(bpoints) def bez2poly(bez, numpy_ordering=True, return_poly1d=False): """Converts a Bezier object or tuple of Bezier control points to a tuple of coefficients of the expanded polynomial. return_poly1d : returns a numpy.poly1d object. This makes computations of derivatives/anti-derivatives and many other operations quite quick. numpy_ordering : By default (to accommodate numpy) the coefficients will be output in reverse standard order. Note: This function is redundant thanks to the .poly() method included with all bezier segment classes.""" if is_bezier_segment(bez): bez = bez.bpoints() return bezier2polynomial(bez, numpy_ordering=numpy_ordering, return_poly1d=return_poly1d) # Geometric#################################################################### def rotate(curve, degs, origin=None): """Returns curve rotated by `degs` degrees (CCW) around the point `origin` (a complex number). By default origin is either `curve.point(0.5)`, or in the case that curve is an Arc object, `origin` defaults to `curve.center`. """ def transform(z): return exp(1j*radians(degs))*(z - origin) + origin if origin is None: if isinstance(curve, Arc): origin = curve.center else: origin = curve.point(0.5) if isinstance(curve, Path): return Path(*[rotate(seg, degs, origin=origin) for seg in curve]) elif is_bezier_segment(curve): return bpoints2bezier([transform(bpt) for bpt in curve.bpoints()]) elif isinstance(curve, Arc): new_start = transform(curve.start) new_end = transform(curve.end) new_rotation = curve.rotation + degs return Arc(new_start, radius=curve.radius, rotation=new_rotation, large_arc=curve.large_arc, sweep=curve.sweep, end=new_end) else: raise TypeError("Input `curve` should be a Path, Line, " "QuadraticBezier, CubicBezier, or Arc object.") def translate(curve, z0): """Shifts the curve by the complex quantity z such that translate(curve, z0).point(t) = curve.point(t) + z0""" if isinstance(curve, Path): return Path(*[translate(seg, z0) for seg in curve]) elif is_bezier_segment(curve): return bpoints2bezier([bpt + z0 for bpt in curve.bpoints()]) elif isinstance(curve, Arc): new_start = curve.start + z0 new_end = curve.end + z0 return Arc(new_start, radius=curve.radius, rotation=curve.rotation, large_arc=curve.large_arc, sweep=curve.sweep, end=new_end) else: raise TypeError("Input `curve` should be a Path, Line, " "QuadraticBezier, CubicBezier, or Arc object.") def scale(curve, sx, sy=None, origin=0j): """Scales `curve`, about `origin`, by diagonal matrix `[[sx,0],[0,sy]]`. Notes: ------ * If `sy` is not specified, it is assumed to be equal to `sx` and a scalar transformation of `curve` about `origin` will be returned. I.e. scale(curve, sx, origin).point(t) == ((curve.point(t) - origin) * sx) + origin """ if sy is None: isy = 1j*sx else: isy = 1j*sy def _scale(z): if sy is None: return sx*z return sx*z.real + isy*z.imag def scale_bezier(bez): p = [_scale(c) for c in bez2poly(bez)] p[-1] += origin - _scale(origin) return poly2bez(p) if isinstance(curve, Path): return Path(*[scale(seg, sx, sy, origin) for seg in curve]) elif is_bezier_segment(curve): return scale_bezier(curve) elif isinstance(curve, Arc): if sy is None or sy == sx: return Arc(start=sx*(curve.start - origin) + origin, radius=sx*curve.radius, rotation=curve.rotation, large_arc=curve.large_arc, sweep=curve.sweep, end=sx*(curve.end - origin) + origin) else: raise Exception("\nFor `Arc` objects, only scale transforms " "with sx==sy are implemented.\n") else: raise TypeError("Input `curve` should be a Path, Line, " "QuadraticBezier, CubicBezier, or Arc object.") def transform(curve, tf): """Transforms the curve by the homogeneous transformation matrix tf""" def to_point(p): return np.array([[p.real], [p.imag], [1.0]]) def to_vector(z): return np.array([[z.real], [z.imag], [0.0]]) def to_complex(v): return v.item(0) + 1j * v.item(1) if isinstance(curve, Path): return Path(*[transform(segment, tf) for segment in curve]) elif is_bezier_segment(curve): return bpoints2bezier([to_complex(tf.dot(to_point(p))) for p in curve.bpoints()]) elif isinstance(curve, Arc): new_start = to_complex(tf.dot(to_point(curve.start))) new_end = to_complex(tf.dot(to_point(curve.end))) new_radius = to_complex(tf.dot(to_vector(curve.radius))) if tf[0][0] * tf[1][1] >= 0.0: new_sweep = curve.sweep else: new_sweep = not curve.sweep return Arc(new_start, radius=new_radius, rotation=curve.rotation, large_arc=curve.large_arc, sweep=new_sweep, end=new_end) else: raise TypeError("Input `curve` should be a Path, Line, " "QuadraticBezier, CubicBezier, or Arc object.") def bezier_unit_tangent(seg, t): """Returns the unit tangent of the segment at t. Notes ----- If you receive a RuntimeWarning, try the following: >>> import numpy >>> old_numpy_error_settings = numpy.seterr(invalid='raise') This can be undone with: >>> numpy.seterr(**old_numpy_error_settings) """ assert 0 <= t <= 1 dseg = seg.derivative(t) # Note: dseg might be numpy value, use np.seterr(invalid='raise') try: unit_tangent = dseg/abs(dseg) except (ZeroDivisionError, FloatingPointError): # This may be a removable singularity, if so we just need to compute # the limit. # Note: limit{{dseg / abs(dseg)} = sqrt(limit{dseg**2 / abs(dseg)**2}) dseg_poly = seg.poly().deriv() dseg_abs_squared_poly = (real(dseg_poly) ** 2 + imag(dseg_poly) ** 2) try: unit_tangent = csqrt(rational_limit(dseg_poly**2, dseg_abs_squared_poly, t)) except ValueError: bef = seg.poly().deriv()(t - 1e-4) aft = seg.poly().deriv()(t + 1e-4) mes = ("Unit tangent appears to not be well-defined at " "t = {}, \n".format(t) + "seg.poly().deriv()(t - 1e-4) = {}\n".format(bef) + "seg.poly().deriv()(t + 1e-4) = {}".format(aft)) raise ValueError(mes) return unit_tangent def segment_curvature(self, t, use_inf=False): """returns the curvature of the segment at t. Notes ----- If you receive a RuntimeWarning, run command >>> old = np.seterr(invalid='raise') This can be undone with >>> np.seterr(**old) """ dz = self.derivative(t) ddz = self.derivative(t, n=2) dx, dy = dz.real, dz.imag ddx, ddy = ddz.real, ddz.imag old_np_seterr = np.seterr(invalid='raise') try: kappa = abs(dx*ddy - dy*ddx)/sqrt(dx*dx + dy*dy)**3 except (ZeroDivisionError, FloatingPointError): # tangent vector is zero at t, use polytools to find limit p = self.poly() dp = p.deriv() ddp = dp.deriv() dx, dy = real(dp), imag(dp) ddx, ddy = real(ddp), imag(ddp) f2 = (dx*ddy - dy*ddx)**2 g2 = (dx*dx + dy*dy)**3 lim2 = rational_limit(f2, g2, t) if lim2 < 0: # impossible, must be numerical error return 0 kappa = sqrt(lim2) finally: np.seterr(**old_np_seterr) return kappa def bezier_radialrange(seg, origin, return_all_global_extrema=False): """returns the tuples (d_min, t_min) and (d_max, t_max) which minimize and maximize, respectively, the distance d = |self.point(t)-origin|. return_all_global_extrema: Multiple such t_min or t_max values can exist. By default, this will only return one. Set return_all_global_extrema=True to return all such global extrema.""" def _radius(tau): return abs(seg.point(tau) - origin) shifted_seg_poly = seg.poly() - origin r_squared = real(shifted_seg_poly) ** 2 + \ imag(shifted_seg_poly) ** 2 extremizers = [0, 1] + polyroots01(r_squared.deriv()) extrema = [(_radius(t), t) for t in extremizers] if return_all_global_extrema: raise NotImplementedError else: seg_global_min = min(extrema, key=itemgetter(0)) seg_global_max = max(extrema, key=itemgetter(0)) return seg_global_min, seg_global_max def closest_point_in_path(pt, path): """returns (|path.seg.point(t)-pt|, t, seg_idx) where t and seg_idx minimize the distance between pt and curve path[idx].point(t) for 0<=t<=1 and any seg_idx. Warning: Multiple such global minima can exist. This will only return one.""" return path.radialrange(pt)[0] def farthest_point_in_path(pt, path): """returns (|path.seg.point(t)-pt|, t, seg_idx) where t and seg_idx maximize the distance between pt and curve path[idx].point(t) for 0<=t<=1 and any seg_idx. :rtype : object :param pt: :param path: Warning: Multiple such global maxima can exist. This will only return one.""" return path.radialrange(pt)[1] def path_encloses_pt(pt, opt, path): """returns true if pt is a point enclosed by path (which must be a Path object satisfying path.isclosed==True). opt is a point you know is NOT enclosed by path.""" assert path.isclosed() intersections = Path(Line(pt, opt)).intersect(path) if len(intersections) % 2: return True else: return False def segment_length(curve, start, end, start_point, end_point, error=LENGTH_ERROR, min_depth=LENGTH_MIN_DEPTH, depth=0): """Recursively approximates the length by straight lines""" mid = (start + end)/2 mid_point = curve.point(mid) length = abs(end_point - start_point) first_half = abs(mid_point - start_point) second_half = abs(end_point - mid_point) length2 = first_half + second_half if (length2 - length > error) or (depth < min_depth): # Calculate the length of each segment: depth += 1 return (segment_length(curve, start, mid, start_point, mid_point, error, min_depth, depth) + segment_length(curve, mid, end, mid_point, end_point, error, min_depth, depth)) # This is accurate enough. return length2 def inv_arclength(curve, s, s_tol=ILENGTH_S_TOL, maxits=ILENGTH_MAXITS, error=ILENGTH_ERROR, min_depth=ILENGTH_MIN_DEPTH): """INPUT: curve should be a CubicBezier, Line, of Path of CubicBezier and/or Line objects. OUTPUT: Returns a float, t, such that the arc length of curve from 0 to t is approximately s. s_tol - exit when |s(t) - s| < s_tol where s(t) = seg.length(0, t, error, min_depth) and seg is either curve or, if curve is a Path object, then seg is a segment in curve. error - used to compute lengths of cubics and arcs min_depth - used to compute lengths of cubics and arcs Note: This function is not designed to be efficient, but if it's slower than you need, make sure you have scipy installed.""" curve_length = curve.length(error=error, min_depth=min_depth) assert curve_length > 0 if not 0 <= s <= curve_length: raise ValueError("s is not in interval [0, curve.length()].") if s == 0: return 0 if s == curve_length: return 1 if isinstance(curve, Path): seg_lengths = [seg.length(error=error, min_depth=min_depth) for seg in curve] lsum = 0 # Find which segment the point we search for is located on for k, len_k in enumerate(seg_lengths): if lsum <= s <= lsum + len_k: t = inv_arclength(curve[k], s - lsum, s_tol=s_tol, maxits=maxits, error=error, min_depth=min_depth) return curve.t2T(k, t) lsum += len_k return 1 elif isinstance(curve, Line): return s / curve.length(error=error, min_depth=min_depth) elif (isinstance(curve, QuadraticBezier) or isinstance(curve, CubicBezier) or isinstance(curve, Arc)): t_upper = 1 t_lower = 0 iteration = 0 while iteration < maxits: iteration += 1 t = (t_lower + t_upper)/2 s_t = curve.length(t1=t, error=error, min_depth=min_depth) if abs(s_t - s) < s_tol: return t elif s_t < s: # t too small t_lower = t else: # s < s_t, t too big t_upper = t if t_upper == t_lower: warn("t is as close as a float can be to the correct value, " "but |s(t) - s| = {} > s_tol".format(abs(s_t-s))) return t raise Exception("Maximum iterations reached with s(t) - s = {}." "".format(s_t - s)) else: raise TypeError("First argument must be a Line, QuadraticBezier, " "CubicBezier, Arc, or Path object.") # Operations################################################################### def crop_bezier(seg, t0, t1): """returns a cropped copy of this segment which starts at self.point(t0) and ends at self.point(t1).""" assert t0 < t1 if t0 == 0: cropped_seg = seg.split(t1)[0] elif t1 == 1: cropped_seg = seg.split(t0)[1] else: pt1 = seg.point(t1) # trim off the 0 <= t < t0 part trimmed_seg = crop_bezier(seg, t0, 1) # find the adjusted t1 (i.e. the t1 such that # trimmed_seg.point(t1) ~= pt))and trim off the t1 < t <= 1 part t1_adj = trimmed_seg.radialrange(pt1)[0][1] cropped_seg = crop_bezier(trimmed_seg, 0, t1_adj) return cropped_seg # Main Classes ################################################################ class Line(object): def __init__(self, start, end): self.start = start self.end = end def __repr__(self): return 'Line(start=%s, end=%s)' % (self.start, self.end) def __eq__(self, other): if not isinstance(other, Line): return NotImplemented return self.start == other.start and self.end == other.end def __ne__(self, other): if not isinstance(other, Line): return NotImplemented return not self == other def __getitem__(self, item): return self.bpoints()[item] def __len__(self): return 2 def joins_smoothly_with(self, previous, wrt_parameterization=False): """Checks if this segment joins smoothly with previous segment. By default, this only checks that this segment starts moving (at t=0) in the same direction (and from the same positive) as previous stopped moving (at t=1). To check if the tangent magnitudes also match, set wrt_parameterization=True.""" if wrt_parameterization: return self.start == previous.end and np.isclose( self.derivative(0), previous.derivative(1)) else: return self.start == previous.end and np.isclose( self.unit_tangent(0), previous.unit_tangent(1)) def point(self, t): """returns the coordinates of the Bezier curve evaluated at t.""" distance = self.end - self.start return self.start + distance*t def length(self, t0=0, t1=1, error=None, min_depth=None): """returns the length of the line segment between t0 and t1.""" return abs(self.end - self.start)*(t1-t0) def ilength(self, s, s_tol=ILENGTH_S_TOL, maxits=ILENGTH_MAXITS, error=ILENGTH_ERROR, min_depth=ILENGTH_MIN_DEPTH): """Returns a float, t, such that self.length(0, t) is approximately s. See the inv_arclength() docstring for more details.""" return inv_arclength(self, s, s_tol=s_tol, maxits=maxits, error=error, min_depth=min_depth) def bpoints(self): """returns the Bezier control points of the segment.""" return self.start, self.end def poly(self, return_coeffs=False): """returns the line as a Polynomial object.""" p = self.bpoints() coeffs = ([p[1] - p[0], p[0]]) if return_coeffs: return coeffs else: return np.poly1d(coeffs) def derivative(self, t=None, n=1): """returns the nth derivative of the segment at t.""" assert self.end != self.start if n == 1: return self.end - self.start elif n > 1: return 0 else: raise ValueError("n should be a positive integer.") def unit_tangent(self, t=None): """returns the unit tangent of the segment at t.""" assert self.end != self.start dseg = self.end - self.start return dseg/abs(dseg) def normal(self, t=None): """returns the (right hand rule) unit normal vector to self at t.""" return -1j*self.unit_tangent(t) def curvature(self, t): """returns the curvature of the line, which is always zero.""" return 0 # def icurvature(self, kappa): # """returns a list of t-values such that 0 <= t<= 1 and # seg.curvature(t) = kappa.""" # if kappa: # raise ValueError("The .icurvature() method for Line elements will " # "return an empty list if kappa is nonzero and " # "will raise this exception when kappa is zero as " # "this is true at every point on the line.") # return [] def reversed(self): """returns a copy of the Line object with its orientation reversed.""" return Line(self.end, self.start) def intersect(self, other_seg, tol=None): """Finds the intersections of two segments. returns a list of tuples (t1, t2) such that self.point(t1) == other_seg.point(t2). Note: This will fail if the two segments coincide for more than a finite collection of points. tol is not used.""" if isinstance(other_seg, Line): assert other_seg.end != other_seg.start and self.end != self.start assert self != other_seg # Solve the system [p1-p0, q1-q0]*[t1, t2]^T = q0 - p0 # where self == Line(p0, p1) and other_seg == Line(q0, q1) a = (self.start.real, self.end.real) b = (self.start.imag, self.end.imag) c = (other_seg.start.real, other_seg.end.real) d = (other_seg.start.imag, other_seg.end.imag) denom = ((a[1] - a[0])*(d[0] - d[1]) - (b[1] - b[0])*(c[0] - c[1])) if np.isclose(denom, 0): return [] t1 = (c[0]*(b[0] - d[1]) - c[1]*(b[0] - d[0]) - a[0]*(d[0] - d[1]))/denom t2 = -(a[1]*(b[0] - d[0]) - a[0]*(b[1] - d[0]) - c[0]*(b[0] - b[1]))/denom if 0 <= t1 <= 1 and 0 <= t2 <= 1: return [(t1, t2)] return [] elif isinstance(other_seg, QuadraticBezier): t2t1s = bezier_by_line_intersections(other_seg, self) return [(t1, t2) for t2, t1 in t2t1s] elif isinstance(other_seg, CubicBezier): t2t1s = bezier_by_line_intersections(other_seg, self) return [(t1, t2) for t2, t1 in t2t1s] elif isinstance(other_seg, Arc): t2t1s = other_seg.intersect(self) return [(t1, t2) for t2, t1 in t2t1s] elif isinstance(other_seg, Path): raise TypeError( "other_seg must be a path segment, not a Path object, use " "Path.intersect().") else: raise TypeError("other_seg must be a path segment.") def bbox(self): """returns the bounding box for the segment in the form (xmin, xmax, ymin, ymax).""" xmin = min(self.start.real, self.end.real) xmax = max(self.start.real, self.end.real) ymin = min(self.start.imag, self.end.imag) ymax = max(self.start.imag, self.end.imag) return xmin, xmax, ymin, ymax def point_to_t(self, point): """If the point lies on the Line, returns its `t` parameter. If the point does not lie on the Line, returns None.""" # Single-precision floats have only 7 significant figures of # resolution, so test that we're within 6 sig figs. if np.isclose(point, self.start, rtol=0, atol=1e-6): return 0.0 elif np.isclose(point, self.end, rtol=0, atol=1e-6): return 1.0 # Finding the point "by hand" here is much faster than calling # radialrange(), see the discussion on PR #40: # https://github.com/mathandy/svgpathtools/pull/40#issuecomment-358134261 p = self.poly() # p(t) = (p_1 * t) + p_0 = point # t = (point - p_0) / p_1 t = (point - p[0]) / p[1] if np.isclose(t.imag, 0) and (t.real >= 0.0) and (t.real <= 1.0): return t.real return None def cropped(self, t0, t1): """returns a cropped copy of this segment which starts at self.point(t0) and ends at self.point(t1).""" return Line(self.point(t0), self.point(t1)) def split(self, t): """returns two segments, whose union is this segment and which join at self.point(t).""" pt = self.point(t) return Line(self.start, pt), Line(pt, self.end) def radialrange(self, origin, return_all_global_extrema=False): """returns the tuples (d_min, t_min) and (d_max, t_max) which minimize and maximize, respectively, the distance d = |self.point(t)-origin|.""" return bezier_radialrange(self, origin, return_all_global_extrema=return_all_global_extrema) def rotated(self, degs, origin=None): """Returns a copy of self rotated by `degs` degrees (CCW) around the point `origin` (a complex number). By default `origin` is either `self.point(0.5)`, or in the case that self is an Arc object, `origin` defaults to `self.center`.""" return rotate(self, degs, origin=origin) def translated(self, z0): """Returns a copy of self shifted by the complex quantity `z0` such that self.translated(z0).point(t) = self.point(t) + z0 for any t.""" return translate(self, z0) def scaled(self, sx, sy=None, origin=0j): """Scale transform. See `scale` function for further explanation.""" return scale(self, sx=sx, sy=sy, origin=origin) class QuadraticBezier(object): # For compatibility with old pickle files. _length_info = {'length': None, 'bpoints': None} def __init__(self, start, control, end): self.start = start self.end = end self.control = control # used to know if self._length needs to be updated self._length_info = {'length': None, 'bpoints': None} def __repr__(self): return 'QuadraticBezier(start=%s, control=%s, end=%s)' % ( self.start, self.control, self.end) def __eq__(self, other): if not isinstance(other, QuadraticBezier): return NotImplemented return self.start == other.start and self.end == other.end \ and self.control == other.control def __ne__(self, other): if not isinstance(other, QuadraticBezier): return NotImplemented return not self == other def __getitem__(self, item): return self.bpoints()[item] def __len__(self): return 3 def is_smooth_from(self, previous, warning_on=True): """[Warning: The name of this method is somewhat misleading (yet kept for compatibility with scripts created using svg.path 2.0). This method is meant only for d string creation and should not be used to check for kinks. To check a segment for differentiability, use the joins_smoothly_with() method instead.]""" if warning_on: warn(_is_smooth_from_warning) if isinstance(previous, QuadraticBezier): return (self.start == previous.end and (self.control - self.start) == ( previous.end - previous.control)) else: return self.control == self.start def joins_smoothly_with(self, previous, wrt_parameterization=False, error=0): """Checks if this segment joins smoothly with previous segment. By default, this only checks that this segment starts moving (at t=0) in the same direction (and from the same positive) as previous stopped moving (at t=1). To check if the tangent magnitudes also match, set wrt_parameterization=True.""" if wrt_parameterization: return self.start == previous.end and abs( self.derivative(0) - previous.derivative(1)) <= error else: return self.start == previous.end and abs( self.unit_tangent(0) - previous.unit_tangent(1)) <= error def point(self, t): """returns the coordinates of the Bezier curve evaluated at t.""" return (1 - t)**2*self.start + 2*(1 - t)*t*self.control + t**2*self.end def length(self, t0=0, t1=1, error=None, min_depth=None): if t0 == 1 and t1 == 0: if self._length_info['bpoints'] == self.bpoints(): return self._length_info['length'] a = self.start - 2*self.control + self.end b = 2*(self.control - self.start) a_dot_b = a.real*b.real + a.imag*b.imag if abs(a) < 1e-12: s = abs(b)*(t1 - t0) elif abs(a_dot_b + abs(a)*abs(b)) < 1e-12: tstar = abs(b)/(2*abs(a)) if t1 < tstar: return abs(a)*(t0**2 - t1**2) - abs(b)*(t0 - t1) elif tstar < t0: return abs(a)*(t1**2 - t0**2) - abs(b)*(t1 - t0) else: return abs(a)*(t1**2 + t0**2) - abs(b)*(t1 + t0) + \ abs(b)**2/(2*abs(a)) else: c2 = 4*(a.real**2 + a.imag**2) c1 = 4*a_dot_b c0 = b.real**2 + b.imag**2 beta = c1/(2*c2) gamma = c0/c2 - beta**2 dq1_mag = sqrt(c2*t1**2 + c1*t1 + c0) dq0_mag = sqrt(c2*t0**2 + c1*t0 + c0) logarand = (sqrt(c2)*(t1 + beta) + dq1_mag) / \ (sqrt(c2)*(t0 + beta) + dq0_mag) s = (t1 + beta)*dq1_mag - (t0 + beta)*dq0_mag + \ gamma*sqrt(c2)*log(logarand) s /= 2 if t0 == 1 and t1 == 0: self._length_info['length'] = s self._length_info['bpoints'] = self.bpoints() return self._length_info['length'] else: return s def ilength(self, s, s_tol=ILENGTH_S_TOL, maxits=ILENGTH_MAXITS, error=ILENGTH_ERROR, min_depth=ILENGTH_MIN_DEPTH): """Returns a float, t, such that self.length(0, t) is approximately s. See the inv_arclength() docstring for more details.""" return inv_arclength(self, s, s_tol=s_tol, maxits=maxits, error=error, min_depth=min_depth) def bpoints(self): """returns the Bezier control points of the segment.""" return self.start, self.control, self.end def poly(self, return_coeffs=False): """returns the quadratic as a Polynomial object.""" p = self.bpoints() coeffs = (p[0] - 2*p[1] + p[2], 2*(p[1] - p[0]), p[0]) if return_coeffs: return coeffs else: return np.poly1d(coeffs) def derivative(self, t, n=1): """returns the nth derivative of the segment at t. Note: Bezier curves can have points where their derivative vanishes. If you are interested in the tangent direction, use the unit_tangent() method instead.""" p = self.bpoints() if n == 1: return 2*((p[1] - p[0])*(1 - t) + (p[2] - p[1])*t) elif n == 2: return 2*(p[2] - 2*p[1] + p[0]) elif n > 2: return 0 else: raise ValueError("n should be a positive integer.") def unit_tangent(self, t): """returns the unit tangent vector of the segment at t (centered at the origin and expressed as a complex number). If the tangent vector's magnitude is zero, this method will find the limit of self.derivative(tau)/abs(self.derivative(tau)) as tau approaches t.""" return bezier_unit_tangent(self, t) def normal(self, t): """returns the (right hand rule) unit normal vector to self at t.""" return -1j*self.unit_tangent(t) def curvature(self, t): """returns the curvature of the segment at t.""" return segment_curvature(self, t) # def icurvature(self, kappa): # """returns a list of t-values such that 0 <= t<= 1 and # seg.curvature(t) = kappa.""" # z = self.poly() # x, y = real(z), imag(z) # dx, dy = x.deriv(), y.deriv() # ddx, ddy = dx.deriv(), dy.deriv() # # p = kappa**2*(dx**2 + dy**2)**3 - (dx*ddy - ddx*dy)**2 # return polyroots01(p) def reversed(self): """returns a copy of the QuadraticBezier object with its orientation reversed.""" new_quad = QuadraticBezier(self.end, self.control, self.start) if self._length_info['length']: new_quad._length_info = self._length_info new_quad._length_info['bpoints'] = ( self.end, self.control, self.start) return new_quad def intersect(self, other_seg, tol=1e-12): """Finds the intersections of two segments. returns a list of tuples (t1, t2) such that self.point(t1) == other_seg.point(t2). Note: This will fail if the two segments coincide for more than a finite collection of points.""" if isinstance(other_seg, Line): return bezier_by_line_intersections(self, other_seg) elif isinstance(other_seg, QuadraticBezier): assert self != other_seg longer_length = max(self.length(), other_seg.length()) return bezier_intersections(self, other_seg, longer_length=longer_length, tol=tol, tol_deC=tol) elif isinstance(other_seg, CubicBezier): longer_length = max(self.length(), other_seg.length()) return bezier_intersections(self, other_seg, longer_length=longer_length, tol=tol, tol_deC=tol) elif isinstance(other_seg, Arc): t2t1s = other_seg.intersect(self) return [(t1, t2) for t2, t1 in t2t1s] elif isinstance(other_seg, Path): raise TypeError( "other_seg must be a path segment, not a Path object, use " "Path.intersect().") else: raise TypeError("other_seg must be a path segment.") def bbox(self): """returns the bounding box for the segment in the form (xmin, xmax, ymin, ymax).""" return bezier_bounding_box(self) def split(self, t): """returns two segments, whose union is this segment and which join at self.point(t).""" bpoints1, bpoints2 = split_bezier(self.bpoints(), t) return QuadraticBezier(*bpoints1), QuadraticBezier(*bpoints2) def cropped(self, t0, t1): """returns a cropped copy of this segment which starts at self.point(t0) and ends at self.point(t1).""" return QuadraticBezier(*crop_bezier(self, t0, t1)) def radialrange(self, origin, return_all_global_extrema=False): """returns the tuples (d_min, t_min) and (d_max, t_max) which minimize and maximize, respectively, the distance d = |self.point(t)-origin|.""" return bezier_radialrange(self, origin, return_all_global_extrema=return_all_global_extrema) def rotated(self, degs, origin=None): """Returns a copy of self rotated by `degs` degrees (CCW) around the point `origin` (a complex number). By default `origin` is either `self.point(0.5)`, or in the case that self is an Arc object, `origin` defaults to `self.center`.""" return rotate(self, degs, origin=origin) def translated(self, z0): """Returns a copy of self shifted by the complex quantity `z0` such that self.translated(z0).point(t) = self.point(t) + z0 for any t.""" return translate(self, z0) def scaled(self, sx, sy=None, origin=0j): """Scale transform. See `scale` function for further explanation.""" return scale(self, sx=sx, sy=sy, origin=origin) class CubicBezier(object): # For compatibility with old pickle files. _length_info = {'length': None, 'bpoints': None, 'error': None, 'min_depth': None} def __init__(self, start, control1, control2, end): self.start = start self.control1 = control1 self.control2 = control2 self.end = end # used to know if self._length needs to be updated self._length_info = {'length': None, 'bpoints': None, 'error': None, 'min_depth': None} def __repr__(self): return 'CubicBezier(start=%s, control1=%s, control2=%s, end=%s)' % ( self.start, self.control1, self.control2, self.end) def __eq__(self, other): if not isinstance(other, CubicBezier): return NotImplemented return self.start == other.start and self.end == other.end \ and self.control1 == other.control1 \ and self.control2 == other.control2 def __ne__(self, other): if not isinstance(other, CubicBezier): return NotImplemented return not self == other def __getitem__(self, item): return self.bpoints()[item] def __len__(self): return 4 def is_smooth_from(self, previous, warning_on=True): """[Warning: The name of this method is somewhat misleading (yet kept for compatibility with scripts created using svg.path 2.0). This method is meant only for d string creation and should not be used to check for kinks. To check a segment for differentiability, use the joins_smoothly_with() method instead.]""" if warning_on: warn(_is_smooth_from_warning) if isinstance(previous, CubicBezier): return (self.start == previous.end and (self.control1 - self.start) == ( previous.end - previous.control2)) else: return self.control1 == self.start def joins_smoothly_with(self, previous, wrt_parameterization=False): """Checks if this segment joins smoothly with previous segment. By default, this only checks that this segment starts moving (at t=0) in the same direction (and from the same positive) as previous stopped moving (at t=1). To check if the tangent magnitudes also match, set wrt_parameterization=True.""" if wrt_parameterization: return self.start == previous.end and np.isclose( self.derivative(0), previous.derivative(1)) else: return self.start == previous.end and np.isclose( self.unit_tangent(0), previous.unit_tangent(1)) def point(self, t): """Evaluate the cubic Bezier curve at t using Horner's rule.""" # algebraically equivalent to # P0*(1-t)**3 + 3*P1*t*(1-t)**2 + 3*P2*(1-t)*t**2 + P3*t**3 # for (P0, P1, P2, P3) = self.bpoints() return self.start + t*( 3*(self.control1 - self.start) + t*( 3*(self.start + self.control2) - 6*self.control1 + t*( -self.start + 3*(self.control1 - self.control2) + self.end ))) def length(self, t0=0, t1=1, error=LENGTH_ERROR, min_depth=LENGTH_MIN_DEPTH): """Calculate the length of the path up to a certain position""" if t0 == 0 and t1 == 1: if self._length_info['bpoints'] == self.bpoints() \ and self._length_info['error'] >= error \ and self._length_info['min_depth'] >= min_depth: return self._length_info['length'] # using scipy.integrate.quad is quick if _quad_available: s = quad(lambda tau: abs(self.derivative(tau)), t0, t1, epsabs=error, limit=1000)[0] else: s = segment_length(self, t0, t1, self.point(t0), self.point(t1), error, min_depth, 0) if t0 == 0 and t1 == 1: self._length_info['length'] = s self._length_info['bpoints'] = self.bpoints() self._length_info['error'] = error self._length_info['min_depth'] = min_depth return self._length_info['length'] else: return s def ilength(self, s, s_tol=ILENGTH_S_TOL, maxits=ILENGTH_MAXITS, error=ILENGTH_ERROR, min_depth=ILENGTH_MIN_DEPTH): """Returns a float, t, such that self.length(0, t) is approximately s. See the inv_arclength() docstring for more details.""" return inv_arclength(self, s, s_tol=s_tol, maxits=maxits, error=error, min_depth=min_depth) def bpoints(self): """returns the Bezier control points of the segment.""" return self.start, self.control1, self.control2, self.end def poly(self, return_coeffs=False): """Returns a the cubic as a Polynomial object.""" p = self.bpoints() coeffs = (-p[0] + 3*(p[1] - p[2]) + p[3], 3*(p[0] - 2*p[1] + p[2]), 3*(-p[0] + p[1]), p[0]) if return_coeffs: return coeffs else: return np.poly1d(coeffs) def derivative(self, t, n=1): """returns the nth derivative of the segment at t. Note: Bezier curves can have points where their derivative vanishes. If you are interested in the tangent direction, use the unit_tangent() method instead.""" p = self.bpoints() if n == 1: return 3*(p[1] - p[0])*(1 - t)**2 + 6*(p[2] - p[1])*(1 - t)*t + 3*( p[3] - p[2])*t**2 elif n == 2: return 6*( (1 - t)*(p[2] - 2*p[1] + p[0]) + t*(p[3] - 2*p[2] + p[1])) elif n == 3: return 6*(p[3] - 3*(p[2] - p[1]) - p[0]) elif n > 3: return 0 else: raise ValueError("n should be a positive integer.") def unit_tangent(self, t): """returns the unit tangent vector of the segment at t (centered at the origin and expressed as a complex number). If the tangent vector's magnitude is zero, this method will find the limit of self.derivative(tau)/abs(self.derivative(tau)) as tau approaches t.""" return bezier_unit_tangent(self, t) def normal(self, t): """returns the (right hand rule) unit normal vector to self at t.""" return -1j * self.unit_tangent(t) def curvature(self, t): """returns the curvature of the segment at t.""" return segment_curvature(self, t) # def icurvature(self, kappa): # """returns a list of t-values such that 0 <= t<= 1 and # seg.curvature(t) = kappa.""" # z = self.poly() # x, y = real(z), imag(z) # dx, dy = x.deriv(), y.deriv() # ddx, ddy = dx.deriv(), dy.deriv() # # p = kappa**2*(dx**2 + dy**2)**3 - (dx*ddy - ddx*dy)**2 # return polyroots01(p) def reversed(self): """returns a copy of the CubicBezier object with its orientation reversed.""" new_cub = CubicBezier(self.end, self.control2, self.control1, self.start) if self._length_info['length']: new_cub._length_info = self._length_info new_cub._length_info['bpoints'] = ( self.end, self.control2, self.control1, self.start) return new_cub def intersect(self, other_seg, tol=1e-12): """Finds the intersections of two segments. returns a list of tuples (t1, t2) such that self.point(t1) == other_seg.point(t2). Note: This will fail if the two segments coincide for more than a finite collection of points.""" if isinstance(other_seg, Line): return bezier_by_line_intersections(self, other_seg) elif (isinstance(other_seg, QuadraticBezier) or isinstance(other_seg, CubicBezier)): assert self != other_seg longer_length = max(self.length(), other_seg.length()) return bezier_intersections(self, other_seg, longer_length=longer_length, tol=tol, tol_deC=tol) elif isinstance(other_seg, Arc): t2t1s = other_seg.intersect(self) return [(t1, t2) for t2, t1 in t2t1s] elif isinstance(other_seg, Path): raise TypeError( "other_seg must be a path segment, not a Path object, use " "Path.intersect().") else: raise TypeError("other_seg must be a path segment.") def bbox(self): """returns the bounding box for the segment in the form (xmin, xmax, ymin, ymax).""" return bezier_bounding_box(self) def split(self, t): """returns two segments, whose union is this segment and which join at self.point(t).""" bpoints1, bpoints2 = split_bezier(self.bpoints(), t) return CubicBezier(*bpoints1), CubicBezier(*bpoints2) def cropped(self, t0, t1): """returns a cropped copy of this segment which starts at self.point(t0) and ends at self.point(t1).""" return CubicBezier(*crop_bezier(self, t0, t1)) def radialrange(self, origin, return_all_global_extrema=False): """returns the tuples (d_min, t_min) and (d_max, t_max) which minimize and maximize, respectively, the distance d = |self.point(t)-origin|.""" return bezier_radialrange(self, origin, return_all_global_extrema=return_all_global_extrema) def rotated(self, degs, origin=None): """Returns a copy of self rotated by `degs` degrees (CCW) around the point `origin` (a complex number). By default `origin` is either `self.point(0.5)`, or in the case that self is an Arc object, `origin` defaults to `self.center`.""" return rotate(self, degs, origin=origin) def translated(self, z0): """Returns a copy of self shifted by the complex quantity `z0` such that self.translated(z0).point(t) = self.point(t) + z0 for any t.""" return translate(self, z0) def scaled(self, sx, sy=None, origin=0j): """Scale transform. See `scale` function for further explanation.""" return scale(self, sx=sx, sy=sy, origin=origin) class Arc(object): def __init__(self, start, radius, rotation, large_arc, sweep, end, autoscale_radius=True): """ This should be thought of as a part of an ellipse connecting two points on that ellipse, start and end. Parameters ---------- start : complex The start point of the curve. Note: `start` and `end` cannot be the same. To make a full ellipse or circle, use two `Arc` objects. radius : complex rx + 1j*ry, where rx and ry are the radii of the ellipse (also known as its semi-major and semi-minor axes, or vice-versa or if rx < ry). Note: If rx = 0 or ry = 0 then this arc is treated as a straight line segment joining the endpoints. Note: If rx or ry has a negative sign, the sign is dropped; the absolute value is used instead. Note: If no such ellipse exists, the radius will be scaled so that one does (unless autoscale_radius is set to False). rotation : float This is the CCW angle (in degrees) from the positive x-axis of the current coordinate system to the x-axis of the ellipse. large_arc : bool Given two points on an ellipse, there are two elliptical arcs connecting those points, the first going the short way around the ellipse, and the second going the long way around the ellipse. If `large_arc == False`, the shorter elliptical arc will be used. If `large_arc == True`, then longer elliptical will be used. In other words, `large_arc` should be 0 for arcs spanning less than or equal to 180 degrees and 1 for arcs spanning greater than 180 degrees. sweep : bool For any acceptable parameters `start`, `end`, `rotation`, and `radius`, there are two ellipses with the given major and minor axes (radii) which connect `start` and `end`. One which connects them in a CCW fashion and one which connected them in a CW fashion. If `sweep == True`, the CCW ellipse will be used. If `sweep == False`, the CW ellipse will be used. See note on curve orientation below. end : complex The end point of the curve. Note: `start` and `end` cannot be the same. To make a full ellipse or circle, use two `Arc` objects. autoscale_radius : bool If `autoscale_radius == True`, then will also scale `self.radius` in the case that no ellipse exists with the input parameters (see inline comments for further explanation). Derived Parameters/Attributes ----------------------------- self.theta : float This is the phase (in degrees) of self.u1transform(self.start). It is $\theta_1$ in the official documentation and ranges from -180 to 180. self.delta : float This is the angular distance (in degrees) between the start and end of the arc after the arc has been sent to the unit circle through self.u1transform(). It is $\Delta\theta$ in the official documentation and ranges from -360 to 360; being positive when the arc travels CCW and negative otherwise (i.e. is positive/negative when sweep == True/False). self.center : complex This is the center of the arc's ellipse. self.phi : float The arc's rotation in radians, i.e. `radians(self.rotation)`. self.rot_matrix : complex Equal to `exp(1j * self.phi)` which is also equal to `cos(self.phi) + 1j*sin(self.phi)`. Note on curve orientation (CW vs CCW) ------------------------------------- The notions of clockwise (CW) and counter-clockwise (CCW) are reversed in some sense when viewing SVGs (as the y coordinate starts at the top of the image and increases towards the bottom). """ assert start != end assert radius.real != 0 and radius.imag != 0 self.start = start self.radius = abs(radius.real) + 1j*abs(radius.imag) self.rotation = rotation self.large_arc = bool(large_arc) self.sweep = bool(sweep) self.end = end self.autoscale_radius = autoscale_radius self.segment_length_hash = None self.segment_length = None # Convenience parameters self.phi = radians(self.rotation) self.rot_matrix = exp(1j*self.phi) # Derive derived parameters self._parameterize() def __hash__(self): return hash((self.start, self.radius, self.rotation, self.large_arc, self.sweep, self.end)) def __repr__(self): params = (self.start, self.radius, self.rotation, self.large_arc, self.sweep, self.end) return ("Arc(start={}, radius={}, rotation={}, " "large_arc={}, sweep={}, end={})".format(*params)) def __eq__(self, other): if not isinstance(other, Arc): return NotImplemented return self.start == other.start and self.end == other.end \ and self.radius == other.radius \ and self.rotation == other.rotation \ and self.large_arc == other.large_arc and self.sweep == other.sweep def __ne__(self, other): if not isinstance(other, Arc): return NotImplemented return not self == other def _parameterize(self): # See http://www.w3.org/TR/SVG/implnote.html#ArcImplementationNotes # my notation roughly follows theirs rx = self.radius.real ry = self.radius.imag rx_sqd = rx*rx ry_sqd = ry*ry # Transform z-> z' = x' + 1j*y' # = self.rot_matrix**(-1)*(z - (end+start)/2) # coordinates. This translates the ellipse so that the midpoint # between self.end and self.start lies on the origin and rotates # the ellipse so that the its axes align with the xy-coordinate axes. # Note: This sends self.end to -self.start zp1 = (1/self.rot_matrix)*(self.start - self.end)/2 x1p, y1p = zp1.real, zp1.imag x1p_sqd = x1p*x1p y1p_sqd = y1p*y1p # Correct out of range radii # Note: an ellipse going through start and end with radius and phi # exists if and only if radius_check is true radius_check = (x1p_sqd/rx_sqd) + (y1p_sqd/ry_sqd) if radius_check > 1: if self.autoscale_radius: rx *= sqrt(radius_check) ry *= sqrt(radius_check) self.radius = rx + 1j*ry rx_sqd = rx*rx ry_sqd = ry*ry else: raise ValueError("No such elliptic arc exists.") # Compute c'=(c_x', c_y'), the center of the ellipse in (x', y') coords # Noting that, in our new coord system, (x_2', y_2') = (-x_1', -x_2') # and our ellipse is cut out by of the plane by the algebraic equation # (x'-c_x')**2 / r_x**2 + (y'-c_y')**2 / r_y**2 = 1, # we can find c' by solving the system of two quadratics given by # plugging our transformed endpoints (x_1', y_1') and (x_2', y_2') tmp = rx_sqd*y1p_sqd + ry_sqd*x1p_sqd radicand = (rx_sqd*ry_sqd - tmp) / tmp try: radical = sqrt(radicand) except ValueError: radical = 0 if self.large_arc == self.sweep: cp = -radical*(rx*y1p/ry - 1j*ry*x1p/rx) else: cp = radical*(rx*y1p/ry - 1j*ry*x1p/rx) # The center in (x,y) coordinates is easy to find knowing c' self.center = exp(1j*self.phi)*cp + (self.start + self.end)/2 # Now we do a second transformation, from (x', y') to (u_x, u_y) # coordinates, which is a translation moving the center of the # ellipse to the origin and a dilation stretching the ellipse to be # the unit circle u1 = (x1p - cp.real)/rx + 1j*(y1p - cp.imag)/ry # transformed start u2 = (-x1p - cp.real)/rx + 1j*(-y1p - cp.imag)/ry # transformed end # clip in case of floating point error u1 = np.clip(u1.real, -1, 1) + 1j*np.clip(u1.imag, -1, 1) u2 = np.clip(u2.real, -1, 1) + 1j * np.clip(u2.imag, -1, 1) # Now compute theta and delta (we'll define them as we go) # delta is the angular distance of the arc (w.r.t the circle) # theta is the angle between the positive x'-axis and the start point # on the circle if u1.imag > 0: self.theta = degrees(acos(u1.real)) elif u1.imag < 0: self.theta = -degrees(acos(u1.real)) else: if u1.real > 0: # start is on pos u_x axis self.theta = 0 else: # start is on neg u_x axis # Note: This behavior disagrees with behavior documented in # http://www.w3.org/TR/SVG/implnote.html#ArcImplementationNotes # where theta is set to 0 in this case. self.theta = 180 det_uv = u1.real*u2.imag - u1.imag*u2.real acosand = u1.real*u2.real + u1.imag*u2.imag acosand = np.clip(acosand.real, -1, 1) + np.clip(acosand.imag, -1, 1) if det_uv > 0: self.delta = degrees(acos(acosand)) elif det_uv < 0: self.delta = -degrees(acos(acosand)) else: if u1.real*u2.real + u1.imag*u2.imag > 0: # u1 == u2 self.delta = 0 else: # u1 == -u2 # Note: This behavior disagrees with behavior documented in # http://www.w3.org/TR/SVG/implnote.html#ArcImplementationNotes # where delta is set to 0 in this case. self.delta = 180 if not self.sweep and self.delta >= 0: self.delta -= 360 elif self.large_arc and self.delta <= 0: self.delta += 360 def point(self, t): if t == 0: return self.start if t == 1: return self.end angle = radians(self.theta + t*self.delta) cosphi = self.rot_matrix.real sinphi = self.rot_matrix.imag rx = self.radius.real ry = self.radius.imag # z = self.rot_matrix*(rx*cos(angle) + 1j*ry*sin(angle)) + self.center x = rx*cosphi*cos(angle) - ry*sinphi*sin(angle) + self.center.real y = rx*sinphi*cos(angle) + ry*cosphi*sin(angle) + self.center.imag return complex(x, y) def point_to_t(self, point): """If the point lies on the Arc, returns its `t` parameter. If the point does not lie on the Arc, returns None. This function only works on Arcs with rotation == 0.0""" def in_range(min, max, val): return (min <= val) and (max >= val) # Single-precision floats have only 7 significant figures of # resolution, so test that we're within 6 sig figs. if np.isclose(point, self.start, rtol=0.0, atol=1e-6): return 0.0 elif np.isclose(point, self.end, rtol=0.0, atol=1e-6): return 1.0 if self.rotation != 0.0: raise ValueError("Arc.point_to_t() only works on non-rotated Arcs.") v = point - self.center distance_from_center = sqrt((v.real * v.real) + (v.imag * v.imag)) min_radius = min(self.radius.real, self.radius.imag) max_radius = max(self.radius.real, self.radius.imag) if (distance_from_center < min_radius) and not np.isclose(distance_from_center, min_radius): return None if (distance_from_center > max_radius) and not np.isclose(distance_from_center, max_radius): return None # x = center_x + radius_x cos(radians(theta + t delta)) # y = center_y + radius_y sin(radians(theta + t delta)) # # For x: # cos(radians(theta + t delta)) = (x - center_x) / radius_x # radians(theta + t delta) = acos((x - center_x) / radius_x) # theta + t delta = degrees(acos((x - center_x) / radius_x)) # t_x = (degrees(acos((x - center_x) / radius_x)) - theta) / delta # # Similarly for y: # t_y = (degrees(asin((y - center_y) / radius_y)) - theta) / delta x = point.real y = point.imag # # +Y points down! # # sweep mean clocwise # sweep && (delta > 0) # !sweep && (delta < 0) # # -180 <= theta_1 <= 180 # # large_arc && (-360 <= delta <= 360) # !large_arc && (-180 < delta < 180) # end_angle = self.theta + self.delta min_angle = min(self.theta, end_angle) max_angle = max(self.theta, end_angle) acos_arg = (x - self.center.real) / self.radius.real if acos_arg > 1.0: acos_arg = 1.0 elif acos_arg < -1.0: acos_arg = -1.0 x_angle_0 = degrees(acos(acos_arg)) while x_angle_0 < min_angle: x_angle_0 += 360.0 while x_angle_0 > max_angle: x_angle_0 -= 360.0 x_angle_1 = -1.0 * x_angle_0 while x_angle_1 < min_angle: x_angle_1 += 360.0 while x_angle_1 > max_angle: x_angle_1 -= 360.0 t_x_0 = (x_angle_0 - self.theta) / self.delta t_x_1 = (x_angle_1 - self.theta) / self.delta asin_arg = (y - self.center.imag) / self.radius.imag if asin_arg > 1.0: asin_arg = 1.0 elif asin_arg < -1.0: asin_arg = -1.0 y_angle_0 = degrees(asin(asin_arg)) while y_angle_0 < min_angle: y_angle_0 += 360.0 while y_angle_0 > max_angle: y_angle_0 -= 360.0 y_angle_1 = 180 - y_angle_0 while y_angle_1 < min_angle: y_angle_1 += 360.0 while y_angle_1 > max_angle: y_angle_1 -= 360.0 t_y_0 = (y_angle_0 - self.theta) / self.delta t_y_1 = (y_angle_1 - self.theta) / self.delta t = None if np.isclose(t_x_0, t_y_0): t = (t_x_0 + t_y_0) / 2.0 elif np.isclose(t_x_0, t_y_1): t= (t_x_0 + t_y_1) / 2.0 elif np.isclose(t_x_1, t_y_0): t = (t_x_1 + t_y_0) / 2.0 elif np.isclose(t_x_1, t_y_1): t = (t_x_1 + t_y_1) / 2.0 else: # Comparing None and float yields a result in python2, # but throws TypeError in python3. This fix (suggested by # @CatherineH) explicitly handles and avoids the case where # the None-vs-float comparison would have happened below. return None if (t >= 0.0) and (t <= 1.0): return t return None def centeriso(self, z): """This is an isometry that translates and rotates self so that it is centered on the origin and has its axes aligned with the xy axes.""" return (1/self.rot_matrix)*(z - self.center) def icenteriso(self, zeta): """This is an isometry, the inverse of standardiso().""" return self.rot_matrix*zeta + self.center def u1transform(self, z): """This is an affine transformation (same as used in self._parameterize()) that sends self to the unit circle.""" zeta = (1/self.rot_matrix)*(z - self.center) # same as centeriso(z) x, y = real(zeta), imag(zeta) return x/self.radius.real + 1j*y/self.radius.imag def iu1transform(self, zeta): """This is an affine transformation, the inverse of self.u1transform().""" x = real(zeta) y = imag(zeta) z = x*self.radius.real + y*self.radius.imag return self.rot_matrix*z + self.center def length(self, t0=0, t1=1, error=LENGTH_ERROR, min_depth=LENGTH_MIN_DEPTH): """The length of an elliptical large_arc segment requires numerical integration, and in that case it's simpler to just do a geometric approximation, as for cubic bezier curves.""" assert 0 <= t0 <= 1 and 0 <= t1 <= 1 if t0 == 0 and t1 == 1: h = hash(self) if self.segment_length_hash is None or self.segment_length_hash != h: self.segment_length_hash = h if _quad_available: self.segment_length = quad(lambda tau: abs(self.derivative(tau)), t0, t1, epsabs=error, limit=1000)[0] else: self.segment_length = segment_length(self, t0, t1, self.point(t0), self.point(t1), error, min_depth, 0) return self.segment_length if _quad_available: return quad(lambda tau: abs(self.derivative(tau)), t0, t1, epsabs=error, limit=1000)[0] else: return segment_length(self, t0, t1, self.point(t0), self.point(t1), error, min_depth, 0) def ilength(self, s, s_tol=ILENGTH_S_TOL, maxits=ILENGTH_MAXITS, error=ILENGTH_ERROR, min_depth=ILENGTH_MIN_DEPTH): """Returns a float, t, such that self.length(0, t) is approximately s. See the inv_arclength() docstring for more details.""" return inv_arclength(self, s, s_tol=s_tol, maxits=maxits, error=error, min_depth=min_depth) def joins_smoothly_with(self, previous, wrt_parameterization=False, error=0): """Checks if this segment joins smoothly with previous segment. By default, this only checks that this segment starts moving (at t=0) in the same direction (and from the same positive) as previous stopped moving (at t=1). To check if the tangent magnitudes also match, set wrt_parameterization=True.""" if wrt_parameterization: return self.start == previous.end and abs( self.derivative(0) - previous.derivative(1)) <= error else: return self.start == previous.end and abs( self.unit_tangent(0) - previous.unit_tangent(1)) <= error def derivative(self, t, n=1): """returns the nth derivative of the segment at t.""" angle = radians(self.theta + t*self.delta) phi = radians(self.rotation) rx = self.radius.real ry = self.radius.imag k = (self.delta*2*pi/360)**n # ((d/dt)angle)**n if n % 4 == 0 and n > 0: return rx*cos(phi)*cos(angle) - ry*sin(phi)*sin(angle) + 1j*( rx*sin(phi)*cos(angle) + ry*cos(phi)*sin(angle)) elif n % 4 == 1: return k*(-rx*cos(phi)*sin(angle) - ry*sin(phi)*cos(angle) + 1j*( -rx*sin(phi)*sin(angle) + ry*cos(phi)*cos(angle))) elif n % 4 == 2: return k*(-rx*cos(phi)*cos(angle) + ry*sin(phi)*sin(angle) + 1j*( -rx*sin(phi)*cos(angle) - ry*cos(phi)*sin(angle))) elif n % 4 == 3: return k*(rx*cos(phi)*sin(angle) + ry*sin(phi)*cos(angle) + 1j*( rx*sin(phi)*sin(angle) - ry*cos(phi)*cos(angle))) else: raise ValueError("n should be a positive integer.") def unit_tangent(self, t): """returns the unit tangent vector of the segment at t (centered at the origin and expressed as a complex number).""" dseg = self.derivative(t) return dseg/abs(dseg) def normal(self, t): """returns the (right hand rule) unit normal vector to self at t.""" return -1j*self.unit_tangent(t) def curvature(self, t): """returns the curvature of the segment at t.""" return segment_curvature(self, t) # def icurvature(self, kappa): # """returns a list of t-values such that 0 <= t<= 1 and # seg.curvature(t) = kappa.""" # # a, b = self.radius.real, self.radius.imag # if kappa > min(a, b)/max(a, b)**2 or kappa <= 0: # return [] # if a==b: # if kappa != 1/a: # return [] # else: # raise ValueError( # "The .icurvature() method for Arc elements with " # "radius.real == radius.imag (i.e. circle segments) " # "will raise this exception when kappa is 1/radius.real as " # "this is true at every point on the circle segment.") # # # kappa = a*b / (a^2sin^2(tau) + b^2cos^2(tau))^(3/2), tau=2*pi*phase # sin2 = np.poly1d([1, 0]) # p = kappa**2*(a*sin2 + b*(1 - sin2))**3 - a*b # sin2s = polyroots01(p) # taus = [] # # for sin2 in sin2s: # taus += [np.arcsin(sqrt(sin2)), np.arcsin(-sqrt(sin2))] # # # account for the other branch of arcsin # sgn = lambda x: x/abs(x) if x else 0 # other_taus = [sgn(tau)*np.pi - tau for tau in taus if abs(tau) != np.pi/2] # taus = taus + other_taus # # # get rid of points not included in segment # ts = [phase2t(tau) for tau in taus] # # return [t for t in ts if 0<=t<=1] def reversed(self): """returns a copy of the Arc object with its orientation reversed.""" return Arc(self.end, self.radius, self.rotation, self.large_arc, not self.sweep, self.start) def phase2t(self, psi): """Given phase -pi < psi <= pi, returns the t value such that exp(1j*psi) = self.u1transform(self.point(t)). """ def _deg(rads, domain_lower_limit): # Convert rads to degrees in [0, 360) domain degs = degrees(rads % (2*pi)) # Convert to [domain_lower_limit, domain_lower_limit + 360) domain k = domain_lower_limit // 360 degs += k * 360 if degs < domain_lower_limit: degs += 360 return degs if self.delta > 0: degs = _deg(psi, domain_lower_limit=self.theta) else: degs = _deg(psi, domain_lower_limit=self.theta) return (degs - self.theta)/self.delta def intersect(self, other_seg, tol=1e-12): """NOT FULLY IMPLEMENTED. Finds the intersections of two segments. returns a list of tuples (t1, t2) such that self.point(t1) == other_seg.point(t2). Note: This will fail if the two segments coincide for more than a finite collection of points. Note: Arc related intersections are only partially supported, i.e. are only half-heartedly implemented and not well tested. Please feel free to let me know if you're interested in such a feature -- or even better please submit an implementation if you want to code one.""" # This special case can be easily solved algebraically. if (self.rotation == 0) and isinstance(other_seg, Line): a = self.radius.real b = self.radius.imag # Ignore the ellipse's center point (to pretend that it's # centered at the origin), and translate the Line to match. l = Line(start=(other_seg.start-self.center), end=(other_seg.end-self.center)) # This gives us the translated Line as a parametric equation. # s = p1 t + p0 p = l.poly() if p[1].real == 0.0: # The `x` value doesn't depend on `t`, the line is vertical. c = p[0].real x_values = [c] # Substitute the line `x = c` into the equation for the # (origin-centered) ellipse. # # x^2/a^2 + y^2/b^2 = 1 # c^2/a^2 + y^2/b^2 = 1 # y^2/b^2 = 1 - c^2/a^2 # y^2 = b^2(1 - c^2/a^2) # y = +-b sqrt(1 - c^2/a^2) discriminant = 1 - (c * c)/(a * a) if discriminant < 0: return [] elif discriminant == 0: y_values = [0] else: val = b * sqrt(discriminant) y_values = [val, -val] else: # This is a non-vertical line. # # Convert the Line's parametric equation to the "y = mx + c" format. # x = p1.real t + p0.real # y = p1.imag t + p0.imag # # t = (x - p0.real) / p1.real # t = (y - p0.imag) / p1.imag # # (y - p0.imag) / p1.imag = (x - p0.real) / p1.real # (y - p0.imag) = ((x - p0.real) * p1.imag) / p1.real # y = ((x - p0.real) * p1.imag) / p1.real + p0.imag # y = (x p1.imag - p0.real * p1.imag) / p1.real + p0.imag # y = x p1.imag/p1.real - p0.real p1.imag / p1.real + p0.imag # m = p1.imag/p1.real # c = -m p0.real + p0.imag m = p[1].imag / p[1].real c = (-m * p[0].real) + p[0].imag # Substitute the line's y(x) equation into the equation for # the ellipse. We can pretend the ellipse is centered at the # origin, since we shifted the Line by the ellipse's center. # # x^2/a^2 + y^2/b^2 = 1 # x^2/a^2 + (mx+c)^2/b^2 = 1 # (b^2 x^2 + a^2 (mx+c)^2)/(a^2 b^2) = 1 # b^2 x^2 + a^2 (mx+c)^2 = a^2 b^2 # b^2 x^2 + a^2(m^2 x^2 + 2mcx + c^2) = a^2 b^2 # b^2 x^2 + a^2 m^2 x^2 + 2a^2 mcx + a^2 c^2 - a^2 b^2 = 0 # (a^2 m^2 + b^2)x^2 + 2a^2 mcx + a^2(c^2 - b^2) = 0 # # The quadratic forumla tells us: x = (-B +- sqrt(B^2 - 4AC)) / 2A # Where: # A = a^2 m^2 + b^2 # B = 2 a^2 mc # C = a^2(c^2 - b^2) # # The determinant is: B^2 - 4AC # # The solution simplifies to: # x = (-a^2 mc +- a b sqrt(a^2 m^2 + b^2 - c^2)) / (a^2 m^2 + b^2) # # Solving the line for x(y) and substituting *that* into # the equation for the ellipse gives this solution for y: # y = (b^2 c +- abm sqrt(a^2 m^2 + b^2 - c^2)) / (a^2 m^2 + b^2) denominator = (a * a * m * m) + (b * b) discriminant = denominator - (c * c) if discriminant < 0: return [] x_sqrt = a * b * sqrt(discriminant) x1 = (-(a * a * m * c) + x_sqrt) / denominator x2 = (-(a * a * m * c) - x_sqrt) / denominator x_values = [x1] if x1 != x2: x_values.append(x2) y_sqrt = x_sqrt * m y1 = ((b * b * c) + y_sqrt) / denominator y2 = ((b * b * c) - y_sqrt) / denominator y_values = [y1] if y1 != y2: y_values.append(y2) intersections = [] for x in x_values: for y in y_values: p = complex(x, y) + self.center my_t = self.point_to_t(p) if my_t == None: continue other_t = other_seg.point_to_t(p) if other_t == None: continue intersections.append([my_t, other_t]) return intersections elif is_bezier_segment(other_seg): u1poly = self.u1transform(other_seg.poly()) u1poly_mag2 = real(u1poly)**2 + imag(u1poly)**2 t2s = polyroots01(u1poly_mag2 - 1) t1s = [self.phase2t(phase(u1poly(t2))) for t2 in t2s] return list(zip(t1s, t2s)) elif isinstance(other_seg, Arc): assert other_seg != self import sys # From "Intersection of two circles", at # http://paulbourke.net/geometry/circlesphere/ # It's easy to find the intersections of two circles, so # compute that and see if any of those # intersection points are on the arcs. if (self.rotation == 0) and (self.radius.real == self.radius.imag) and (other_seg.rotation == 0) and (other_seg.radius.real == other_seg.radius.imag): r0 = self.radius.real r1 = other_seg.radius.real p0 = self.center p1 = other_seg.center d = abs(p0 - p1) possible_inters = [] if d > (r0 + r1): # The circles are farther apart than the sum of # their radii: no intersections possible. pass elif d < abs(r0 - r1): # The small circle is wholly contained within the # large circle: no intersections possible. pass elif (np.isclose(d, 0, rtol=0.0, atol=1e-6)) and (np.isclose(r0, r1, rtol=0.0, atol=1e-6)): # The Arcs lie on the same circle: they have the # same center and are of equal radius. def point_in_seg_interior(point, seg): t = seg.point_to_t(point) if t is None: return False if np.isclose(t, 0.0, rtol=0.0, atol=1e-6): return False if np.isclose(t, 1.0, rtol=0.0, atol=1e-6): return False return True # If either end of either segment is in the interior # of the other segment, then the Arcs overlap # in an infinite number of points, and we return # "no intersections". if point_in_seg_interior(self.start, other_seg): return [] if point_in_seg_interior(self.end, other_seg): return [] if point_in_seg_interior(other_seg.start, self): return [] if point_in_seg_interior(other_seg.end, self): return [] # If they touch at their endpoint(s) and don't go # in "overlapping directions", then we accept that # as intersections. if (self.start == other_seg.start) and (self.sweep != other_seg.sweep): possible_inters.append((0.0, 0.0)) if (self.start == other_seg.end) and (self.sweep == other_seg.sweep): possible_inters.append((0.0, 1.0)) if (self.end == other_seg.start) and (self.sweep == other_seg.sweep): possible_inters.append((1.0, 0.0)) if (self.end == other_seg.end) and (self.sweep != other_seg.sweep): possible_inters.append((1.0, 1.0)) elif np.isclose(d, r0 + r1, rtol=0.0, atol=1e-6): # The circles are tangent, so the Arcs may touch # at exactly one point. The circles lie outside # each other. l = Line(start=p0, end=p1) p = l.point(r0/d) possible_inters.append((self.point_to_t(p), other_seg.point_to_t(p))) elif np.isclose(d, abs(r0 - r1), rtol=0.0, atol=1e-6): # The circles are tangent, so the Arcs may touch # at exactly one point. One circle lies inside # the other. # Make a line from the center of the inside circle # to the center of the outside circle, and walk # along it the negative of the small radius. l = Line(start=p0, end=p1) little_r = r0 if r0 > r1: l = Line(start=p1, end=p0) little_r = r1 p = l.point(-little_r/d) possible_inters.append((self.point_to_t(p), other_seg.point_to_t(p))) else: a = (pow(r0, 2.0) - pow(r1, 2.0) + pow(d, 2.0)) / (2.0 * d) h = sqrt(pow(r0, 2.0) - pow(a, 2.0)) p2 = p0 + (a * (p1 - p0) / d) x30 = p2.real + (h * (p1.imag - p0.imag) / d) x31 = p2.real - (h * (p1.imag - p0.imag) / d) y30 = p2.imag - (h * (p1.real - p0.real) / d) y31 = p2.imag + (h * (p1.real - p0.real) / d) p30 = complex(x30, y30) p31 = complex(x31, y31) possible_inters.append((self.point_to_t(p30), other_seg.point_to_t(p30))) possible_inters.append((self.point_to_t(p31), other_seg.point_to_t(p31))) inters = [] for p in possible_inters: self_t = p[0] if (self_t is None) or (self_t < 0.0) or (self_t > 1.0): continue other_t = p[1] if (other_t is None) or (other_t < 0.0) or (other_t > 1.0): continue assert(np.isclose(self.point(self_t), other_seg.point(other_t), rtol=0.0, atol=1e-6)) i = (self_t, other_t) inters.append(i) return inters # This could be made explicit to increase efficiency longer_length = max(self.length(), other_seg.length()) inters = bezier_intersections(self, other_seg, longer_length=longer_length, tol=tol, tol_deC=tol) # ad hoc fix for redundant solutions if len(inters) > 2: def keyfcn(tpair): t1, t2 = tpair return abs(self.point(t1) - other_seg.point(t2)) inters.sort(key=keyfcn) for idx in range(1, len(inters)-1): if (abs(inters[idx][0] - inters[idx + 1][0]) < abs(inters[idx][0] - inters[0][0])): return [inters[0], inters[idx]] else: return [inters[0], inters[-1]] return inters else: raise TypeError("other_seg should be a Arc, Line, " "QuadraticBezier, or CubicBezier object.") def bbox(self): """returns a bounding box for the segment in the form (xmin, xmax, ymin, ymax).""" # a(t) = radians(self.theta + self.delta*t) # = (2*pi/360)*(self.theta + self.delta*t) # x'=0: ~~~~~~~~~ # -rx*cos(phi)*sin(a(t)) = ry*sin(phi)*cos(a(t)) # -(rx/ry)*cot(phi)*tan(a(t)) = 1 # a(t) = arctan(-(ry/rx)tan(phi)) + pi*k === atan_x # y'=0: ~~~~~~~~~~ # rx*sin(phi)*sin(a(t)) = ry*cos(phi)*cos(a(t)) # (rx/ry)*tan(phi)*tan(a(t)) = 1 # a(t) = arctan((ry/rx)*cot(phi)) # atanres = arctan((ry/rx)*cot(phi)) === atan_y # ~~~~~~~~ # (2*pi/360)*(self.theta + self.delta*t) = atanres + pi*k # Therfore, for both x' and y', we have... # t = ((atan_{x/y} + pi*k)*(360/(2*pi)) - self.theta)/self.delta # for all k s.t. 0 < t < 1 from math import atan, tan if cos(self.phi) == 0: atan_x = pi/2 atan_y = 0 elif sin(self.phi) == 0: atan_x = 0 atan_y = pi/2 else: rx, ry = self.radius.real, self.radius.imag atan_x = atan(-(ry/rx)*tan(self.phi)) atan_y = atan((ry/rx)/tan(self.phi)) def angle_inv(ang, k): # inverse of angle from Arc.derivative() return ((ang + pi*k)*(360/(2*pi)) - self.theta)/self.delta xtrema = [self.start.real, self.end.real] ytrema = [self.start.imag, self.end.imag] for k in range(-4, 5): tx = angle_inv(atan_x, k) ty = angle_inv(atan_y, k) if 0 <= tx <= 1: xtrema.append(self.point(tx).real) if 0 <= ty <= 1: ytrema.append(self.point(ty).imag) xmin = max(xtrema) return min(xtrema), max(xtrema), min(ytrema), max(ytrema) def split(self, t): """returns two segments, whose union is this segment and which join at self.point(t).""" return self.cropped(0, t), self.cropped(t, 1) def cropped(self, t0, t1): """returns a cropped copy of this segment which starts at self.point(t0) and ends at self.point(t1).""" if abs(self.delta*(t1 - t0)) <= 180: new_large_arc = 0 else: new_large_arc = 1 return Arc(self.point(t0), radius=self.radius, rotation=self.rotation, large_arc=new_large_arc, sweep=self.sweep, end=self.point(t1), autoscale_radius=self.autoscale_radius) def radialrange(self, origin, return_all_global_extrema=False): """returns the tuples (d_min, t_min) and (d_max, t_max) which minimize and maximize, respectively, the distance, d = |self.point(t)-origin|.""" # u1orig = self.u1transform(origin) # if abs(u1orig) == 1: # origin lies on ellipse # t = self.phase2t(phase(u1orig)) # d_min = 0 # # # Transform to a coordinate system where the ellipse is centered # # at the origin and its axes are horizontal/vertical # zeta0 = self.centeriso(origin) # a, b = self.radius.real, self.radius.imag # x0, y0 = zeta0.real, zeta0.imag # # # Find t s.t. z'(t) # a2mb2 = (a**2 - b**2) # if u1orig.imag: # x != x0 # # coeffs = [a2mb2**2, # 2*a2mb2*b**2*y0, # (-a**4 + (2*a**2 - b**2 + y0**2)*b**2 + x0**2)*b**2, # -2*a2mb2*b**4*y0, # -b**6*y0**2] # ys = polyroots(coeffs, realroots=True, # condition=lambda r: -b <= r <= b) # xs = (a*sqrt(1 - y**2/b**2) for y in ys) # # ts = [self.phase2t(phase(self.u1transform(self.icenteriso( # complex(x, y))))) for x, y in zip(xs, ys)] # # else: # This case is very similar, see notes and assume instead y0!=y # b2ma2 = (b**2 - a**2) # coeffs = [b2ma2**2, # 2*b2ma2*a**2*x0, # (-b**4 + (2*b**2 - a**2 + x0**2)*a**2 + y0**2)*a**2, # -2*b2ma2*a**4*x0, # -a**6*x0**2] # xs = polyroots(coeffs, realroots=True, # condition=lambda r: -a <= r <= a) # ys = (b*sqrt(1 - x**2/a**2) for x in xs) # # ts = [self.phase2t(phase(self.u1transform(self.icenteriso( # complex(x, y))))) for x, y in zip(xs, ys)] raise _NotImplemented4ArcException def rotated(self, degs, origin=None): """Returns a copy of self rotated by `degs` degrees (CCW) around the point `origin` (a complex number). By default `origin` is either `self.point(0.5)`, or in the case that self is an Arc object, `origin` defaults to `self.center`.""" return rotate(self, degs, origin=origin) def translated(self, z0): """Returns a copy of self shifted by the complex quantity `z0` such that self.translated(z0).point(t) = self.point(t) + z0 for any t.""" return translate(self, z0) def scaled(self, sx, sy=None, origin=0j): """Scale transform. See `scale` function for further explanation.""" return scale(self, sx=sx, sy=sy, origin=origin) def is_bezier_segment(x): return (isinstance(x, Line) or isinstance(x, QuadraticBezier) or isinstance(x, CubicBezier)) def is_path_segment(x): return is_bezier_segment(x) or isinstance(x, Arc) class Path(MutableSequence): """A Path is a sequence of path segments""" # Put it here, so there is a default if unpickled. _closed = False _start = None _end = None attributes = None meta = None # meant as container for storage of arbitrary meta data def __init__(self, *segments, **kw): self._segments = list(segments) self._length = None self._lengths = None if 'closed' in kw: self.closed = kw['closed'] # DEPRECATED if self._segments: self._start = self._segments[0].start self._end = self._segments[-1].end else: self._start = None self._end = None if 'tree_element' in kw: self._tree_element = kw['tree_element'] def __getitem__(self, index): return self._segments[index] def __setitem__(self, index, value): self._segments[index] = value self._length = None self._start = self._segments[0].start self._end = self._segments[-1].end def __delitem__(self, index): del self._segments[index] self._length = None self._start = self._segments[0].start self._end = self._segments[-1].end def __iter__(self): return self._segments.__iter__() def __contains__(self, x): return self._segments.__contains__(x) def insert(self, index, value): self._segments.insert(index, value) self._length = None self._start = self._segments[0].start self._end = self._segments[-1].end def reversed(self): """returns a copy of the Path object with its orientation reversed.""" newpath = [seg.reversed() for seg in self] newpath.reverse() return Path(*newpath) def __len__(self): return len(self._segments) def __repr__(self): return "Path({})".format( ",\n ".join(repr(x) for x in self._segments)) def __eq__(self, other): if not isinstance(other, Path): return NotImplemented if len(self) != len(other): return False for s, o in zip(self._segments, other._segments): if not s == o: return False return True def __ne__(self, other): if not isinstance(other, Path): return NotImplemented return not self == other def _calc_lengths(self, error=LENGTH_ERROR, min_depth=LENGTH_MIN_DEPTH): if self._length is not None: return lengths = [each.length(error=error, min_depth=min_depth) for each in self._segments] self._length = sum(lengths) self._lengths = [each/self._length for each in lengths] def point(self, pos): # Shortcuts if pos == 0.0: return self._segments[0].point(pos) if pos == 1.0: return self._segments[-1].point(pos) self._calc_lengths() # Find which segment the point we search for is located on: segment_start = 0 for index, segment in enumerate(self._segments): segment_end = segment_start + self._lengths[index] if segment_end >= pos: # This is the segment! How far in on the segment is the point? segment_pos = (pos - segment_start)/( segment_end - segment_start) return segment.point(segment_pos) segment_start = segment_end def length(self, T0=0, T1=1, error=LENGTH_ERROR, min_depth=LENGTH_MIN_DEPTH): self._calc_lengths(error=error, min_depth=min_depth) if T0 == 0 and T1 == 1: return self._length else: if len(self) == 1: return self[0].length(t0=T0, t1=T1) idx0, t0 = self.T2t(T0) idx1, t1 = self.T2t(T1) if idx0 == idx1: return self[idx0].length(t0=t0, t1=t1) return (self[idx0].length(t0=t0) + sum(self[idx].length() for idx in range(idx0 + 1, idx1)) + self[idx1].length(t1=t1)) def ilength(self, s, s_tol=ILENGTH_S_TOL, maxits=ILENGTH_MAXITS, error=ILENGTH_ERROR, min_depth=ILENGTH_MIN_DEPTH): """Returns a float, t, such that self.length(0, t) is approximately s. See the inv_arclength() docstring for more details.""" return inv_arclength(self, s, s_tol=s_tol, maxits=maxits, error=error, min_depth=min_depth) def iscontinuous(self): """Checks if a path is continuous with respect to its parameterization.""" return all(self[i].end == self[i+1].start for i in range(len(self) - 1)) def continuous_subpaths(self): """Breaks self into its continuous components, returning a list of continuous subpaths. I.e. (all(subpath.iscontinuous() for subpath in self.continuous_subpaths()) and self == concatpaths(self.continuous_subpaths())) ) """ subpaths = [] subpath_start = 0 for i in range(len(self) - 1): if self[i].end != self[(i+1) % len(self)].start: subpaths.append(Path(*self[subpath_start: i+1])) subpath_start = i+1 subpaths.append(Path(*self[subpath_start: len(self)])) return subpaths def isclosed(self): """This function determines if a connected path is closed.""" assert len(self) != 0 assert self.iscontinuous() return self.start == self.end def isclosedac(self): assert len(self) != 0 return self.start == self.end def _is_closable(self): end = self[-1].end for segment in self: if segment.start == end: return True return False @property def closed(self, warning_on=CLOSED_WARNING_ON): """The closed attribute is deprecated, please use the isclosed() method instead. See _closed_warning for more information.""" mes = ("This attribute is deprecated, consider using isclosed() " "method instead.\n\nThis attribute is kept for compatibility " "with scripts created using svg.path (v2.0). You can prevent " "this warning in the future by setting " "CLOSED_WARNING_ON=False.") if warning_on: warn(mes) return self._closed and self._is_closable() @closed.setter def closed(self, value): value = bool(value) if value and not self._is_closable(): raise ValueError("End does not coincide with a segment start.") self._closed = value @property def start(self): if not self._start: self._start = self._segments[0].start return self._start @start.setter def start(self, pt): self._start = pt self._segments[0].start = pt @property def end(self): if not self._end: self._end = self._segments[-1].end return self._end @end.setter def end(self, pt): self._end = pt self._segments[-1].end = pt def d(self, useSandT=False, use_closed_attrib=False, rel=False): """Returns a path d-string for the path object. For an explanation of useSandT and use_closed_attrib, see the compatibility notes in the README.""" if use_closed_attrib: self_closed = self.iscontinuous() and self.isclosed() if self_closed: segments = self[:-1] else: segments = self[:] else: self_closed = False segments = self[:] current_pos = None parts = [] previous_segment = None end = self[-1].end for segment in segments: seg_start = segment.start # If the start of this segment does not coincide with the end of # the last segment or if this segment is actually the close point # of a closed path, then we should start a new subpath here. if current_pos != seg_start or \ (self_closed and seg_start == end and use_closed_attrib): if rel: _seg_start = seg_start - current_pos if current_pos is not None else seg_start else: _seg_start = seg_start parts.append('M {},{}'.format(_seg_start.real, _seg_start.imag)) if isinstance(segment, Line): if rel: _seg_end = segment.end - seg_start else: _seg_end = segment.end parts.append('L {},{}'.format(_seg_end.real, _seg_end.imag)) elif isinstance(segment, CubicBezier): if useSandT and segment.is_smooth_from(previous_segment, warning_on=False): if rel: _seg_control2 = segment.control2 - seg_start _seg_end = segment.end - seg_start else: _seg_control2 = segment.control2 _seg_end = segment.end args = (_seg_control2.real, _seg_control2.imag, _seg_end.real, _seg_end.imag) parts.append('S {},{} {},{}'.format(*args)) else: if rel: _seg_control1 = segment.control1 - seg_start _seg_control2 = segment.control2 - seg_start _seg_end = segment.end - seg_start else: _seg_control1 = segment.control1 _seg_control2 = segment.control2 _seg_end = segment.end args = (_seg_control1.real, _seg_control1.imag, _seg_control2.real, _seg_control2.imag, _seg_end.real, _seg_end.imag) parts.append('C {},{} {},{} {},{}'.format(*args)) elif isinstance(segment, QuadraticBezier): if useSandT and segment.is_smooth_from(previous_segment, warning_on=False): if rel: _seg_end = segment.end - seg_start else: _seg_end = segment.end args = _seg_end.real, _seg_end.imag parts.append('T {},{}'.format(*args)) else: if rel: _seg_control = segment.control - seg_start _seg_end = segment.end - seg_start else: _seg_control = segment.control _seg_end = segment.end args = (_seg_control.real, _seg_control.imag, _seg_end.real, _seg_end.imag) parts.append('Q {},{} {},{}'.format(*args)) elif isinstance(segment, Arc): if rel: _seg_end = segment.end - seg_start else: _seg_end = segment.end args = (segment.radius.real, segment.radius.imag, segment.rotation,int(segment.large_arc), int(segment.sweep),_seg_end.real, _seg_end.imag) parts.append('A {},{} {} {:d},{:d} {},{}'.format(*args)) current_pos = segment.end previous_segment = segment if self_closed: parts.append('Z') s = ' '.join(parts) return s if not rel else s.lower() def joins_smoothly_with(self, previous, wrt_parameterization=False): """Checks if this Path object joins smoothly with previous path/segment. By default, this only checks that this Path starts moving (at t=0) in the same direction (and from the same positive) as previous stopped moving (at t=1). To check if the tangent magnitudes also match, set wrt_parameterization=True.""" if wrt_parameterization: return self[0].start == previous.end and self.derivative( 0) == previous.derivative(1) else: return self[0].start == previous.end and self.unit_tangent( 0) == previous.unit_tangent(1) def T2t(self, T): """returns the segment index, `seg_idx`, and segment parameter, `t`, corresponding to the path parameter `T`. In other words, this is the inverse of the `Path.t2T()` method.""" if T == 1: return len(self)-1, 1 if T == 0: return 0, 0 self._calc_lengths() # Find which segment self.point(T) falls on: T0 = 0 # the T-value the current segment starts on for seg_idx, seg_length in enumerate(self._lengths): T1 = T0 + seg_length # the T-value the current segment ends on if T1 >= T: # This is the segment! t = (T - T0)/seg_length return seg_idx, t T0 = T1 assert 0 <= T <= 1 raise BugException def t2T(self, seg, t): """returns the path parameter T which corresponds to the segment parameter t. In other words, for any Path object, path, and any segment in path, seg, T(t) = path.t2T(seg, t) is the unique reparameterization such that path.point(T(t)) == seg.point(t) for all 0 <= t <= 1. Input Note: seg can be a segment in the Path object or its corresponding index.""" self._calc_lengths() # Accept an index or a segment for seg if isinstance(seg, int): seg_idx = seg else: try: seg_idx = self.index(seg) except ValueError: assert is_path_segment(seg) or isinstance(seg, int) raise segment_start = sum(self._lengths[:seg_idx]) segment_end = segment_start + self._lengths[seg_idx] T = (segment_end - segment_start)*t + segment_start return T def derivative(self, T, n=1): """returns the tangent vector of the Path at T (centered at the origin and expressed as a complex number). Note: Bezier curves can have points where their derivative vanishes. If you are interested in the tangent direction, use unit_tangent() method instead.""" seg_idx, t = self.T2t(T) seg = self._segments[seg_idx] return seg.derivative(t, n=n)/seg.length()**n def unit_tangent(self, T): """returns the unit tangent vector of the Path at T (centered at the origin and expressed as a complex number). If the tangent vector's magnitude is zero, this method will find the limit of self.derivative(tau)/abs(self.derivative(tau)) as tau approaches T.""" seg_idx, t = self.T2t(T) return self._segments[seg_idx].unit_tangent(t) def normal(self, t): """returns the (right hand rule) unit normal vector to self at t.""" return -1j*self.unit_tangent(t) def curvature(self, T): """returns the curvature of this Path object at T and outputs float('inf') if not differentiable at T.""" seg_idx, t = self.T2t(T) seg = self[seg_idx] if np.isclose(t, 0) and (seg_idx != 0 or self.end==self.start): previous_seg_in_path = self._segments[ (seg_idx - 1) % len(self._segments)] if not seg.joins_smoothly_with(previous_seg_in_path): return float('inf') elif np.isclose(t, 1) and (seg_idx != len(self) - 1 or self.end == self.start): next_seg_in_path = self._segments[ (seg_idx + 1) % len(self._segments)] if not next_seg_in_path.joins_smoothly_with(seg): return float('inf') dz = self.derivative(T) ddz = self.derivative(T, n=2) dx, dy = dz.real, dz.imag ddx, ddy = ddz.real, ddz.imag return abs(dx*ddy - dy*ddx)/(dx*dx + dy*dy)**1.5 # def icurvature(self, kappa): # """returns a list of T-values such that 0 <= T <= 1 and # seg.curvature(t) = kappa. # Note: not implemented for paths containing Arc segments.""" # assert is_bezier_path(self) # Ts = [] # for i, seg in enumerate(self): # Ts += [self.t2T(i, t) for t in seg.icurvature(kappa)] # return Ts def area(self, chord_length=1e-4): """Find area enclosed by path. Approximates any Arc segments in the Path with lines approximately `chord_length` long, and returns the area enclosed by the approximated Path. Default chord length is 0.01. If Arc segments are included in path, to ensure accurate results, make sure this `chord_length` is set to a reasonable value (e.g. by checking curvature). Notes ----- * Negative area results from clockwise (as opposed to counter-clockwise) parameterization of the input Path. To Contributors --------------- This is one of many parts of `svgpathtools` that could be improved by a noble soul implementing a piecewise-linear approximation scheme for paths (one with controls to guarantee a desired accuracy). """ def area_without_arcs(path): area_enclosed = 0 for seg in path: x = real(seg.poly()) dy = imag(seg.poly()).deriv() integrand = x*dy integral = integrand.integ() area_enclosed += integral(1) - integral(0) return area_enclosed def seg2lines(seg): """Find piecewise-linear approximation of `seg`.""" num_lines = int(ceil(seg.length() / chord_length)) pts = [seg.point(t) for t in np.linspace(0, 1, num_lines+1)] return [Line(pts[i], pts[i+1]) for i in range(num_lines)] assert self.isclosed() bezier_path_approximation = [] for seg in self: if isinstance(seg, Arc): bezier_path_approximation += seg2lines(seg) else: bezier_path_approximation.append(seg) return area_without_arcs(Path(*bezier_path_approximation)) def intersect(self, other_curve, justonemode=False, tol=1e-12): """returns list of pairs of pairs ((T1, seg1, t1), (T2, seg2, t2)) giving the intersection points. If justonemode==True, then returns just the first intersection found. tol is used to check for redundant intersections (see comment above the code block where tol is used). Note: If the two path objects coincide for more than a finite set of points, this code will fail.""" path1 = self if isinstance(other_curve, Path): path2 = other_curve else: path2 = Path(other_curve) assert path1 != path2 intersection_list = [] for seg1 in path1: for seg2 in path2: if justonemode and intersection_list: return intersection_list[0] for t1, t2 in seg1.intersect(seg2, tol=tol): T1 = path1.t2T(seg1, t1) T2 = path2.t2T(seg2, t2) intersection_list.append(((T1, seg1, t1), (T2, seg2, t2))) if justonemode and intersection_list: return intersection_list[0] # Note: If the intersection takes place at a joint (point one seg ends # and next begins in path) then intersection_list may contain a # redundant intersection. This code block checks for and removes said # redundancies. if intersection_list: pts = [seg1.point(_t1) for _T1, _seg1, _t1 in list(zip(*intersection_list))[0]] indices2remove = [] for ind1 in range(len(pts)): for ind2 in range(ind1 + 1, len(pts)): if abs(pts[ind1] - pts[ind2]) < tol: # then there's a redundancy. Remove it. indices2remove.append(ind2) intersection_list = [inter for ind, inter in enumerate(intersection_list) if ind not in indices2remove] return intersection_list def bbox(self): """returns a bounding box for the input Path object in the form (xmin, xmax, ymin, ymax).""" bbs = [seg.bbox() for seg in self._segments] xmins, xmaxs, ymins, ymaxs = list(zip(*bbs)) xmin = min(xmins) xmax = max(xmaxs) ymin = min(ymins) ymax = max(ymaxs) return xmin, xmax, ymin, ymax def cropped(self, T0, T1): """returns a cropped copy of the path.""" assert 0 <= T0 <= 1 and 0 <= T1<= 1 assert T0 != T1 assert not (T0 == 1 and T1 == 0) if T0 == 1 and 0 < T1 < 1 and self.isclosed(): return self.cropped(0, T1) if T1 == 1: seg1 = self[-1] t_seg1 = 1 i1 = len(self) - 1 else: seg1_idx, t_seg1 = self.T2t(T1) seg1 = self[seg1_idx] if np.isclose(t_seg1, 0): i1 = (self.index(seg1) - 1) % len(self) seg1 = self[i1] t_seg1 = 1 else: i1 = self.index(seg1) if T0 == 0: seg0 = self[0] t_seg0 = 0 i0 = 0 else: seg0_idx, t_seg0 = self.T2t(T0) seg0 = self[seg0_idx] if np.isclose(t_seg0, 1): i0 = (self.index(seg0) + 1) % len(self) seg0 = self[i0] t_seg0 = 0 else: i0 = self.index(seg0) if T0 < T1 and i0 == i1: new_path = Path(seg0.cropped(t_seg0, t_seg1)) else: new_path = Path(seg0.cropped(t_seg0, 1)) # T1 global_max[0]: global_max = seg_global_max + (seg_idx,) return global_min, global_max def rotated(self, degs, origin=None): """Returns a copy of self rotated by `degs` degrees (CCW) around the point `origin` (a complex number). By default `origin` is either `self.point(0.5)`, or in the case that self is an Arc object, `origin` defaults to `self.center`.""" return rotate(self, degs, origin=origin) def translated(self, z0): """Returns a copy of self shifted by the complex quantity `z0` such that self.translated(z0).point(t) = self.point(t) + z0 for any t.""" return translate(self, z0) def scaled(self, sx, sy=None, origin=0j): """Scale transform. See `scale` function for further explanation.""" return scale(self, sx=sx, sy=sy, origin=origin) def is_contained_by(self, other): """Returns true if the path is fully contained in other closed path""" assert isinstance(other, Path) assert other.isclosed() assert self != other if self.intersect(other, justonemode=True): return False pt = self.point(0) xmin, xmax, ymin, ymax = other.bbox() pt_in_bbox = (xmin <= pt.real <= xmax) and (ymin <= pt.imag <= ymax) if not pt_in_bbox: return False opt = complex(xmin-1, ymin-1) return path_encloses_pt(pt, opt, other)