#! /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)