diff --git a/extensions/fablabchemnitz/svg2fcsketch/svg2fcsketch.inx b/extensions/fablabchemnitz/svg2fcsketch/svg2fcsketch.inx new file mode 100644 index 0000000..be70cbc --- /dev/null +++ b/extensions/fablabchemnitz/svg2fcsketch/svg2fcsketch.inx @@ -0,0 +1,25 @@ + + + FreeCAD Sketch Export + fablabchemnitz.de.svg2fcsketch + + + true + + + + + + + + + .fcstd + text/plain + FreeCAD-0.20 Sketch (*.fcstd) + Export path objects to a FreeCAD sketch file + true + + + \ No newline at end of file diff --git a/extensions/fablabchemnitz/svg2fcsketch/svg2fcsketch.py b/extensions/fablabchemnitz/svg2fcsketch/svg2fcsketch.py new file mode 100644 index 0000000..587fe4c --- /dev/null +++ b/extensions/fablabchemnitz/svg2fcsketch/svg2fcsketch.py @@ -0,0 +1,1919 @@ +#! /usr/bin/python3 +''' +(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 + +----------------------- + +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(). +''' + +from optparse import OptionParser +import os +import sys +import math +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/') +sys.path.append('/usr/lib64/freecad-daily/lib64/') # prefer daily over normal. +sys.path.append('/usr/lib64/freecad/lib64/') + +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 +import gettext +import re +import inkex +from inkex import Transform, 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. + """ + inkex.utils.debug("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(str(d.path), 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): + inkex.utils.debug("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...]) + # inkex.utils.debug(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.localization.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 dict(inkex.Style.parse_str(sheet)) + + def getNodeStyle(self, node): + 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(p1, p2, t = 0.5): + x1 = p1[0] + y1 = p1[1] + x2 = p2[0] + y2 = p2[1] + 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(d) + new = [] + for sub in p: + idash = 0 + dash = dashes[0] + # inkex.utils.debug("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(new).to_path(curves_only=True) + + 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'. + """ + 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 = inkex.Color(s).to_rgb() + 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: + inkex.utils.debug(guidoc_xml) + inkex.utils.debug("Warning: Failed to add GuiDocument.xml to %s -- camera and visibility are undefined." % fcstdfile) + +if verbose > -1: + inkex.utils.debug("%s written." % fcstdfile) + +if not options.outfile: + out = open(fcstdfile,'rb') + sys.stdout.buffer.write(out.read()) + out.close() + os.unlink(fcstdfile) \ No newline at end of file