From fdf809774f5688e7ae6a38239d3ec152dfe09240 Mon Sep 17 00:00:00 2001 From: Juan Pablo Caram Date: Fri, 18 Dec 2015 12:49:52 -0500 Subject: [PATCH] Basic support for importing SVG. Via shell only at this time. See issue #179. --- FlatCAMApp.py | 45 +++++++ PlotCanvas.py | 2 +- camlib.py | 41 ++++++- svgparse.py | 268 ++++++++++++++++++++++++++++++++++++++++++ tests/svg/drawing.svg | 126 ++++++++++++++++++++ 5 files changed, 480 insertions(+), 2 deletions(-) create mode 100644 svgparse.py create mode 100644 tests/svg/drawing.svg diff --git a/FlatCAMApp.py b/FlatCAMApp.py index 9e4eab4d..9ed0c8e3 100644 --- a/FlatCAMApp.py +++ b/FlatCAMApp.py @@ -1604,6 +1604,34 @@ class App(QtCore.QObject): else: self.inform.emit("Project copy saved to: " + self.project_filename) + def import_svg(self, filename, outname=None): + """ + Adds a new Geometry Object to the projects and populates + it with shapes extracted from the SVG file. + + :param filename: Path to the SVG file. + :param outname: + :return: + """ + + def obj_init(geo_obj, app_obj): + + geo_obj.import_svg(filename) + + with self.proc_container.new("Importing SVG") as proc: + + # Object name + name = outname or filename.split('/')[-1].split('\\')[-1] + + self.new_object("geometry", name, obj_init) + + # TODO: No support for this yet. + # Register recent file + # self.file_opened.emit("gerber", filename) + + # GUI feedback + self.inform.emit("Opened: " + filename) + def open_gerber(self, filename, follow=False, outname=None): """ Opens a Gerber file, parses it and creates a new object for @@ -1959,6 +1987,17 @@ class App(QtCore.QObject): return a, kwa + def import_svg(filename, *args): + a, kwa = h(*args) + types = {'outname': str} + + for key in kwa: + if key not in types: + return 'Unknown parameter: %s' % key + kwa[key] = types[key](kwa[key]) + + self.import_svg(str(filename), **kwa) + def open_gerber(filename, *args): a, kwa = h(*args) types = {'follow': bool, @@ -2556,6 +2595,12 @@ class App(QtCore.QObject): 'fcn': shelp, 'help': "Shows list of commands." }, + 'import_svg': { + 'fcn': import_svg, + 'help': "Import an SVG file as a Geometry Object.\n" + + "> import_svg " + + " filename: Path to the file to import." + }, 'open_gerber': { 'fcn': open_gerber, 'help': "Opens a Gerber file.\n' +" diff --git a/PlotCanvas.py b/PlotCanvas.py index 0fdef5cb..c7fb19b7 100644 --- a/PlotCanvas.py +++ b/PlotCanvas.py @@ -124,7 +124,7 @@ class PlotCanvas: def connect(self, event_name, callback): """ - Attach an event handler to the canvas through the native GTK interface. + Attach an event handler to the canvas through the native Qt interface. :param event_name: Name of the event :type event_name: str diff --git a/camlib.py b/camlib.py index ae85ddaa..15ef5c0f 100644 --- a/camlib.py +++ b/camlib.py @@ -42,6 +42,16 @@ import simplejson as json # TODO: Commented for FlatCAM packaging with cx_freeze #from matplotlib.pyplot import plot, subplot +import xml.etree.ElementTree as ET +from svg.path import Path, Line, Arc, CubicBezier, QuadraticBezier, parse_path +import itertools + +import xml.etree.ElementTree as ET +from svg.path import Path, Line, Arc, CubicBezier, QuadraticBezier, parse_path + + +from svgparse import * + import logging log = logging.getLogger('base2') @@ -193,7 +203,6 @@ class Geometry(object): return interiors - def get_exteriors(self, geometry=None): """ Returns all exteriors of polygons in geometry. Uses @@ -344,6 +353,36 @@ class Geometry(object): return False + def import_svg(self, filename): + """ + Imports shapes from an SVG file into the object's geometry. + + :param filename: Path to the SVG file. + :return: None + """ + + # Parse into list of shapely objects + svg_tree = ET.parse(filename) + svg_root = svg_tree.getroot() + + # Change origin to bottom left + h = float(svg_root.get('height')) + # w = float(svg_root.get('width')) + geos = getsvggeo(svg_root) + geo_flip = [translate(scale(g, 1.0, -1.0, origin=(0, 0)), yoff=h) for g in geos] + + # Add to object + if self.solid_geometry is None: + self.solid_geometry = [] + + if type(self.solid_geometry) is list: + self.solid_geometry.append(cascaded_union(geo_flip)) + else: # It's shapely geometry + self.solid_geometry = cascaded_union([self.solid_geometry, + cascaded_union(geo_flip)]) + + return + def size(self): """ Returns (width, height) of rectangular diff --git a/svgparse.py b/svgparse.py new file mode 100644 index 00000000..89f858e7 --- /dev/null +++ b/svgparse.py @@ -0,0 +1,268 @@ +############################################################ +# FlatCAM: 2D Post-processing for Manufacturing # +# http://flatcam.org # +# Author: Juan Pablo Caram (c) # +# Date: 12/18/2015 # +# MIT Licence # +############################################################ + +import xml.etree.ElementTree as ET +import re +import itertools +from svg.path import Path, Line, Arc, CubicBezier, QuadraticBezier, parse_path +from shapely.geometry import LinearRing, LineString +from shapely.affinity import translate, rotate, scale, skew, affine_transform + + +def path2shapely(path, res=1.0): + """ + Converts an svg.path.Path into a Shapely + LinearRing or LinearString. + + :rtype : LinearRing + :rtype : LineString + :param path: svg.path.Path instance + :param res: Resolution (minimum step along path) + :return: Shapely geometry object + """ + points = [] + + for component in path: + + # Line + if isinstance(component, Line): + start = component.start + x, y = start.real, start.imag + if len(points) == 0 or points[-1] != (x, y): + points.append((x, y)) + end = component.end + points.append((end.real, end.imag)) + continue + + # Arc, CubicBezier or QuadraticBezier + if isinstance(component, Arc) or \ + isinstance(component, CubicBezier) or \ + isinstance(component, QuadraticBezier): + length = component.length(res / 10.0) + steps = int(length / res + 0.5) + frac = 1.0 / steps + print length, steps, frac + for i in range(steps): + point = component.point(i * frac) + x, y = point.real, point.imag + if len(points) == 0 or points[-1] != (x, y): + points.append((x, y)) + end = component.point(1.0) + points.append((end.real, end.imag)) + continue + + print "I don't know what this is:", component + continue + + if path.closed: + return LinearRing(points) + else: + return LineString(points) + + +def svgrect2shapely(rect): + w = float(rect.get('width')) + h = float(rect.get('height')) + x = float(rect.get('x')) + y = float(rect.get('y')) + pts = [ + (x, y), (x + w, y), (x + w, y + h), (x, y + h), (x, y) + ] + return LinearRing(pts) + + +def getsvggeo(node): + """ + Extracts and flattens all geometry from an SVG node + into a list of Shapely geometry. + + :param node: + :return: + """ + kind = re.search('(?:\{.*\})?(.*)$', node.tag).group(1) + geo = [] + + # Recurse + if len(node) > 0: + for child in node: + subgeo = getsvggeo(child) + if subgeo is not None: + geo += subgeo + + # Parse + elif kind == 'path': + print "***PATH***" + P = parse_path(node.get('d')) + P = path2shapely(P) + geo = [P] + + elif kind == 'rect': + print "***RECT***" + R = svgrect2shapely(node) + geo = [R] + + else: + print "Unknown kind:", kind + geo = None + + # Transformations + if 'transform' in node.attrib: + trstr = node.get('transform') + trlist = parse_svg_transform(trstr) + print trlist + + # Transformations are applied in reverse order + for tr in trlist[::-1]: + if tr[0] == 'translate': + geo = [translate(geoi, tr[1], tr[2]) for geoi in geo] + elif tr[0] == 'scale': + geo = [scale(geoi, tr[0], tr[1], origin=(0, 0)) + for geoi in geo] + elif tr[0] == 'rotate': + geo = [rotate(geoi, tr[1], origin=(tr[2], tr[3])) + for geoi in geo] + elif tr[0] == 'skew': + geo = [skew(geoi, tr[1], tr[2], origin=(0, 0)) + for geoi in geo] + elif tr[0] == 'matrix': + geo = [affine_transform(geoi, tr[1:]) for geoi in geo] + else: + raise Exception('Unknown transformation: %s', tr) + + return geo + + +def parse_svg_transform(trstr): + """ + Parses an SVG transform string into a list + of transform names and their parameters. + + Possible transformations are: + + * Translate: translate( []), which specifies + a translation by tx and ty. If is not provided, + it is assumed to be zero. Result is + ['translate', tx, ty] + + * Scale: scale( []), which specifies a scale operation + by sx and sy. If is not provided, it is assumed to be + equal to . Result is: ['scale', sx, sy] + + * Rotate: rotate( [ ]), which specifies + a rotation by degrees about a given point. + If optional parameters and are not supplied, + the rotate is about the origin of the current user coordinate + system. Result is: ['rotate', rotate-angle, cx, cy] + + * Skew: skewX(), which specifies a skew + transformation along the x-axis. skewY(), which + specifies a skew transformation along the y-axis. + Result is ['skew', angle-x, angle-y] + + * Matrix: matrix( ), which specifies a + transformation in the form of a transformation matrix of six + values. matrix(a,b,c,d,e,f) is equivalent to applying the + transformation matrix [a b c d e f]. Result is + ['matrix', a, b, c, d, e, f] + + :param trstr: SVG transform string. + :type trstr: str + :return: List of transforms. + :rtype: list + """ + trlist = [] + + assert isinstance(trstr, str) + trstr = trstr.strip(' ') + + num_re_str = r'[\+\-]?[0-9\.e]+' # TODO: Negative exponents missing + comma_or_space_re_str = r'(?:(?:\s+)|(?:\s*,\s*))' + translate_re_str = r'translate\s*\(\s*(' + \ + num_re_str + r')' + \ + r'(?:' + comma_or_space_re_str + \ + r'(' + num_re_str + r'))?\s*\)' + scale_re_str = r'scale\s*\(\s*(' + \ + num_re_str + r')' + \ + r'(?:' + comma_or_space_re_str + \ + r'(' + num_re_str + r'))?\s*\)' + skew_re_str = r'skew([XY])\s*\(\s*(' + \ + num_re_str + r')\s*\)' + rotate_re_str = r'rotate\s*\(\s*(' + \ + num_re_str + r')' + \ + r'(?:' + comma_or_space_re_str + \ + r'(' + num_re_str + r')' + \ + comma_or_space_re_str + \ + r'(' + num_re_str + r'))?\*\)' + matrix_re_str = r'matrix\s*\(\s*' + \ + r'(' + num_re_str + r')' + comma_or_space_re_str + \ + r'(' + num_re_str + r')' + comma_or_space_re_str + \ + r'(' + num_re_str + r')' + comma_or_space_re_str + \ + r'(' + num_re_str + r')' + comma_or_space_re_str + \ + r'(' + num_re_str + r')' + comma_or_space_re_str + \ + r'(' + num_re_str + r')\s*\)' + + while len(trstr) > 0: + match = re.search(r'^' + translate_re_str, trstr) + if match: + trlist.append([ + 'translate', + float(match.group(1)), + float(match.group(2)) if match.group else 0.0 + ]) + trstr = trstr[len(match.group(0)):].strip(' ') + continue + + match = re.search(r'^' + scale_re_str, trstr) + if match: + trlist.append([ + 'translate', + float(match.group(1)), + float(match.group(2)) if match.group else float(match.group(1)) + ]) + trstr = trstr[len(match.group(0)):].strip(' ') + continue + + match = re.search(r'^' + skew_re_str, trstr) + if match: + trlist.append([ + 'skew', + float(match.group(2)) if match.group(1) == 'X' else 0.0, + float(match.group(2)) if match.group(1) == 'Y' else 0.0 + ]) + trstr = trstr[len(match.group(0)):].strip(' ') + continue + + match = re.search(r'^' + rotate_re_str, trstr) + if match: + trlist.append([ + 'rotate', + float(match.group(1)), + float(match.group(2)) if match.group(2) else 0.0, + float(match.group(3)) if match.group(3) else 0.0 + ]) + trstr = trstr[len(match.group(0)):].strip(' ') + continue + + match = re.search(r'^' + matrix_re_str, trstr) + if match: + trlist.append(['matrix'] + [float(x) for x in match.groups()]) + trstr = trstr[len(match.group(0)):].strip(' ') + continue + + raise Exception("Don't know how to parse: %s" % trstr) + + return trlist + + +if __name__ == "__main__": + tree = ET.parse('tests/svg/drawing.svg') + root = tree.getroot() + ns = re.search(r'\{(.*)\}', root.tag).group(1) + print ns + for geo in getsvggeo(root): + print geo \ No newline at end of file diff --git a/tests/svg/drawing.svg b/tests/svg/drawing.svg new file mode 100644 index 00000000..7feb03a4 --- /dev/null +++ b/tests/svg/drawing.svg @@ -0,0 +1,126 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + +