#! /usr/bin/python # # (C) 2018 juergen@fabmail.org # Distribute under GPL-2.0 or ask. # # References: # https://github.com/FreeCAD/FreeCAD/blob/master/src/Mod/Sketcher/TestSketcherApp.py # /usr/lib/freecad-daily/Mod/Sketcher/SketcherExample.py # https://en.wikipedia.org/wiki/Rytz%27s_construction#Computer_aided_solution # http://wiki.inkscape.org/wiki/index.php/Python_modules_for_extensions # https://en.wikipedia.org/wiki/Composite_B%C3%A9zier_curve # https://en.wikipedia.org/wiki/B-spline#Relationship_to_piecewise/composite_B%C3%A9zier # # v0.1 jw, initial draft refactoring inksvg to make it fit here. # v0.2 jw, Introducing class SketchPathGen to seperate the sketch generator from the svg parser. # v0.3 jw, correct _coord_from_svg() size and offset handling. Suppress # silly version printing, that would ruin an inkscape extension. # V0.4 jw, Added GuiDocument.xml for visibility and camera defaults. # Using BoundBox() to compute camera placement. # V0.5 jw, objEllipse() done correctly with _ellipse_vertices2d() # V0.6 jw, objArc() done. ArcOfCircle() is a strange beast with rotation and mirroring. # V0.7 jw, pathString() done. # V0.8 jw, imported class SubPathTracker() from src/examples/sketch_spline.py # from optparse import OptionParser import os, sys, math, re sys_platform = sys.platform.lower() if sys_platform.startswith('win'): sys.path.append('C:\Program Files\Inkscape\share\extensions') elif sys_platform.startswith('darwin'): sys.path.append('~/.config/inkscape/extensions') else: # Linux sys.path.append('/usr/share/inkscape/extensions/') import cubicsuperpath sys.path.append('/usr/lib/freecad-daily/lib/') # prefer daily over normal. sys.path.append('/usr/lib/freecad/lib/') verbose=-1 # -1=quiet, 0=normal, 1=babble epsilon = 0.00001 if verbose <= 0: os.dup2(1,99) # hack to avoid silly version string printing. f = open("/dev/null", "w") os.dup2(f.fileno(), 1) # The version printing code has # src/App/Application.cpp: if (!(mConfig["Verbose"] == "Strict")) # but we cannot call SetConfig('Verbose', 'Strict') early enough. from FreeCAD import Base, BoundBox sys.stdout.flush() # push silly version string into /dev/null if verbose <= 0: f.close() os.dup2(99,1) # back in cansas. import Part, Sketcher # causes SEGV if Base is not yet imported from FreeCAD import ProfileLib.RegularPolygon as Poly # CAUTION: Keep in sync with with svg2fcsketch.inx ca. line 3 and line 24 __version__ = '0.8' #! /usr/bin/python # # inksvg.py - parse an svg file into a plain list of paths. # # (C) 2017 juergen@fabmail.org, authors of eggbot and others. # # 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., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA # ################# # 2017-12-04 jw, v1.0 Refactored class InkSvg from cookiecutter extension # 2017-12-07 jw, v1.1 Added roundedRectBezier() # 2017-12-10 jw, v1.3 Added styleDasharray() with stroke-dashoffset # 2017-12-14 jw, v1.4 Added matchStrokeColor() # 2017-12-21 jw, v1.5 Changed getPathVertices() to construct a to self.paths list, instead of # a dictionary. (Preserving native ordering) # 2017-12-22 jw, v1.6 fixed "use" to avoid errors with unknown global symbal 'composeTransform' # 2017-12-25 jw, v1.7 Added getNodeStyle(), cssDictAdd(), expanded matchStrokeColor() to use # inline style defs. Added a warning message for not-implemented CSS styles. # v1.7a Added getNodeStyleOne() made getNodeStyle() recurse through parents. # 2018-03-10 jw, v1.7b Added search paths to find inkex. # v1.7c Refactoring for simpler interface without subclassing. # Added load(), getElementsByIds() methods. # 2018-03-21 jw, v1.7d Added handleViewBox() to load(). # Added traverse(). import gettext import re import sys sys_platform = sys.platform.lower() if sys_platform.startswith('win'): sys.path.append('C:\Program Files\Inkscape\share\extensions') elif sys_platform.startswith('darwin'): sys.path.append('~/.config/inkscape/extensions') else: # Linux sys.path.append('/usr/share/inkscape/extensions/') import inkex import simplepath import simplestyle import simpletransform import cubicsuperpath import cspsubdiv import bezmisc from lxml import etree class PathGenerator(): """ A PathGenerator has methods for different svg objects. It compiles an internal representation of them all, handling transformations and linear interpolation of curved path segments. The base class PathGenerator is dummy (abstract) class that raises an NotImplementedError() on each method entry point. It serves as documentation for the generator interface. """ def __init__(self): self._svg = None def registerSvg(self, svg): self._svg = svg svg.stats = self.stats def pathString(self, d, node, mat): """ d is expected formatted as an svg path string here. """ raise NotImplementedError("See example inksvg.LinearPathGen.pathString()") def pathList(self, d, node, mat): """ d is expected as an [[cmd, [args]], ...] arrray """ raise NotImplementedError("See example inksvg.LinearPathGen.pathList()") def objRect(x, y, w, h, node, mat): raise NotImplementedError("See example inksvg.LinearPathGen.objRect()") def objRoundedRect(self, x, y, w, h, rx, ry, node, mat): raise NotImplementedError("See example inksvg.LinearPathGen.objRoundedRect()") def objEllipse(self, cx, xy, rx, ry, node, mat): raise NotImplementedError("See example inksvg.LinearPathGen.objEllipse()") def objArc(self, d, cx, cy, rx, ry, st, en, cl, node, mat): """ SVG does not have an arc element. Inkscape creates officially a path element, but also (redundantly) provides the original arc values. Implementations can choose to work with the path d and ignore the rest, or work with the cx, cy, rx, ry, ... parameters and ignore d. Note: the parameter closed=True/False is actually derived from looking at the last command of path d. Hackish, but there is no 'sodipodi:closed' element, or similar. """ raise NotImplementedError("See example inksvg.LinearPathGen.objArc()") class LinearPathGen(PathGenerator): def __init__(self, smoothness=0.2): self.smoothness = max(0.0001, smoothness) def pathString(self, d, node, mat): """ d is expected formatted as an svg path string here. """ print("calling getPathVertices", self.smoothness) self._svg.getPathVertices(d, node, mat, self.smoothness) def pathList(self, d, node, mat): """ d is expected as an [[cmd, [args]], ...] arrray """ return self.pathString(simplepath.formatPath(d), node, mat) def objRect(x, y, w, h, node, mat): """ Manually transform into I.e., explicitly draw three sides of the rectangle and the fourth side implicitly """ a = [] a.append(['M ', [x, y]]) a.append([' l ', [w, 0]]) a.append([' l ', [0, h]]) a.append([' l ', [-w, 0]]) a.append([' Z', []]) self.pathList(a, node, matNew) def objRoundedRect(self, x, y, w, h, rx, ry, node, mat): print("calling roundedRectBezier") d = self._svg.roundedRectBezier(x, y, w, h, rx, ry) self._svg.getPathVertices(d, node, mat, self.smoothness) def objEllipse(self, cx, xy, rx, ry, node, mat): """ Convert circles and ellipses to a path with two 180 degree arcs. In general (an ellipse), we convert to where X1 = CX - RX X2 = CX + RX Note: ellipses or circles with a radius attribute of value 0 are ignored """ x1 = cx - rx x2 = cx + rx d = 'M %f,%f ' % (x1, cy) + \ 'A %f,%f ' % (rx, ry) + \ '0 1 0 %f,%f ' % (x2, cy) + \ 'A %f,%f ' % (rx, ry) + \ '0 1 0 %f,%f' % (x1, cy) self.pathString(d, node, matNew) def objArc(self, d, cx, cy, rx, ry, st, en, cl, node, mat): """ We ignore the cx, cy, rx, ry data, and are happy that inkscape also provides the same information as a path. """ self.pathString(d, node, matNew) class InkSvg(): """ Usage example with subclassing: # # class ThunderLaser(inkex.Effect): # def __init__(self): # inkex.localize() # inkex.Effect.__init__(self) # def effect(self): # svg = InkSvg(document=self.document, pathgen=LinearPathGen(smoothness=0.2)) # svg.handleViewBox() # svg.recursivelyTraverseSvg(self.document.getroot(), svg.docTransform) # for tup in svg.paths: # node = tup[0] # ... # e = ThunderLaser() # e.affect() # Simple usage example with method invocation: # svg = InkSvg(pathgen=LinearPathGen(smoothness=0.01)) # svg.load(svgfile) # svg.traverse([ids...]) # print(svg.pathgen.path) """ __version__ = "1.7c" DEFAULT_WIDTH = 100 DEFAULT_HEIGHT = 100 # imports from inkex NSS = inkex.NSS def getElementsByIds(self, ids): """ ids be a string of a comma seperated values, or a list of strings. Returns a list of xml nodes. """ if not self.document: raise ValueError("no document loaded.") if isinstance(ids, (bytes, str)): ids = [ ids ] # handle some scalars ids = ','.join(ids).split(',') # merge into a string and re-split ## OO-Fail: # cannot use inkex.getElementById() -- it returns only the first element of each hit. # cannot use inkex.getselected() -- it returns the last element of each hit only. """Collect selected nodes""" nodes = [] for id in ids: if id != '': # empty strings happen after splitting... path = '//*[@id="%s"]' % id el_list = self.document.xpath(path, namespaces=InkSvg.NSS) if el_list: for node in el_list: nodes.append(node) else: raise ValueError("id "+id+" not found in the svg document.") return nodes def load(self, filename): inkex.localize() # OO-Fail: cannot call inkex.Effect.parse(), Effect constructor has so many side-effects. stream = open(filename, 'r') p = etree.XMLParser(huge_tree=True) self.document = etree.parse(stream, parser=p) stream.close() # initialize a coordinate system that can be picked up by pathgen. self.handleViewBox() def traverse(self, ids=None): """ Recursively traverse the SVG document. If ids are given, all matching nodes are taken as start positions for traversal. Otherwise traveral starts at the root node of the document. """ selected = [] if ids is not None: selected = self.getElementsByIds(ids) if len(selected): # Traverse the selected objects for node in selected: transform = self.recursivelyGetEnclosingTransform(node) self.recursivelyTraverseSvg([node], transform) else: # Traverse the entire document building new, transformed paths self.recursivelyTraverseSvg(self.document.getroot(), self.docTransform) def getNodeStyleOne(self, node): """ Finds style declarations by .class, #id or by tag.class syntax, and of course by a direct style='...' attribute. """ sheet = '' selectors = [] classes = node.get('class', '') # classes == None can happen here. if classes is not None and classes != '': selectors = ["."+cls for cls in re.split('[\s,]+', classes)] selectors += [node.tag+sel for sel in selectors] node_id = node.get('id', '') if node_id is not None and node_id != '': selectors += [ "#"+node_id ] for sel in selectors: if sel in self.css_dict: sheet += '; '+self.css_dict[sel] style = node.get('style', '') if style is not None and style != '': sheet += '; '+style return simplestyle.parseStyle(sheet) def getNodeStyle(self, node): """ Recurse into parent group nodes, like simpletransform.ComposeParents Calling getNodeStyleOne() for each. """ combined_style = {} parent = node.getparent() if parent.tag == inkex.addNS('g','svg') or parent.tag == 'g': combined_style = self.getNodeStyle(parent) style = self.getNodeStyleOne(node) for s in style: combined_style[s] = style[s] # overwrite or add return combined_style def styleDasharray(self, path_d, node): """ Check the style of node for a stroke-dasharray, and apply it to the path d returning the result. d is returned unchanged, if no stroke-dasharray was found. ## Extracted from inkscape extension convert2dashes; original ## comments below. ## Added stroke-dashoffset handling, made it a universal operator ## on nodes and 'd' paths. This extension converts a path into a dashed line using 'stroke-dasharray' It is a modification of the file addnodes.py Copyright (C) 2005,2007 Aaron Spike, aaron@ekips.org Copyright (C) 2009 Alvin Penner, penner@vaxxine.com """ def tpoint((x1,y1), (x2,y2), t = 0.5): return [x1+t*(x2-x1),y1+t*(y2-y1)] def cspbezsplit(sp1, sp2, t = 0.5): m1=tpoint(sp1[1],sp1[2],t) m2=tpoint(sp1[2],sp2[0],t) m3=tpoint(sp2[0],sp2[1],t) m4=tpoint(m1,m2,t) m5=tpoint(m2,m3,t) m=tpoint(m4,m5,t) return [[sp1[0][:],sp1[1][:],m1], [m4,m,m5], [m3,sp2[1][:],sp2[2][:]]] def cspbezsplitatlength(sp1, sp2, l = 0.5, tolerance = 0.001): bez = (sp1[1][:],sp1[2][:],sp2[0][:],sp2[1][:]) t = bezmisc.beziertatlength(bez, l, tolerance) return cspbezsplit(sp1, sp2, t) def cspseglength(sp1,sp2, tolerance = 0.001): bez = (sp1[1][:],sp1[2][:],sp2[0][:],sp2[1][:]) return bezmisc.bezierlength(bez, tolerance) style = self.getNodeStyle(node) if not style.has_key('stroke-dasharray'): return path_d dashes = [] if style['stroke-dasharray'].find(',') > 0: dashes = [float (dash) for dash in style['stroke-dasharray'].split(',') if dash] if not dashes: return path_d dashoffset = 0.0 if style.has_key('stroke-dashoffset'): dashoffset = float(style['stroke-dashoffset']) if dashoffset < 0.0: dashoffset = 0.0 if dashoffset > dashes[0]: dashoffset = dashes[0] # avoids a busy-loop below! p = cubicsuperpath.parsePath(path_d) new = [] for sub in p: idash = 0 dash = dashes[0] # print("initial dash length: ", dash, dashoffset) dash = dash - dashoffset length = 0 new.append([sub[0][:]]) i = 1 while i < len(sub): dash = dash - length length = cspseglength(new[-1][-1], sub[i]) while dash < length: new[-1][-1], next, sub[i] = cspbezsplitatlength(new[-1][-1], sub[i], dash/length) if idash % 2: # create a gap new.append([next[:]]) else: # splice the curve new[-1].append(next[:]) length = length - dash idash = (idash + 1) % len(dashes) dash = dashes[idash] if idash % 2: new.append([sub[i]]) else: new[-1].append(sub[i]) i+=1 return cubicsuperpath.formatPath(new) def matchStrokeColor(self, node, rgb, eps=None, avg=True): """ Return True if the line color found in the style attribute of elem does not differ from rgb in any of the components more than eps. The default eps with avg=True is 64. With avg=False the default is eps=85 (33% on a 0..255 scale). In avg mode, the average of all three color channel differences is compared against eps. Otherwise each color channel difference is compared individually. The special cases None, False, True for rgb are interpreted logically. Otherwise rgb is expected as a list of three integers in 0..255 range. Missing style attribute or no stroke element is interpreted as False. Unparseable stroke elements are interpreted as 'black' (0,0,0). Hexadecimal stroke formats of '#RRGGBB' or '#RGB' are understood as well as 'rgb(100%, 0%, 0%) or 'red' relying on simplestyle. """ if eps is None: eps = 64 if avg == True else 85 if rgb is None or rgb is False: return False if rgb is True: return True style = self.getNodeStyle(node) s = style.get('stroke', '') if s == '': return False c = simplestyle.parseColor(s) if sum: s = abs(rgb[0]-c[0]) + abs(rgb[1]-c[1]) + abs(rgb[2]-c[2]) if s < 3*eps: return True return False if abs(rgb[0]-c[0]) > eps: return False if abs(rgb[1]-c[1]) > eps: return False if abs(rgb[2]-c[2]) > eps: return False return True def cssDictAdd(self, text): """ Represent css cdata as a hash in css_dict. Implements what is seen on: http://www.blooberry.com/indexdot/css/examples/cssembedded.htm """ text=re.sub('^\s*( %s """ % ('Sketch_'+docname, camera_xml) try: import zipfile z = zipfile.ZipFile(fcstdfile, 'a') z.writestr('GuiDocument.xml', guidoc_xml) z.close() except: print(guidoc_xml) print("Warning: Failed to add GuiDocument.xml to %s -- camera and visibility are undefined." % fcstdfile) if verbose > -1: print("%s written." % fcstdfile) if not options.outfile: sys.stdout.write(open(fcstdfile).read()) os.unlink(fcstdfile)