#! /usr/bin/python3 # # flatproj.py -- apply a transformation matrix to an svg object # # (C) 2019 Juergen Weigert # Distribute under GPLv2 or ask. # # recursivelyTraverseSvg() is originally from eggbot. Thank! # inkscape-paths2openscad and inkscape-silhouette contain copies of recursivelyTraverseSvg() # with almost identical features, but different inmplementation details. The version used here is derived from # inkscape-paths2openscad. # # --------------------------------------------------------------- # 2019-01-12, jw, v0.1 initial draught. Idea and an inx. No code, but a beer. # 2019-01-12, jw, v0.2 option parser drafted. inx refined. # 2019-01-14, jw, v0.3 creating dummy objects. scale and placing is correct. # 2019-01-15, jw, v0.4 correct stacking of middle layer objects. # 2019-01-16, jw, v0.5 standard and free projections done. enforce stroke-width option added. # 2019-01-19, jw, v0.6 slightly improved zcmp(). Not yet robust. # 2019-01-26, jw, v0.7 option autoscale done, proj_* attributes added to g. # 2019-03-10, jw, v0.8 using ZSort from src/zsort42.py -- code complete, needs debugging. # * fixed style massaging. No regexp, but disassembly into a dict # 2019-05-12, jw, v0.9 using zsort2d, no debugging needed, but code incomplete. # * obsoleted: fix zcmp() to implement correct depth sorting of quads # * obsoleted: fix zcmp() to sort edges always above their adjacent faces # 2019-06-012, jw, sorted(.... key=...) cannot do what we need. # Compare http://code.activestate.com/recipes/578272-topological-sort/ # https://en.wikipedia.org/wiki/Partially_ordered_set # 2019-06-26, jw, v0.9.1 Use TSort from src/tsort.py -- much better than my ZSort or zsort2d attempts. # Donald Knuth, taocp(2.2.3): "It is hard to imagine a faster algorithm for this problem!" # 2019-06-27, jw, v0.9.2 import SvgColor from src/svgcolor.py -- code added, still unused # 2019-06-28, jw, v0.9.3 added shading options. # 2019-07-01, jw, v0.9.4 Fixed manual rotation. # 2019-07-08, jw, v0.9.5 extra rotation added. We sometimes need out of order rotations. # # TODO: # * test: adjustment of line-width according to transformation. # * objects jump wildly when rotated. arrange them around their source. # --------------------------------------------------------------- # # Dimetric 7,42: Rotate(Y, 69.7 deg), Rotate(X, 19.4 deg) # Isometric: Rotate(Y, 45 deg), Rotate(X, degrees(atan(1/sqrt2))) # 35.26439 deg # # Isometric transformation example: # Ry = genRy(np.radians(45)) # Rx = genRx(np.radians(35.26439)) # np.matmul( np.matmul( [[0,0,-1], [1,0,0], [0,-1,0]], Ry ), Rx) # array([[-0.70710678, 0.40824829, -0.57735027], # [ 0.70710678, 0.40824829, -0.57735027], # [ 0. , -0.81649658, -0.57735027]]) # R = np.matmul(Ry, Rx) # np.matmul( [[0,0,-1], [1,0,0], [0,-1,0]], R ) # -> same as above :-) # # Extend an array of xy vectors array into xyz vectors # a = np.random.rand(5,2) * 100 # array([[ 86.85675737, 85.44421643], # [ 31.11925583, 11.41818619], # [ 71.83803221, 63.15662683], # [ 45.21094383, 75.48939099], # [ 63.8159168 , 49.47674044]]) # # b = np.zeros( (a.shape[0], 3) ) # b[:,:-1] = a # b += [0,0,33] # array([[ 86.85675737, 85.44421643, 33. ], # [ 31.11925583, 11.41818619, 33. ], # [ 71.83803221, 63.15662683, 33. ], # [ 45.21094383, 75.48939099, 33. ], # [ 63.8159168 , 49.47674044, 33. ]]) # np.matmul(b, R) # python2 compatibility: from __future__ import print_function import sys, time, functools import numpy as np # Tav's perspective extension also uses numpy. 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/') #! /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(). # 2019-01-12 jw, v1.7e debug output to self.tty # 2019-01-15 jw, v1.7f tunnel transform as third item into paths tuple. needed for style stroke-width adjustment. 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, cy, 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, file=self._svg.tty) 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(self, 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, mat) def objRoundedRect(self, x, y, w, h, rx, ry, node, mat): print("calling roundedRectBezier", file=self.tty) d = self._svg.roundedRectBezier(x, y, w, h, rx, ry) self._svg.getPathVertices(d, node, mat, self.smoothness) def objEllipse(self, cx, cy, 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, mat) 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, mat) 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.paths) # all coordinates in mm """ __version__ = "1.7f" 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. # FIXME: stroke-width depends on the current transformation matrix scale. """ 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: # FIXME: stroke-width depends on the current transformation matrix scale. 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, file=self.tty) 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*(