"""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 import re 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 from itertools import tee from functools import reduce # these imports were originally from math and cmath, now are from numpy # in order to encourage code that generalizes to vector inputs from numpy import sqrt, cos, sin, tan, arccos as acos, arcsin as asin, \ degrees, radians, log, pi, ceil from numpy import exp, sqrt as csqrt, angle as phase, isnan 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 # To maintain forward/backward compatibility try: str = basestring except NameError: pass COMMANDS = set('MmZzLlHhVvCcSsQqTtAa') UPPERCASE = set('MZLHVCSQTA') COMMAND_RE = re.compile("([MmZzLlHhVvCcSsQqTtAa])") FLOAT_RE = re.compile("[-+]?[0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?") # 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: start, end = bpoints return Line(start, end) elif len(bpoints) == 4: start, control1, control2, end = bpoints return CubicBezier(start, control1, control2, end) elif len(bpoints) == 3: start, control, end = bpoints return QuadraticBezier(start, control, end) 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 transform_segments_together(path, transformation): """Makes sure that, if joints were continuous, they're kept that way.""" transformed_segs = [transformation(seg) for seg in path] for i, (sa, sb) in enumerate(path.joints()): if sa.end == sb.start: transformed_segs[i].end = transformed_segs[(i + 1) % len(path)].start return Path(*transformed_segs) 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 rotate_point(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): transformation = lambda seg: rotate(seg, degs, origin=origin) return transform_segments_together(curve, transformation) elif is_bezier_segment(curve): return bpoints2bezier([rotate_point(bpt) for bpt in curve.bpoints()]) elif isinstance(curve, Arc): new_start = rotate_point(curve.start) new_end = rotate_point(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): transformation = lambda seg: translate(seg, z0) return transform_segments_together(curve, transformation) 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): transformation = lambda seg: scale(seg, sx, sy, origin) return transform_segments_together(curve, transformation) 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""" if all((tf == np.eye(3)).ravel()): return curve # tf is identity, return curve as is 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): transformation = lambda seg: transform(seg, tf) return transform_segments_together(curve, transformation) 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))) # Based on https://math.stackexchange.com/questions/2349726/ rx2 = curve.radius.real ** 2 ry2 = curve.radius.imag ** 2 Q = np.array([[1/rx2, 0], [0, 1/ry2]]) invT = np.linalg.inv(tf[:2,:2]) D = reduce(np.matmul, [invT.T, Q, invT]) eigvals, eigvecs = np.linalg.eig(D) rx = 1 / np.sqrt(eigvals[0]) ry = 1 / np.sqrt(eigvals[1]) new_radius = complex(rx, ry) xeigvec = eigvecs[:, 0] rot = np.degrees(np.arccos(xeigvec[0])) if new_radius.real == 0 or new_radius.imag == 0 : return Line(new_start, new_end) else : return Arc(new_start, radius=new_radius, rotation=curve.rotation + rot, large_arc=curve.large_arc, sweep=curve.sweep, end=new_end, autoscale_radius=True) 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) """ 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): """Crop a copy of this `self` from `self.point(t0)` to `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 __hash__(self): return hash((self.start, self.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 points(self, ts): """Faster than running Path.point many times.""" return self.poly()(ts) 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, QuadraticBezier, CubicBezier)): ob = [e.real for e in other_seg.bpoints()] sb = [e.real for e in self.bpoints()] if min(ob) > max(sb): return [] if max(ob) < min(sb): return [] ob = [e.imag for e in other_seg.bpoints()] sb = [e.imag for e in self.bpoints()] if min(ob) > max(sb): return [] if max(ob) < min(sb): return [] 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, **kwargs): """compute points in self that are min and max distance to origin. Args: origin (complex): the point extremize distance to Returns: tuples (d_min, t_min) and (d_max, t_max) which minimize and maximize, respectively, the distance d = |self.point(t)-origin|. """ x, y = origin.real, origin.imag p0, p1 = self.start, self.end x0, y0, x1, y1 = p0.real, p0.imag, p1.real, p1.imag dx, dy = x1 - x0, y1 - y0 numerator, denominator = dx * (x - x0) + dy * (y - y0), dx * dx + dy * dy t = numerator / denominator if 0 < t < 1: # get distance to origin at 0, 1, and t d0, d1, dt = ( abs(p0 - origin), abs(p1 - origin), abs(self.point(t) - origin) ) if d0 < d1: return (dt, t), (d1, 1) return (dt, t), (d0, 0) else: # get distance to origin at t = 0 and t = 1 d0, d1 = abs(p0 - origin), abs(p1 - origin) if d0 < d1: return (d0, 0), (d1, 1) return (d1, 1), (d0, 0) 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 __hash__(self): return hash((self.start, self.control, self.end)) 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.""" tc = 1 - t return tc*tc*self.start + 2*tc*t*self.control + t*t*self.end def points(self, ts): """Faster than running Path.point many times.""" return self.poly()(ts) 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) 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 isnan(s): 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)) 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, QuadraticBezier, CubicBezier)): ob = [e.real for e in other_seg.bpoints()] sb = [e.real for e in self.bpoints()] if min(ob) > max(sb): return [] if max(ob) < min(sb): return [] ob = [e.imag for e in other_seg.bpoints()] sb = [e.imag for e in self.bpoints()] if min(ob) > max(sb): return [] if max(ob) < min(sb): return [] 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 __hash__(self): return hash((self.start, self.control1, self.control2, self.end)) 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 points(self, ts): """Faster than running Path.point many times.""" return self.poly()(ts) 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: (list[tuple[float]]) a list of tuples (t1, t2) such that self.point(t1) == other_seg.point(t2). Scope: This will fail if the two segments coincide for more than a finite collection of points. """ if isinstance(other_seg, (Line, QuadraticBezier, CubicBezier)): ob = [e.real for e in other_seg.bpoints()] sb = [e.real for e in self.bpoints()] if min(ob) > max(sb): return [] if max(ob) < min(sb): return [] ob = [e.imag for e in other_seg.bpoints()] sb = [e.imag for e in self.bpoints()] if min(ob) > max(sb): return [] if max(ob) < min(sb): return [] 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): return [(t1, t2) for t2, t1 in other_seg.intersect(self)] 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 bounding box in format (xmin, xmax, ymin, ymax).""" return bezier_bounding_box(self) def split(self, t): """Splits a copy of `self` at t and returns the two subsegments.""" 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 radical = 0 if np.isclose(radicand, 0) else sqrt(radicand) 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): angle = (self.theta + t*self.delta)*pi/180 cosphi = self.rot_matrix.real sinphi = self.rot_matrix.imag rx = self.radius.real ry = self.radius.imag 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 x + y*1j 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): """Isometry to a centered aligned ellipse. This is an isometry that shifts and rotates `self`'s underlying ellipse so that it's centered on the origin and has its axes aligned with the xy-axes. Args: z (:obj:`complex` or :obj:`numpy.ndarray[complex]`): a point to send through the above-described isometry. Returns: (:obj:`complex` or :obj:`numpy.ndarray[complex]`) The point(s) f(z), where f is the above described isometry of the xy-plane (i.e. the one-dimensional complex plane). """ return (1/self.rot_matrix)*(z - self.center) def icenteriso(self, zeta): """The inverse of the `centeriso()` method.""" return self.rot_matrix*zeta + self.center def u1transform(self, z): """Similar to the `centeriso()` method, but maps to the unit circle.""" zeta = self.centeriso(z) x, y = real(zeta), imag(zeta) return x/self.radius.real + 1j*y/self.radius.imag def iu1transform(self, zeta): """The inverse of the `u1transform()` method.""" 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): """Computes the length of the Arc segment, `self`, from t0 to t1. Notes: * 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): """Approximates the unique `t` such that self.length(0, t) = s. Args: s (float): A length between 0 and `self.length()`. Returns: (float) The t, such that self.length(0, t) is approximately s. For more info: See the inv_arclength() docstring. """ 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*pi/180)**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): """Converts phase to t-value. I.e. given phase, psi, such that -np.pi < psi <= np.pi, approximates the unique t-value such that `self.u1transform(self.point(t))` equals `np.exp(1j*psi)`. Args: psi (float): The phase in radians. Returns: (float): the corresponding t-value. """ 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): # if self and other_seg intersect, they will itersect at the # same points after being passed through the `u1transform` # isometry. Since this isometry maps self to the unit circle, # the intersections will be easy to find (just look for any # points where other_seg is a distance of one from the origin. # Moreoever, the t-values that the intersection happen at will # be unchanged by the isometry. u1poly = np.poly1d(self.u1transform(other_seg.poly())) u1poly_mag2 = real(u1poly)**2 + imag(u1poly)**2 t2s = [t for t in polyroots01(u1poly_mag2 - 1) if 0 <= t <= 1] t1s = [self.phase2t(phase(u1poly(t2))) for t2 in t2s] return [(t1, t2) for t1, t2 in zip(t1s, t2s) if 0 <= t1 <= 1] 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 (not t or np.isclose(t, 0.0, rtol=0.0, atol=1e-6) or 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) or point_in_seg_interior(self.end, other_seg) or point_in_seg_interior(other_seg.start, self) or 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 as_cubic_curves(self, curves=1): """Generates cubic curves to approximate this arc""" slice_t = radians(self.delta) / float(curves) current_t = radians(self.theta) rx = self.radius.real # * self.radius_scale ry = self.radius.imag # * self.radius_scale p_start = self.start theta = radians(self.rotation) x0 = self.center.real y0 = self.center.imag cos_theta = cos(theta) sin_theta = sin(theta) for i in range(curves): next_t = current_t + slice_t alpha = sin(slice_t) * (sqrt(4 + 3 * pow(tan((slice_t) / 2.0), 2)) - 1) / 3.0 cos_start_t = cos(current_t) sin_start_t = sin(current_t) ePrimen1x = -rx * cos_theta * sin_start_t - ry * sin_theta * cos_start_t ePrimen1y = -rx * sin_theta * sin_start_t + ry * cos_theta * cos_start_t cos_end_t = cos(next_t) sin_end_t = sin(next_t) p2En2x = x0 + rx * cos_end_t * cos_theta - ry * sin_end_t * sin_theta p2En2y = y0 + rx * cos_end_t * sin_theta + ry * sin_end_t * cos_theta p_end = p2En2x + p2En2y * 1j if i == curves - 1: p_end = self.end ePrimen2x = -rx * cos_theta * sin_end_t - ry * sin_theta * cos_end_t ePrimen2y = -rx * sin_theta * sin_end_t + ry * cos_theta * cos_end_t p_c1 = (p_start.real + alpha * ePrimen1x) + (p_start.imag + alpha * ePrimen1y) * 1j p_c2 = (p_end.real - alpha * ePrimen2x) + (p_end.imag - alpha * ePrimen2y) * 1j yield CubicBezier(p_start, p_c1, p_c2, p_end) p_start = p_end current_t = next_t def as_quad_curves(self, curves=1): """Generates quadratic curves to approximate this arc""" slice_t = radians(self.delta) / float(curves) current_t = radians(self.theta) a = self.radius.real # * self.radius_scale b = self.radius.imag # * self.radius_scale p_start = self.start theta = radians(self.rotation) cx = self.center.real cy = self.center.imag cos_theta = cos(theta) sin_theta = sin(theta) for i in range(curves): next_t = current_t + slice_t mid_t = (next_t + current_t) / 2 cos_end_t = cos(next_t) sin_end_t = sin(next_t) p2En2x = cx + a * cos_end_t * cos_theta - b * sin_end_t * sin_theta p2En2y = cy + a * cos_end_t * sin_theta + b * sin_end_t * cos_theta p_end = p2En2x + p2En2y * 1j if i == curves - 1: p_end = self.end cos_mid_t = cos(mid_t) sin_mid_t = sin(mid_t) alpha = (4.0 - cos(slice_t)) / 3.0 px = cx + alpha * (a * cos_mid_t * cos_theta - b * sin_mid_t * sin_theta) py = cy + alpha * (a * cos_mid_t * sin_theta + b * sin_mid_t * cos_theta) yield QuadraticBezier(p_start, px + py * 1j, p_end) p_start = p_end current_t = next_t 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 element = None transform = None meta = None # meant as container for storage of arbitrary meta data def __init__(self, *segments, **kw): self._length = None self._lengths = None if 'closed' in kw: self.closed = kw['closed'] # DEPRECATED if len(segments) >= 1: if isinstance(segments[0], str): if len(segments) >= 2: current_pos = segments[1] elif 'current_pos' in kw: current_pos = kw['current_pos'] else: current_pos = 0j self._segments = list() self._parse_path(segments[0], current_pos) else: self._segments = list(segments) else: self._segments = list() 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 __hash__(self): return hash((tuple(self._segments), self._closed)) 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 if len(self._segments) > 0: self._start = self._segments[0].start self._end = self._segments[-1].end else: self._start = None self._end = None 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) if self._length == 0: self._lengths = lengths # all lengths are 0. else: self._lengths = [each / self._length for each in lengths] def point(self, pos): # Shortcuts if len(self._segments) == 0: return None 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): try: end = self[-1].end except IndexError: return True 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 and len(self._segments)>0: self._start = self._segments[0].start return self._start @start.setter def start(self, pt): self._start = pt if len(self._segments)>0: self._segments[0].start = pt @property def end(self): if not self._end and len(self._segments)>0: self._end = self._segments[-1].end return self._end @end.setter def end(self, pt): self._end = pt if len(self._segments)>0: 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 len(self) == 0: return '' 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): """Finds intersections of `self` with `other_curve` Args: other_curve: the path or path segment to check for intersections with `self` justonemode (bool): if true, returns only the first intersection found. tol (float): A tolerance used to check for redundant intersections (see comment above the code block where tol is used). Returns: (list[tuple[float, Curve, float]]): list of intersections, each in the format ((T1, seg1, t1), (T2, seg2, t2)), where self.point(T1) == seg1.point(t1) == seg2.point(t2) == other_curve.point(T2) Scope: If the two path objects coincide for more than a finite set of points, this code will iterate to max depth and/or raise an error. """ path1 = self path2 = other_curve if isinstance(other_curve, Path) else 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 bounding box 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) def approximate_arcs_with_cubics(self, error=0.1): """ Iterates through this path and replaces any Arcs with cubic bezier curves. """ tau = pi * 2 sweep_limit = degrees(tau * error) for s in range(len(self)-1, -1, -1): segment = self[s] if not isinstance(segment, Arc): continue arc_required = int(ceil(abs(segment.delta) / sweep_limit)) self[s:s+1] = list(segment.as_cubic_curves(arc_required)) def approximate_arcs_with_quads(self, error=0.1): """ Iterates through this path and replaces any Arcs with quadratic bezier curves. """ tau = pi * 2 sweep_limit = degrees(tau * error) for s in range(len(self)-1, -1, -1): segment = self[s] if not isinstance(segment, Arc): continue arc_required = int(ceil(abs(segment.delta) / sweep_limit)) self[s:s+1] = list(segment.as_quad_curves(arc_required)) def joints(self): """returns generator of segment joints I.e. Path(s0, s1, s2, ..., sn).joints() returns generator (s0, s1), (s1, s2), ..., (sn, s0) credit: https://docs.python.org/3/library/itertools.html#recipes """ a, b = tee(self) next(b, None) return zip(a, b) def _tokenize_path(self, pathdef): for x in COMMAND_RE.split(pathdef): if x in COMMANDS: yield x for token in FLOAT_RE.findall(x): yield token def _parse_path(self, pathdef, current_pos=0j, tree_element=None): # In the SVG specs, initial movetos are absolute, even if # specified as 'm'. This is the default behavior here as well. # But if you pass in a current_pos variable, the initial moveto # will be relative to that current_pos. This is useful. elements = list(self._tokenize_path(pathdef)) # Reverse for easy use of .pop() elements.reverse() segments = self._segments start_pos = None command = None while elements: if elements[-1] in COMMANDS: # New command. last_command = command # Used by S and T command = elements.pop() absolute = command in UPPERCASE command = command.upper() else: # If this element starts with numbers, it is an implicit command # and we don't change the command. Check that it's allowed: if command is None: raise ValueError("Unallowed implicit command in %s, position %s" % ( pathdef, len(pathdef.split()) - len(elements))) last_command = command # Used by S and T if command == 'M': # Moveto command. x = elements.pop() y = elements.pop() pos = float(x) + float(y) * 1j if absolute: current_pos = pos else: current_pos += pos # when M is called, reset start_pos # This behavior of Z is defined in svg spec: # http://www.w3.org/TR/SVG/paths.html#PathDataClosePathCommand start_pos = current_pos # Implicit moveto commands are treated as lineto commands. # So we set command to lineto here, in case there are # further implicit commands after this moveto. command = 'L' elif command == 'Z': # Close path if not (current_pos == start_pos): segments.append(Line(current_pos, start_pos)) self._closed = True current_pos = start_pos command = None elif command == 'L': x = elements.pop() y = elements.pop() pos = float(x) + float(y) * 1j if not absolute: pos += current_pos segments.append(Line(current_pos, pos)) current_pos = pos elif command == 'H': x = elements.pop() pos = float(x) + current_pos.imag * 1j if not absolute: pos += current_pos.real segments.append(Line(current_pos, pos)) current_pos = pos elif command == 'V': y = elements.pop() pos = current_pos.real + float(y) * 1j if not absolute: pos += current_pos.imag * 1j segments.append(Line(current_pos, pos)) current_pos = pos elif command == 'C': control1 = float(elements.pop()) + float(elements.pop()) * 1j control2 = float(elements.pop()) + float(elements.pop()) * 1j end = float(elements.pop()) + float(elements.pop()) * 1j if not absolute: control1 += current_pos control2 += current_pos end += current_pos segments.append(CubicBezier(current_pos, control1, control2, end)) current_pos = end elif command == 'S': # Smooth curve. First control point is the "reflection" of # the second control point in the previous path. if last_command not in 'CS': # If there is no previous command or if the previous command # was not an C, c, S or s, assume the first control point is # coincident with the current point. control1 = current_pos else: # The first control point is assumed to be the reflection of # the second control point on the previous command relative # to the current point. control1 = current_pos + current_pos - segments[-1].control2 control2 = float(elements.pop()) + float(elements.pop()) * 1j end = float(elements.pop()) + float(elements.pop()) * 1j if not absolute: control2 += current_pos end += current_pos segments.append(CubicBezier(current_pos, control1, control2, end)) current_pos = end elif command == 'Q': control = float(elements.pop()) + float(elements.pop()) * 1j end = float(elements.pop()) + float(elements.pop()) * 1j if not absolute: control += current_pos end += current_pos segments.append(QuadraticBezier(current_pos, control, end)) current_pos = end elif command == 'T': # Smooth curve. Control point is the "reflection" of # the second control point in the previous path. if last_command not in 'QT': # If there is no previous command or if the previous command # was not an Q, q, T or t, assume the first control point is # coincident with the current point. control = current_pos else: # The control point is assumed to be the reflection of # the control point on the previous command relative # to the current point. control = current_pos + current_pos - segments[-1].control end = float(elements.pop()) + float(elements.pop()) * 1j if not absolute: end += current_pos segments.append(QuadraticBezier(current_pos, control, end)) current_pos = end elif command == 'A': radius = float(elements.pop()) + float(elements.pop()) * 1j rotation = float(elements.pop()) arc = float(elements.pop()) sweep = float(elements.pop()) end = float(elements.pop()) + float(elements.pop()) * 1j if not absolute: end += current_pos if radius.real == 0 or radius.imag == 0: # Note: In browsers AFAIK, zero radius arcs are displayed # as lines (see "examples/zero-radius-arcs.svg"). # Thus zero radius arcs are substituted for lines here. warn('Replacing degenerate (zero radius) Arc with a Line: ' 'Arc(start={}, radius={}, rotation={}, large_arc={}, ' 'sweep={}, end={})'.format( current_pos, radius, rotation, arc, sweep, end) + ' --> Line(start={}, end={})' ''.format(current_pos, end)) segments.append(Line(current_pos, end)) else: segments.append( Arc(current_pos, radius, rotation, arc, sweep, end)) current_pos = end return segments