#!/usr/bin/env python3 ''' Copyright (C) 2018 Tao Wei taowei@buffalo.edu This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. ''' import inkex import math import svgpathtools from lxml import etree KAPPA = 4/3. * (math.sqrt(2)-1) def cround(cnumber, ndigits): return round(cnumber.real, ndigits) + round(cnumber.imag, ndigits)*1j def round_seg(seg, ndigits): seg.start = cround(seg.start, ndigits) seg.end = cround(seg.end, ndigits) return seg def round_path(p, ndigits=6): """fix for precision issue""" for seg in p: round_seg(seg, ndigits) return p def remove_zero_length_segments(p, eps=1e-6): "z will add a zero length line segment" return svgpathtools.Path(*filter(lambda seg: seg.length() > eps, p)) def iscontinuous(p): for seg1, seg2 in zip(p[:-1], p[1:]): if abs(seg1.end-seg2.start) >= 1e-6: return False return True def isclosedac(p): return abs(p.start-p.end) < 1e-6 def isclosed(p): assert iscontinuous(p) return isclosedac(p) from svgpathtools.path import Line, CubicBezier, QuadraticBezier, Arc def d_str(self, useSandT=False, use_closed_attrib=False, rel=False): """Returns a path d-string for the path object. For an explanation of useSandT and use_closed_attrib, see the compatibility notes in the README.""" if use_closed_attrib: self_closed = self.iscontinuous() and self.isclosed() if self_closed: segments = self[:-1] else: segments = self[:] else: self_closed = False segments = self[:] current_pos = None parts = [] previous_segment = None end = self[-1].end for segment in segments: seg_start = segment.start # If the start of this segment does not coincide with the end of # the last segment or if this segment is actually the close point # of a closed path, then we should start a new subpath here. if current_pos != seg_start or \ (self_closed and seg_start == end and use_closed_attrib): if rel: _seg_start = seg_start - current_pos if current_pos is not None else seg_start else: _seg_start = seg_start parts.append('M {},{}'.format(_seg_start.real, _seg_start.imag)) if isinstance(segment, Line): if rel: _seg_end = segment.end - seg_start else: _seg_end = segment.end parts.append('L {},{}'.format(_seg_end.real, _seg_end.imag)) elif isinstance(segment, CubicBezier): if useSandT and segment.is_smooth_from(previous_segment, warning_on=False): if rel: _seg_control2 = segment.control2 - seg_start _seg_end = segment.end - seg_start else: _seg_control2 = segment.control2 _seg_end = segment.end args = (_seg_control2.real, _seg_control2.imag, _seg_end.real, _seg_end.imag) parts.append('S {},{} {},{}'.format(*args)) else: if rel: _seg_control1 = segment.control1 - seg_start _seg_control2 = segment.control2 - seg_start _seg_end = segment.end - seg_start else: _seg_control1 = segment.control1 _seg_control2 = segment.control2 _seg_end = segment.end args = (_seg_control1.real, _seg_control1.imag, _seg_control2.real, _seg_control2.imag, _seg_end.real, _seg_end.imag) parts.append('C {},{} {},{} {},{}'.format(*args)) elif isinstance(segment, QuadraticBezier): if useSandT and segment.is_smooth_from(previous_segment, warning_on=False): if rel: _seg_end = segment.end - seg_start else: _seg_end = segment.end args = _seg_end.real, _seg_end.imag parts.append('T {},{}'.format(*args)) else: if rel: _seg_control = segment.control - seg_start _seg_end = segment.end - seg_start else: _seg_control = segment.control _seg_end = segment.end args = (_seg_control.real, _seg_control.imag, _seg_end.real, _seg_end.imag) parts.append('Q {},{} {},{}'.format(*args)) elif isinstance(segment, Arc): if rel: _seg_end = segment.end - seg_start else: _seg_end = segment.end args = (segment.radius.real, segment.radius.imag, segment.rotation,int(segment.large_arc), int(segment.sweep),_seg_end.real, _seg_end.imag) parts.append('A {},{} {} {:d},{:d} {},{}'.format(*args)) current_pos = segment.end previous_segment = segment if self_closed: parts.append('Z') s = ' '.join(parts) return s if not rel else s.lower() class FilletAndChamfer(inkex.EffectExtension): def add_arguments(self, pars): pars.add_argument("-t", "--fillet_type", default="fillet", help="Selects whether using fillet or chamfer") pars.add_argument("-R", "--radius", type=float, default=60.0, help="The radius") pars.add_argument('--unit', default='px', help='units of measurement') pars.add_argument("--remove", type=inkex.Boolean, default=False, help="If True, control object will be removed") def addEle(self, ele, parent, props): # https://inkscape.org/~pacogarcia/%E2%98%85new-version-of-shapes-extension elem = etree.SubElement(parent, ele) for n in props: elem.set(n,props[n]) return elem def circle(self, c, r): return svgpathtools.parse_path("m %f,%f a %f,%f 0 0 1 -%f,%f %f,%f 0 0 1 -%f,-%f %f,%f 0 0 1 %f,-%f %f,%f 0 0 1 %f,%f z" % tuple((c.real+r, c.imag) + (r,)*16)) def _calc_fillet_for_joint(self, p, i): seg1 = p[(i) % len(p)] seg2 = p[(i+1) % len(p)] ori_p = svgpathtools.Path(seg1, seg2) new_p = svgpathtools.Path() # ignore the node if G1 continuity tg1 = seg1.unit_tangent(1.0) tg2 = seg2.unit_tangent(0.0) cosA = abs(tg1.real * tg2.real + tg1.imag * tg2.imag) if abs(cosA - 1.0) < 1e-6: new_p.append(seg1.cropped(self._prev_t, 1.0)) self._prev_t = 0.0 if self._very_first_t is None: self._very_first_t = 1.0 if not isclosedac(p) and i == len(p) - 2: new_p.append(seg2.cropped(0.0, 1.0)) # add last segment if not closed else: cir = self.circle(seg1.end, self.options.radius) # new_p.extend(cir) intersects = ori_p.intersect(cir) if len(intersects) != 2: inkex.errormsg("Some fillet or chamfer may not be drawn: %d intersections!" % len(intersects)) new_p.append(seg1.cropped(self._prev_t, 1.0)) self._prev_t = 0.0 if self._very_first_t is None: self._very_first_t = 1.0 if not isclosedac(p) and i == len(p) - 2: new_p.append(seg2.cropped(0.0, 1.0)) # add last segment if not closed else: cb = []; segs = []; ts = [] for (T1, seg1, t1), (T2, seg2, t2) in intersects: c1 = seg1.point(t1) tg1 = seg1.unit_tangent(t1) * (self.options.radius * KAPPA) cb.extend([c1, tg1]) segs.append(seg1); ts.append(t1) # cir1 = self.circle(c1, self.options.radius * KAPPA) # new_p.extend(cir1) # new_p.append(svgpathtools.Line(c1, c1+tg1)) assert len(cb) == 4 new_p.append(segs[0].cropped(self._prev_t, ts[0])) if self.options.fillet_type == 'fillet': fillet = svgpathtools.CubicBezier(cb[0], cb[0]+cb[1], cb[2]-cb[3], cb[2]) else: fillet = svgpathtools.Line(cb[0], cb[2]) new_p.append(fillet) self._prev_t = ts[1] if self._very_first_t is None: self._very_first_t = ts[0] if isclosedac(p) and i == len(p) - 1: new_p.append(segs[1].cropped(ts[1], self._very_first_t)) # update first segment if closed elif not isclosedac(p) and i == len(p) - 2: new_p.append(segs[1].cropped(ts[1], 1.0)) # add last segment if not closed # # fix for the first segment # if p.isclosed(): # new_p[0] = p[0].cropped(ts[1], self._very_first_t) # new_p.append(segs[0].cropped(ts[0], 1.0)) # new_p.append(segs[1].cropped(0.0, ts[1])) # if self.options.fillet_type == 'fillet': # fillet = svgpathtools.CubicBezier(cb[0], cb[0]+cb[1], cb[2]-cb[3], cb[2]) # else: # fillet = svgpathtools.Line(cb[0], cb[2]) # new_p.append(fillet.reversed()) return new_p def add_fillet_to_path(self, d): p = svgpathtools.parse_path(d) p = remove_zero_length_segments(p) # for z, a zero length line segment is possibly added if len(p) <= 1: return d new_p = svgpathtools.Path() self._prev_t = 0 # used as cache self._very_first_t = None # update first segment if closed if isclosedac(p): for i in range(len(p)): new_p.extend(self._calc_fillet_for_joint(p, i)) if not isclosedac(new_p): del new_p[0] # remove first segment if closed else: for i in range(len(p)-1): new_p.extend(self._calc_fillet_for_joint(p, i)) new_p = round_path(new_p, 6) # inkex.errormsg(d_str(new_p, use_closed_attrib=True, rel=True)) return d_str(new_p, use_closed_attrib=True, rel=True) def effect(self): self.options.radius = self.svg.unittouu(str(self.options.radius) + self.options.unit) if self.options.radius == 0: return for id, node in self.svg.selected.items(): _shape = etree.QName(node.tag).localname if _shape != "path": inkex.errormsg("Fillet and chamfer only operates on path: %s is %s" % (id, _shape)) else: # inkex.errormsg(etree.tostring(node)) attrib = {k:v for k,v in node.attrib.items()} attrib['d'] = self.add_fillet_to_path(attrib['d']) self.addEle(inkex.addNS('path','svg'), node.getparent(), attrib) if self.options.remove: node.delete() if __name__ == '__main__': FilletAndChamfer().run()