1984 lines
79 KiB
Python
1984 lines
79 KiB
Python
#! /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
|
|
|
|
<rect x="X" y="Y" width="W" height="H"/>
|
|
|
|
into
|
|
|
|
<path d="MX,Y lW,0 l0,H l-W,0 z"/>
|
|
|
|
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
|
|
|
|
<ellipse rx="RX" ry="RY" cx="X" cy="Y"/>
|
|
|
|
to
|
|
|
|
<path d="MX1,CY A RX,RY 0 1 0 X2,CY A RX,RY 0 1 0 X1,CY"/>
|
|
|
|
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*', '', text)
|
|
while True:
|
|
try:
|
|
(keys, rest) = text.split('{', 1)
|
|
except:
|
|
break
|
|
keys = re.sub('/\*.*?\*/', ' ', keys) # replace comments with whitespace
|
|
keys = re.split('[\s,]+', keys) # convert to list
|
|
while '' in keys:
|
|
keys.remove('') # remove empty elements (at start or end)
|
|
(val,text) = rest.split('}', 1)
|
|
val = re.sub('/\*.*?\*/', '', val) # replace comments nothing in values
|
|
val = re.sub('\s+', ' ', val).strip() # normalize whitespace
|
|
for k in keys:
|
|
if not k in self.css_dict:
|
|
self.css_dict[k] = val
|
|
else:
|
|
self.css_dict[k] += '; '+val
|
|
|
|
|
|
def roundedRectBezier(self, x, y, w, h, rx, ry=0):
|
|
"""
|
|
Draw a rectangle of size w x h, at start point x, y with the corners rounded by radius
|
|
rx and ry. Each corner is a quarter of an ellipsis, where rx and ry are the horizontal
|
|
and vertical dimenstion.
|
|
A pathspec according to https://www.w3.org/TR/SVG/paths.html#PathDataEllipticalArcCommands
|
|
is returned. Very similar to what inkscape would do when converting object to path.
|
|
Inkscape seems to use a kappa value of 0.553, higher precision is used here.
|
|
|
|
x=0, y=0, w=200, h=100, rx=50, ry=30 produces in inkscape
|
|
d="m 50,0 h 100 c 27.7,0 50,13.38 50,30 v 40 c 0,16.62 -22.3,30 -50,30
|
|
H 50 C 22.3,100 0,86.62 0,70 V 30 C 0,13.38 22.3,0 50,0 Z"
|
|
It is unclear, why there is a Z, the last point is identical with the first already.
|
|
It is unclear, why half of the commands use relative and half use absolute coordinates.
|
|
We do it all in relative coords, except for the initial M, and we ommit the Z.
|
|
"""
|
|
if rx < 0: rx = 0
|
|
if rx > 0.5*w: rx = 0.5*w
|
|
if ry < 0: ry = 0
|
|
if ry > 0.5*h: ry = 0.5*h
|
|
if ry < 0.0000001: ry = rx
|
|
k = 0.5522847498307933984022516322796 # kappa, handle length for a 4-point-circle.
|
|
d = "M %f,%f h %f " % (x+rx, y, w-rx-rx) # top horizontal to right
|
|
d += "c %f,%f %f,%f %f,%f " % (rx*k,0, rx,ry*(1-k), rx,ry) # top right ellipse
|
|
d += "v %f " % (h-ry-ry) # right vertical down
|
|
d += "c %f,%f %f,%f %f,%f " % (0,ry*k, rx*(k-1),ry, -rx,ry) # bottom right ellipse
|
|
d += "h %f " % (-w+rx+rx) # bottom horizontal to left
|
|
d += "c %f,%f %f,%f %f,%f " % (-rx*k,0, -rx,ry*(k-1), -rx,-ry) # bottom left ellipse
|
|
d += "v %f " % (-h+ry+ry) # left vertical up
|
|
d += "c %f,%f %f,%f %f,%f" % (0,-ry*k, rx*(1-k),-ry, rx,-ry) # top left ellipse
|
|
return d
|
|
|
|
|
|
def subdivideCubicPath(self, sp, flat, i=1):
|
|
'''
|
|
[ Lifted from eggbot.py with impunity ]
|
|
|
|
Break up a bezier curve into smaller curves, each of which
|
|
is approximately a straight line within a given tolerance
|
|
(the "smoothness" defined by [flat]).
|
|
|
|
This is a modified version of cspsubdiv.cspsubdiv(): rewritten
|
|
because recursion-depth errors on complicated line segments
|
|
could occur with cspsubdiv.cspsubdiv().
|
|
'''
|
|
|
|
while True:
|
|
while True:
|
|
if i >= len(sp):
|
|
return
|
|
|
|
p0 = sp[i - 1][1]
|
|
p1 = sp[i - 1][2]
|
|
p2 = sp[i][0]
|
|
p3 = sp[i][1]
|
|
|
|
b = (p0, p1, p2, p3)
|
|
|
|
if cspsubdiv.maxdist(b) > flat:
|
|
break
|
|
|
|
i += 1
|
|
|
|
one, two = bezmisc.beziersplitatt(b, 0.5)
|
|
sp[i - 1][2] = one[1]
|
|
sp[i][0] = two[2]
|
|
p = [one[2], one[3], two[1]]
|
|
sp[i:1] = [p]
|
|
|
|
def parseLengthWithUnits(self, str, default_unit='px'):
|
|
'''
|
|
Parse an SVG value which may or may not have units attached
|
|
This version is greatly simplified in that it only allows: no units,
|
|
units of px, and units of %. Everything else, it returns None for.
|
|
There is a more general routine to consider in scour.py if more
|
|
generality is ever needed.
|
|
With inkscape 0.91 we need other units too: e.g. svg:width="400mm"
|
|
'''
|
|
|
|
u = default_unit
|
|
s = str.strip()
|
|
if s[-2:] in ('px', 'pt', 'pc', 'mm', 'cm', 'in', 'ft'):
|
|
u = s[-2:]
|
|
s = s[:-2]
|
|
elif s[-1:] in ('m', '%'):
|
|
u = s[-1:]
|
|
s = s[:-1]
|
|
|
|
try:
|
|
v = float(s)
|
|
except:
|
|
return None, None
|
|
|
|
return v, u
|
|
|
|
|
|
def __init__(self, document=None, svgfile=None, smoothness=0.2, pathgen=LinearPathGen(smoothness=0.2)):
|
|
"""
|
|
Usage: ...
|
|
"""
|
|
self.dpi = 90.0 # factored out for inkscape-0.92
|
|
self.px_used = False # raw px unit depends on correct dpi.
|
|
self.xmin, self.xmax = (1.0E70, -1.0E70)
|
|
self.ymin, self.ymax = (1.0E70, -1.0E70)
|
|
|
|
# CAUTION: smoothness here is deprecated. it belongs into pathgen, if.
|
|
# CAUTION: smoothness == 0.0 leads to a busy-loop.
|
|
self.smoothness = max(0.0001, smoothness) # 0.0001 .. 5.0
|
|
self.pathgen = pathgen
|
|
pathgen.registerSvg(self)
|
|
|
|
# List of paths we will construct. Path lists are paired with the SVG node
|
|
# they came from. Such pairing can be useful when you actually want
|
|
# to go back and update the SVG document, or retrieve e.g. style information.
|
|
self.paths = []
|
|
|
|
# cssDictAdd collects style definitions here:
|
|
self.css_dict = {}
|
|
|
|
# For handling an SVG viewbox attribute, we will need to know the
|
|
# values of the document's <svg> width and height attributes as well
|
|
# as establishing a transform from the viewbox to the display.
|
|
|
|
self.docWidth = float(self.DEFAULT_WIDTH)
|
|
self.docHeight = float(self.DEFAULT_HEIGHT)
|
|
self.docTransform = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]
|
|
|
|
# Dictionary of warnings issued. This to prevent from warning
|
|
# multiple times about the same problem
|
|
self.warnings = {}
|
|
|
|
if document:
|
|
self.document = document
|
|
if svgfile:
|
|
inkex.errormsg('Warning: ignoring svgfile. document given too.')
|
|
elif svgfile:
|
|
self.document = self.load(svgfile)
|
|
|
|
def getLength(self, name, default):
|
|
|
|
'''
|
|
Get the <svg> attribute with name "name" and default value "default"
|
|
Parse the attribute into a value and associated units. Then, accept
|
|
units of cm, ft, in, m, mm, pc, or pt. Convert to pixels.
|
|
|
|
Note that SVG defines 90 px = 1 in = 25.4 mm.
|
|
Note: Since inkscape 0.92 we use the CSS standard of 96 px = 1 in.
|
|
'''
|
|
str = self.document.getroot().get(name)
|
|
if str:
|
|
return self.lengthWithUnit(str)
|
|
else:
|
|
# No width specified; assume the default value
|
|
return float(default)
|
|
|
|
def lengthWithUnit(self, strn, default_unit='px'):
|
|
v, u = self.parseLengthWithUnits(strn, default_unit)
|
|
if v is None:
|
|
# Couldn't parse the value
|
|
return None
|
|
elif (u == 'mm'):
|
|
return float(v) * (self.dpi / 25.4)
|
|
elif (u == 'cm'):
|
|
return float(v) * (self.dpi * 10.0 / 25.4)
|
|
elif (u == 'm'):
|
|
return float(v) * (self.dpi * 1000.0 / 25.4)
|
|
elif (u == 'in'):
|
|
return float(v) * self.dpi
|
|
elif (u == 'ft'):
|
|
return float(v) * 12.0 * self.dpi
|
|
elif (u == 'pt'):
|
|
# Use modern "Postscript" points of 72 pt = 1 in instead
|
|
# of the traditional 72.27 pt = 1 in
|
|
return float(v) * (self.dpi / 72.0)
|
|
elif (u == 'pc'):
|
|
return float(v) * (self.dpi / 6.0)
|
|
elif (u == 'px'):
|
|
self.px_used = True
|
|
return float(v)
|
|
else:
|
|
# Unsupported units
|
|
return None
|
|
|
|
def getDocProps(self):
|
|
|
|
'''
|
|
Get the document's height and width attributes from the <svg> tag.
|
|
Use a default value in case the property is not present or is
|
|
expressed in units of percentages.
|
|
|
|
This initializes:
|
|
* self.basename
|
|
* self.docWidth
|
|
* self.docHeight
|
|
* self.dpi
|
|
'''
|
|
|
|
inkscape_version = self.document.getroot().get(
|
|
"{http://www.inkscape.org/namespaces/inkscape}version")
|
|
sodipodi_docname = self.document.getroot().get(
|
|
"{http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd}docname")
|
|
if sodipodi_docname is None:
|
|
sodipodi_docname = "inkscape"
|
|
self.basename = re.sub(r"\.SVG", "", sodipodi_docname, flags=re.I)
|
|
# a simple 'inkscape:version' does not work here. sigh....
|
|
#
|
|
# BUG:
|
|
# inkscape 0.92 uses 96 dpi, inkscape 0.91 uses 90 dpi.
|
|
# From inkscape 0.92 we receive an svg document that has
|
|
# both inkscape:version and sodipodi:docname if the document
|
|
# was ever saved before. If not, both elements are missing.
|
|
#
|
|
import lxml.etree
|
|
# inkex.errormsg(lxml.etree.tostring(self.document.getroot()))
|
|
if inkscape_version:
|
|
'''
|
|
inkscape:version="0.91 r"
|
|
inkscape:version="0.92.0 ..."
|
|
See also https://github.com/fablabnbg/paths2openscad/issues/1
|
|
'''
|
|
# inkex.errormsg("inkscape:version="+inkscape_version)
|
|
m = re.match(r"(\d+)\.(\d+)", inkscape_version)
|
|
if m:
|
|
if int(m.group(1)) > 0 or int(m.group(2)) > 91:
|
|
self.dpi = 96 # 96dpi since inkscape 0.92
|
|
# inkex.errormsg("switching to 96 dpi")
|
|
|
|
# BUGFIX https://github.com/fablabnbg/inkscape-paths2openscad/issues/1
|
|
# get height and width after dpi. This is needed for e.g. mm units.
|
|
self.docHeight = self.getLength('height', self.DEFAULT_HEIGHT)
|
|
self.docWidth = self.getLength('width', self.DEFAULT_WIDTH)
|
|
|
|
if (self.docHeight is None) or (self.docWidth is None):
|
|
return False
|
|
else:
|
|
return True
|
|
|
|
def handleViewBox(self):
|
|
|
|
'''
|
|
Set up the document-wide transform in the event that the document has
|
|
an SVG viewbox
|
|
|
|
This initializes:
|
|
* self.basename
|
|
* self.docWidth
|
|
* self.docHeight
|
|
* self.dpi
|
|
* self.docTransform
|
|
'''
|
|
|
|
if self.getDocProps():
|
|
viewbox = self.document.getroot().get('viewBox')
|
|
if viewbox:
|
|
vinfo = viewbox.strip().replace(',', ' ').split(' ')
|
|
if (vinfo[2] != 0) and (vinfo[3] != 0):
|
|
sx = self.docWidth / float(vinfo[2])
|
|
sy = self.docHeight / float(vinfo[3])
|
|
self.docTransform = simpletransform.parseTransform('scale(%f,%f)' % (sx, sy))
|
|
|
|
def getPathVertices(self, path, node=None, transform=None, smoothness=None):
|
|
|
|
'''
|
|
Decompose the path data from an SVG element into individual
|
|
subpaths, each subpath consisting of absolute move to and line
|
|
to coordinates. Place these coordinates into a list of polygon
|
|
vertices.
|
|
|
|
The result is appended to self.paths as a two-element tuple of the
|
|
form (node, path_list). This preserves the native ordering of
|
|
the SVG file as much as possible, while still making all attributes
|
|
if the node available when processing the path list.
|
|
'''
|
|
|
|
if not smoothness:
|
|
smoothness = self.smoothness # self.smoothness is deprecated.
|
|
|
|
if (not path) or (len(path) == 0):
|
|
# Nothing to do
|
|
return None
|
|
|
|
if node is not None:
|
|
path = self.styleDasharray(path, node)
|
|
|
|
# parsePath() may raise an exception. This is okay
|
|
sp = simplepath.parsePath(path)
|
|
if (not sp) or (len(sp) == 0):
|
|
# Path must have been devoid of any real content
|
|
return None
|
|
|
|
# Get a cubic super path
|
|
p = cubicsuperpath.CubicSuperPath(sp)
|
|
if (not p) or (len(p) == 0):
|
|
# Probably never happens, but...
|
|
return None
|
|
|
|
if transform:
|
|
simpletransform.applyTransformToPath(transform, p)
|
|
|
|
# Now traverse the cubic super path
|
|
subpath_list = []
|
|
subpath_vertices = []
|
|
|
|
for sp in p:
|
|
|
|
# We've started a new subpath
|
|
# See if there is a prior subpath and whether we should keep it
|
|
if len(subpath_vertices):
|
|
subpath_list.append([subpath_vertices, [sp_xmin, sp_xmax, sp_ymin, sp_ymax]])
|
|
|
|
subpath_vertices = []
|
|
self.subdivideCubicPath(sp, float(smoothness))
|
|
|
|
# Note the first point of the subpath
|
|
first_point = sp[0][1]
|
|
subpath_vertices.append(first_point)
|
|
sp_xmin = first_point[0]
|
|
sp_xmax = first_point[0]
|
|
sp_ymin = first_point[1]
|
|
sp_ymax = first_point[1]
|
|
|
|
n = len(sp)
|
|
|
|
# Traverse each point of the subpath
|
|
for csp in sp[1:n]:
|
|
|
|
# Append the vertex to our list of vertices
|
|
pt = csp[1]
|
|
subpath_vertices.append(pt)
|
|
|
|
# Track the bounding box of this subpath
|
|
if pt[0] < sp_xmin:
|
|
sp_xmin = pt[0]
|
|
elif pt[0] > sp_xmax:
|
|
sp_xmax = pt[0]
|
|
if pt[1] < sp_ymin:
|
|
sp_ymin = pt[1]
|
|
elif pt[1] > sp_ymax:
|
|
sp_ymax = pt[1]
|
|
|
|
# Track the bounding box of the overall drawing
|
|
# This is used for centering the polygons in OpenSCAD around the
|
|
# (x,y) origin
|
|
if sp_xmin < self.xmin:
|
|
self.xmin = sp_xmin
|
|
if sp_xmax > self.xmax:
|
|
self.xmax = sp_xmax
|
|
if sp_ymin < self.ymin:
|
|
self.ymin = sp_ymin
|
|
if sp_ymax > self.ymax:
|
|
self.ymax = sp_ymax
|
|
|
|
# Handle the final subpath
|
|
if len(subpath_vertices):
|
|
subpath_list.append([subpath_vertices, [sp_xmin, sp_xmax, sp_ymin, sp_ymax]])
|
|
|
|
if len(subpath_list) > 0:
|
|
self.paths.append( (node, subpath_list) )
|
|
|
|
|
|
def recursivelyTraverseSvg(self, aNodeList, matCurrent=[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
|
|
parent_visibility='visible'):
|
|
|
|
'''
|
|
[ This too is largely lifted from eggbot.py ]
|
|
|
|
Recursively walk the SVG document aNodeList, building polygon vertex lists
|
|
for each graphical element we support. The list is generated in self.paths
|
|
as a list of tuples [ (node, path_list), (node, path_list), ...] ordered
|
|
natively by their order of appearance in the SVG document.
|
|
|
|
Rendered SVG elements:
|
|
<circle>, <ellipse>, <line>, <path>, <polygon>, <polyline>, <rect>
|
|
|
|
Supported SVG elements:
|
|
<group>, <use>
|
|
|
|
Ignored SVG elements:
|
|
<defs>, <eggbot>, <metadata>, <namedview>, <pattern>,
|
|
processing directives
|
|
|
|
All other SVG elements trigger an error (including <text>)
|
|
'''
|
|
|
|
for node in aNodeList:
|
|
|
|
# Ignore invisible nodes
|
|
visibility = node.get('visibility', parent_visibility)
|
|
if visibility == 'inherit':
|
|
visibility = parent_visibility
|
|
if visibility == 'hidden' or visibility == 'collapse':
|
|
continue
|
|
|
|
# FIXME: should we inherit styles from parents?
|
|
s = self.getNodeStyle(node)
|
|
if s.get('display', '') == 'none': continue
|
|
|
|
# First apply the current matrix transform to this node's tranform
|
|
matNew = simpletransform.composeTransform(
|
|
matCurrent, simpletransform.parseTransform(node.get("transform")))
|
|
|
|
if node.tag == inkex.addNS('g', 'svg') or node.tag == 'g':
|
|
|
|
self.recursivelyTraverseSvg(node, matNew, visibility)
|
|
|
|
elif node.tag == inkex.addNS('use', 'svg') or node.tag == 'use':
|
|
|
|
# A <use> element refers to another SVG element via an
|
|
# xlink:href="#blah" attribute. We will handle the element by
|
|
# doing an XPath search through the document, looking for the
|
|
# element with the matching id="blah" attribute. We then
|
|
# recursively process that element after applying any necessary
|
|
# (x,y) translation.
|
|
#
|
|
# Notes:
|
|
# 1. We ignore the height and width attributes as they do not
|
|
# apply to path-like elements, and
|
|
# 2. Even if the use element has visibility="hidden", SVG
|
|
# still calls for processing the referenced element. The
|
|
# referenced element is hidden only if its visibility is
|
|
# "inherit" or "hidden".
|
|
|
|
refid = node.get(inkex.addNS('href', 'xlink'))
|
|
if not refid:
|
|
continue
|
|
|
|
# [1:] to ignore leading '#' in reference
|
|
path = '//*[@id="%s"]' % refid[1:]
|
|
refnode = node.xpath(path)
|
|
if refnode:
|
|
x = float(node.get('x', '0'))
|
|
y = float(node.get('y', '0'))
|
|
# Note: the transform has already been applied
|
|
if (x != 0) or (y != 0):
|
|
matNew2 = simpletransform.composeTransform(matNew, simpletransform.parseTransform('translate(%f,%f)' % (x, y)))
|
|
else:
|
|
matNew2 = matNew
|
|
visibility = node.get('visibility', visibility)
|
|
self.recursivelyTraverseSvg(refnode, matNew2, visibility)
|
|
|
|
elif node.tag == inkex.addNS('path', 'svg'):
|
|
|
|
path_data = node.get('d', '')
|
|
if node.get(inkex.addNS('type', 'sodipodi'), '') == 'arc':
|
|
cx = float(node.get(inkex.addNS('cx', 'sodipodi'), '0'))
|
|
cy = float(node.get(inkex.addNS('cy', 'sodipodi'), '0'))
|
|
rx = float(node.get(inkex.addNS('rx', 'sodipodi'), '0'))
|
|
ry = float(node.get(inkex.addNS('ry', 'sodipodi'), '0'))
|
|
st = float(node.get(inkex.addNS('start', 'sodipodi'), '0'))
|
|
en = float(node.get(inkex.addNS('end', 'sodipodi'), '0'))
|
|
cl = path_data.strip()[-1] in ('z', 'Z')
|
|
self.pathgen.objArc(path_data, cx, cy, rx, ry, st, en, cl, node, matNew)
|
|
else:
|
|
### sodipodi:type="star" also comes here. TBD later, if need be.
|
|
self.pathgen.pathString(path_data, node, matNew)
|
|
|
|
elif node.tag == inkex.addNS('rect', 'svg') or node.tag == 'rect':
|
|
|
|
# Create a path with the outline of the rectangle
|
|
# Adobe Illustrator leaves out 'x'='0'.
|
|
x = float(node.get('x', '0'))
|
|
y = float(node.get('y', '0'))
|
|
w = float(node.get('width', '0'))
|
|
h = float(node.get('height', '0'))
|
|
rx = float(node.get('rx', '0'))
|
|
ry = float(node.get('ry', '0'))
|
|
|
|
if rx > 0.0 or ry > 0.0:
|
|
if ry < 0.0000001: ry = rx
|
|
elif rx < 0.0000001: rx = ry
|
|
self.pathgen.objRoundedRect(x, y, w, h, rx, ry, node, matNew)
|
|
else:
|
|
self.pathgen.objRect(x, y, w, h, node, matNew)
|
|
|
|
elif node.tag == inkex.addNS('line', 'svg') or node.tag == 'line':
|
|
|
|
# Convert
|
|
#
|
|
# <line x1="X1" y1="Y1" x2="X2" y2="Y2/>
|
|
#
|
|
# to
|
|
#
|
|
# <path d="MX1,Y1 LX2,Y2"/>
|
|
|
|
x1 = float(node.get('x1'))
|
|
y1 = float(node.get('y1'))
|
|
x2 = float(node.get('x2'))
|
|
y2 = float(node.get('y2'))
|
|
if (not x1) or (not y1) or (not x2) or (not y2):
|
|
continue
|
|
a = []
|
|
a.append(['M ', [x1, y1]])
|
|
a.append([' L ', [x2, y2]])
|
|
self.pathgen.pathList(a, node, matNew)
|
|
|
|
elif node.tag == inkex.addNS('polyline', 'svg') or node.tag == 'polyline':
|
|
|
|
# Convert
|
|
#
|
|
# <polyline points="x1,y1 x2,y2 x3,y3 [...]"/>
|
|
#
|
|
# to
|
|
#
|
|
# <path d="Mx1,y1 Lx2,y2 Lx3,y3 [...]"/>
|
|
#
|
|
# Note: we ignore polylines with no points
|
|
|
|
pl = node.get('points', '').strip()
|
|
if pl == '':
|
|
continue
|
|
|
|
pa = pl.split()
|
|
d = "".join(["M " + pa[i] if i == 0 else " L " + pa[i] for i in range(0, len(pa))])
|
|
self.pathgen.pathString(d, node, matNew)
|
|
|
|
elif node.tag == inkex.addNS('polygon', 'svg') or node.tag == 'polygon':
|
|
|
|
# Convert
|
|
#
|
|
# <polygon points="x1,y1 x2,y2 x3,y3 [...]"/>
|
|
#
|
|
# to
|
|
#
|
|
# <path d="Mx1,y1 Lx2,y2 Lx3,y3 [...] Z"/>
|
|
#
|
|
# Note: we ignore polygons with no points
|
|
|
|
pl = node.get('points', '').strip()
|
|
if pl == '':
|
|
continue
|
|
|
|
pa = pl.split()
|
|
d = "".join(["M " + pa[i] if i == 0 else " L " + pa[i] for i in range(0, len(pa))])
|
|
d += " Z"
|
|
self.pathgen.pathString(d, node, matNew)
|
|
|
|
elif node.tag == inkex.addNS('ellipse', 'svg') or node.tag == 'ellipse' or \
|
|
node.tag == inkex.addNS('circle', 'svg') or node.tag == 'circle':
|
|
|
|
if node.tag == inkex.addNS('ellipse', 'svg') or node.tag == 'ellipse':
|
|
rx = float(node.get('rx', '0'))
|
|
ry = float(node.get('ry', '0'))
|
|
else:
|
|
rx = float(node.get('r', '0'))
|
|
ry = rx
|
|
if rx == 0 or ry == 0:
|
|
continue
|
|
|
|
cx = float(node.get('cx', '0'))
|
|
cy = float(node.get('cy', '0'))
|
|
self.pathgen.objEllipse(cx, cy, rx, ry, node, matNew)
|
|
|
|
elif node.tag == inkex.addNS('pattern', 'svg') or node.tag == 'pattern':
|
|
pass
|
|
|
|
elif node.tag == inkex.addNS('metadata', 'svg') or node.tag == 'metadata':
|
|
pass
|
|
|
|
elif node.tag == inkex.addNS('defs', 'svg') or node.tag == 'defs':
|
|
self.recursivelyTraverseSvg(node, matNew, visibility)
|
|
|
|
elif node.tag == inkex.addNS('desc', 'svg') or node.tag == 'desc':
|
|
pass
|
|
|
|
elif node.tag == inkex.addNS('namedview', 'sodipodi') or node.tag == 'namedview':
|
|
pass
|
|
|
|
elif node.tag == inkex.addNS('eggbot', 'svg') or node.tag == 'eggbot':
|
|
pass
|
|
|
|
elif node.tag == inkex.addNS('text', 'svg') or node.tag == 'text':
|
|
texts = []
|
|
plaintext = ''
|
|
for tnode in node.iterfind('.//'): # all subtree
|
|
if tnode is not None and tnode.text is not None:
|
|
texts.append(tnode.text)
|
|
if len(texts):
|
|
plaintext = "', '".join(texts).encode('latin-1')
|
|
inkex.errormsg('Warning: text "%s"' % plaintext)
|
|
inkex.errormsg('Warning: unable to draw text, please convert it to a path first.')
|
|
|
|
elif node.tag == inkex.addNS('title', 'svg') or node.tag == 'title':
|
|
pass
|
|
|
|
elif node.tag == inkex.addNS('image', 'svg') or node.tag == 'image':
|
|
if 'image' not in self.warnings:
|
|
inkex.errormsg(
|
|
gettext.gettext(
|
|
'Warning: unable to draw bitmap images; please convert them to line art first. '
|
|
'Consider using the "Trace bitmap..." tool of the "Path" menu. Mac users please '
|
|
'note that some X11 settings may cause cut-and-paste operations to paste in bitmap copies.'))
|
|
self.warnings['image'] = 1
|
|
|
|
elif node.tag == inkex.addNS('pattern', 'svg') or node.tag == 'pattern':
|
|
pass
|
|
|
|
elif node.tag == inkex.addNS('radialGradient', 'svg') or node.tag == 'radialGradient':
|
|
# Similar to pattern
|
|
pass
|
|
|
|
elif node.tag == inkex.addNS('linearGradient', 'svg') or node.tag == 'linearGradient':
|
|
# Similar in pattern
|
|
pass
|
|
|
|
elif node.tag == inkex.addNS('style', 'svg') or node.tag == 'style':
|
|
# This is a reference to an external style sheet and not the
|
|
# value of a style attribute to be inherited by child elements
|
|
#
|
|
# <style type="text/css">
|
|
# <![CDATA[
|
|
# .str0 {stroke:red;stroke-width:20}
|
|
# .fil0 {fill:none}
|
|
# ]]>
|
|
#
|
|
# FIXME: test/test_styles.sh fails without this.
|
|
# This is input for self.getNodeStyle()
|
|
if node.get('type', '') == "text/css":
|
|
self.cssDictAdd(node.text)
|
|
else:
|
|
inkex.errormsg("Warning: Corel-style CSS definitions ignored. Parsing element 'style' with type='%s' not implemented." % node.get('type', ''))
|
|
|
|
elif node.tag == inkex.addNS('cursor', 'svg') or node.tag == 'cursor':
|
|
pass
|
|
|
|
elif node.tag == inkex.addNS('color-profile', 'svg') or node.tag == 'color-profile':
|
|
# Gamma curves, color temp, etc. are not relevant to single
|
|
# color output
|
|
pass
|
|
|
|
elif not isinstance(node.tag, basestring):
|
|
# This is likely an XML processing instruction such as an XML
|
|
# comment. lxml uses a function reference for such node tags
|
|
# and as such the node tag is likely not a printable string.
|
|
# Further, converting it to a printable string likely won't
|
|
# be very useful.
|
|
pass
|
|
|
|
else:
|
|
inkex.errormsg('Warning: unable to draw object <%s>, please convert it to a path first.' % node.tag)
|
|
pass
|
|
|
|
def recursivelyGetEnclosingTransform(self, node):
|
|
|
|
'''
|
|
Determine the cumulative transform which node inherits from
|
|
its chain of ancestors.
|
|
'''
|
|
node = node.getparent()
|
|
if node is not None:
|
|
parent_transform = self.recursivelyGetEnclosingTransform(node)
|
|
node_transform = node.get('transform', None)
|
|
if node_transform is None:
|
|
return parent_transform
|
|
else:
|
|
tr = simpletransform.parseTransform(node_transform)
|
|
if parent_transform is None:
|
|
return tr
|
|
else:
|
|
return simpletransform.composeTransform(parent_transform, tr)
|
|
else:
|
|
return self.docTransform
|
|
|
|
|
|
parser = OptionParser(usage="\n %prog [options] SVGFILE [OUTFILE]\n\nTry --help for details.")
|
|
parser.add_option("-o", "--outfile", dest="outfile",
|
|
help="write fcstd to OUTPUT. Default: stdout (unless it is a tty)", metavar="OUTPUT")
|
|
parser.add_option("-i", "--id", "--ids", dest="ids",
|
|
action="append", type="string", default=[],
|
|
help="Select svg object(s) by id attribute. Use multiple times or combine with comma. Default: root object, aka all")
|
|
parser.add_option("--tab", dest="tab", type="string")
|
|
parser.add_option("--selected-nodes", dest="selected_nodes",
|
|
action="append", type="string", default=[],
|
|
help="id:subpath:position of selected nodes, if any")
|
|
# TODO: check if inkscape is really passing us these
|
|
parser.add_option("-V", "--version",
|
|
action="store_true", dest="version", default=False,
|
|
help="Print version numbers only.")
|
|
parser.add_option("-v", "--verbose",
|
|
action="store_true", dest="verbose", default=False,
|
|
help="Be verbose. Default: silent.")
|
|
parser.add_option("-e", "--expose-internal-geometry", type="string", dest="expose_internal_geometry", default="false",
|
|
help="Expose internal geometry for Splines and Ellipses. Default: False.")
|
|
(options, args) = parser.parse_args()
|
|
|
|
if options.version:
|
|
print("InkSvg %s\n%s %s\n" % (InkSvg.__version__, sys.argv[0], __version__))
|
|
sys.exit(0)
|
|
if len(args) < 1:
|
|
parser.error("Input svg file missing.")
|
|
|
|
svgfile = args[0]
|
|
|
|
outdir = '.'
|
|
if len(args) > 1:
|
|
fcstdfile = args[1]
|
|
else:
|
|
if options.outfile:
|
|
fcstdfile = options.outfile
|
|
else:
|
|
fcstdfile = re.sub('\.svg$', '.fcstd', svgfile, re.I)
|
|
docname = re.sub('\.fcstd$', '', fcstdfile, re.I)
|
|
docname = re.sub('^.*/', '', docname)
|
|
|
|
if not options.outfile:
|
|
if sys.stdout.isatty():
|
|
print "ERROR: stdout isatty. Please use option --outfile or redirect stdout."
|
|
sys.exit(1)
|
|
|
|
|
|
class SubPathTracker():
|
|
"""
|
|
Track status of a subpath. In SVG, a path consists of disconnected subpaths.
|
|
Each subpath consists of a connected list of straight lines or cubic spline segments.
|
|
|
|
A SubPathTracker object remembers the first and last last point of a subpath.
|
|
When adding a line() or cubic(), it continues the current subpath
|
|
assuming same_point(last_point, p1) == True, otherwise it is an error.
|
|
|
|
Control points of splines have circles in construction mode on them. All the same size.
|
|
End points of lines don't have these, unless they are also control points of adjacent splines.
|
|
|
|
If any two or three of the control points coincide, duplicate circles are avoided, an coincidence restrictions are added instead.
|
|
We never convert a cubic spline to a quadratic spline, as they are slightly different in shape.
|
|
|
|
Adjacent ends of segments have coincidence restrictions on them, no matter if the segments are of type line or type cubic.
|
|
Duplicate circles on adjacent ends of two cubics are avoided.
|
|
|
|
If the very last point of a subpath coincides with the very first point, a coincidence restriction is added, and duplicate circles
|
|
are again avoided.
|
|
|
|
Other coincidences within the subpath are ignored and may prodice duplicate circles (serving their own purpose each).
|
|
|
|
|
|
Example spline syntax seen in the Python-Console of FreeCaD 0.18 when drawing a cubic spline manually:
|
|
|
|
addGeometry(Part.Circle(App.Vector(-85,192,0),App.Vector(0,0,1),10),True) # 3
|
|
addGeometry(Part.Circle(App.Vector(-107,160,0),App.Vector(0,0,1),10),True) # 4
|
|
addConstraint(Sketcher.Constraint('Radius',3,7.000000))
|
|
addConstraint(Sketcher.Constraint('Equal',3,4))
|
|
addGeometry(Part.Circle(App.Vector(-20,161,0),App.Vector(0,0,1),10),True) # 5
|
|
addConstraint(Sketcher.Constraint('Equal',3,5))
|
|
addGeometry(Part.Circle(App.Vector(-42,193,0),App.Vector(0,0,1),10),True) # 6
|
|
addConstraint(Sketcher.Constraint('Equal',3,6))
|
|
addGeometry(Part.BSplineCurve([App.Vector(-85,192),App.Vector(-107,160),App.Vector(-20,161),App.Vector(-42,193)],
|
|
None,None,False,3,None,False),False)
|
|
|
|
Sketcher.Constraint('InternalAlignment:Sketcher::BSplineControlPoint',3,3,7,0)
|
|
Sketcher.Constraint('InternalAlignment:Sketcher::BSplineControlPoint',4,3,7,1)
|
|
Sketcher.Constraint('InternalAlignment:Sketcher::BSplineControlPoint',5,3,7,2)
|
|
Sketcher.Constraint('InternalAlignment:Sketcher::BSplineControlPoint',6,3,7,3)
|
|
exposeInternalGeometry(7) # 7
|
|
"""
|
|
|
|
def __init__(self, sketch, same_point, expose_int=True, circ_r=10, debug=False):
|
|
self.ske = sketch
|
|
self.expose_int = expose_int
|
|
self.same_point = same_point
|
|
|
|
self.first_point = None
|
|
self.first_point_idx = None
|
|
self.first_point_circ_idx = None
|
|
|
|
self.first_circ_idx = None # maintained by _find_or_add_circ()
|
|
self.last_point_circ_idx = None
|
|
self.last_point_idx = None
|
|
self.last_point = None
|
|
self.circ_r = circ_r
|
|
self.debug = debug
|
|
|
|
|
|
def line(self, p1, p2, closing=False):
|
|
if self.same_point(p1, p2):
|
|
return
|
|
idx = int(self.ske.GeometryCount)
|
|
if self.first_point is None:
|
|
self.first_point = p1
|
|
self.first_point_idx = (idx,1)
|
|
self.first_point_circ_idx = None
|
|
|
|
self.ske.addGeometry([Part.LineSegment(p1, p2)])
|
|
if self.debug: print 'line: idx=', idx
|
|
|
|
if self.last_point is not None and self.same_point(self.last_point, p1):
|
|
if self.debug: print 'line: Coincident', self.last_point_idx[0], self.last_point_idx[1], idx, 1
|
|
self.ske.addConstraint(Sketcher.Constraint('Coincident', self.last_point_idx[0], self.last_point_idx[1], idx, 1))
|
|
if closing and self.same_point(self.first_point, p2):
|
|
if self.debug: print 'line: Coincident Z', idx, 2, self.first_point_idx[0], self.first_point_idx[1]
|
|
self.ske.addConstraint(Sketcher.Constraint('Coincident', idx, 2, self.first_point_idx[0], self.first_point_idx[1]))
|
|
|
|
self.last_point = p2
|
|
self.last_point_idx = (idx,2)
|
|
self.last_point_circ_idx = None
|
|
|
|
|
|
def _find_or_add_circ(self, pt, ptlist, constr=True):
|
|
"""
|
|
When adding a constuction circle to a control point in a spline, this method
|
|
checks the ptlist for already exising circles.
|
|
ptlist is a list of tuples, consisting of coordinates and index.
|
|
|
|
The index if the circle is returned.
|
|
Caller is responsible to add newly created circles to the ptlist of subsequent invocations.
|
|
"""
|
|
|
|
if self.debug: print "_find_or_add_circ: pt=%s, ptlist=%s" % (pt,ptlist)
|
|
for old in ptlist:
|
|
if self.debug: print " test %s against %s idx=%s" % (pt, old[0], old[1])
|
|
if old is not None and old[0] is not None and old[1] is not None and self.same_point(old[0], pt):
|
|
if self.debug: print " -> return idx=%s" % (old[1])
|
|
return old[1]
|
|
idx = int(self.ske.GeometryCount)
|
|
self.ske.addGeometry(Part.Circle(pt, Normal=Base.Vector(0,0,1), Radius=self.circ_r), constr)
|
|
if self.first_circ_idx is None:
|
|
self.ske.addConstraint(Sketcher.Constraint('Radius', idx, self.circ_r))
|
|
self.first_circ_idx = idx
|
|
else:
|
|
self.ske.addConstraint(Sketcher.Constraint('Equal', idx, self.first_circ_idx))
|
|
return idx
|
|
|
|
|
|
def cubic(self, p1,h1,h2,p2, closing=False):
|
|
"""
|
|
If closing==True, we check for hitting self.first_point
|
|
If we are sure that no intermediate points coincide with the first_point, we can always pass closing=True.
|
|
"""
|
|
|
|
if ((self.same_point(h1, p1) or self.same_point(h1, p2)) and
|
|
(self.same_point(h2, p2) or self.same_point(h2, p1))):
|
|
self.line(p1, p2, closing)
|
|
return
|
|
idx = int(self.ske.GeometryCount)
|
|
self.ske.addGeometry(Part.BSplineCurve([p1, h1, h2, p2],None,None,False,3,None,False),False)
|
|
if self.debug: print "cubic(self, p1=%s,h1=%s,h2=%s,p2=%s, closing=%s) -> idx=%s" % (p1,h1,h2,p2, closing, idx)
|
|
|
|
# 4 circles in construction mode
|
|
p1_circ_idx = self._find_or_add_circ(p1, [(self.last_point,self.last_point_circ_idx)])
|
|
if self.first_point is None:
|
|
self.first_point = p1
|
|
self.first_point_idx = (idx,1)
|
|
self.first_point_circ_idx = p1_circ_idx
|
|
|
|
ptlist = [(p1,p1_circ_idx)]
|
|
h1_circ_idx = self._find_or_add_circ(h1, ptlist)
|
|
|
|
ptlist.append((h1,h1_circ_idx))
|
|
h2_circ_idx = self._find_or_add_circ(h2, ptlist)
|
|
|
|
ptlist.append((h2,h2_circ_idx))
|
|
if closing: ptlist.append((self.first_point,self.first_point_circ_idx))
|
|
p2_circ_idx = self._find_or_add_circ(p2, ptlist)
|
|
|
|
conList = []
|
|
# register the 4 circles as the 4 control points. Helps tuning...
|
|
conList.append(Sketcher.Constraint('InternalAlignment:Sketcher::BSplineControlPoint',p1_circ_idx,3,idx,0))
|
|
conList.append(Sketcher.Constraint('InternalAlignment:Sketcher::BSplineControlPoint',h1_circ_idx,3,idx,1))
|
|
conList.append(Sketcher.Constraint('InternalAlignment:Sketcher::BSplineControlPoint',h2_circ_idx,3,idx,2))
|
|
conList.append(Sketcher.Constraint('InternalAlignment:Sketcher::BSplineControlPoint',p2_circ_idx,3,idx,3))
|
|
self.ske.addConstraint(conList)
|
|
|
|
if self.expose_int:
|
|
self.ske.exposeInternalGeometry(idx)
|
|
|
|
if self.debug: print "cubic: test self.same_point(self.last_point=%s, p1=%s)" % (self.last_point, p1)
|
|
if self.last_point is not None and self.same_point(self.last_point, p1):
|
|
if self.debug: print "cubic: Coincident", self.last_point_idx[0], self.last_point_idx[1], idx, 1
|
|
if self.last_point_circ_idx == p1_circ_idx:
|
|
# Be very careful with duplicate constraints. FreeCAD does not like that at all!
|
|
if self.debug: print " -> skipped, they coincide already, as they share a circle"
|
|
pass
|
|
else:
|
|
self.ske.addConstraint(Sketcher.Constraint('Coincident', self.last_point_idx[0], self.last_point_idx[1], idx, 1))
|
|
if closing and self.same_point(self.first_point, p2):
|
|
if self.debug: print 'cubic: Coincident Z', idx, 2, self.first_point_idx[0], self.first_point_idx[1]
|
|
if self.first_point_circ_idx == p2_circ_idx:
|
|
# Be very careful with duplicate constraints. FreeCAD does not like that at all!
|
|
if self.debug: print " -> skipped, they coincide already, as they share a circle"
|
|
else:
|
|
self.ske.addConstraint(Sketcher.Constraint('Coincident', idx, 2, self.first_point_idx[0], self.first_point_idx[1]))
|
|
|
|
self.last_point = p2
|
|
self.last_point_idx = (idx,2)
|
|
self.last_point_circ_idx = p2_circ_idx
|
|
|
|
|
|
class SketchPathGen(PathGenerator):
|
|
"""
|
|
Generate XML code for a FreeCAD sketch.
|
|
"""
|
|
def __init__(self, ske, yflip=True, expose_int=True):
|
|
self.ske = ske
|
|
self.m = None
|
|
self.yflip = yflip
|
|
self.expose_int = expose_int
|
|
self.bbox = BoundBox()
|
|
|
|
# prepare counters how many objects of each type we generate.
|
|
self.stats = {}
|
|
for s in ('pathList', 'pathString', 'objRect', 'objRoundedRect',
|
|
'objArc', 'objEllipse'):
|
|
self.stats[s] = 0
|
|
|
|
|
|
def _coord_from_svg(self, m=None):
|
|
"""
|
|
Use SVG properties set by InkSVG.handleViewBox()
|
|
to define the transformation matrix from dpi'ish SVG space
|
|
into metric FreeCAD space.
|
|
"""
|
|
xs = 1.0
|
|
if self._svg.docTransform is not None:
|
|
xs = self._svg.docTransform[0][0]
|
|
if abs(xs) < epsilon:
|
|
xs = 1.0 # avoid divison by zero.
|
|
ys = xs
|
|
if self.yflip: ys = -ys
|
|
|
|
if m is None: m = self.m
|
|
m.move(0, -self._svg.docHeight, 0)
|
|
m.scale(1/xs, 1/ys, 1)
|
|
return m
|
|
|
|
def _ellipse_vertices2d(self, C, P, Q):
|
|
"""
|
|
Compute two vertices of the ellipse, major and minor axis.
|
|
Given two conjugated half diameters P, Q.
|
|
Vectors are expected as FreeCAD Base.Vector() assuming that all Z=0.
|
|
|
|
Using a computation derived from Rytz's construction as
|
|
seen in https://en.wikipedia.org/wiki/Rytz%27s_construction.
|
|
Returns (V1, V2, V3, V4) where V1-V4 is the diameter of the major axis
|
|
and V2-V3 is the diameter of the minor axis.
|
|
"""
|
|
f0 = C
|
|
f1 = P-C
|
|
f2 = Q-C
|
|
# det = (f1*f1 - f2*f2) / ( 2 * f1 * f2 ) # raise NaN, if flat.
|
|
idet = (2*f1*f2) / (f1*f1 - f2*f2) # raise NaN, if flat or colinear.
|
|
|
|
def math_cot(a): return 1/math.tan(a)
|
|
|
|
def p(t): return f0 + f1 * math.cos(t) + f2 * math.sin(t)
|
|
|
|
# cot(2*t0) = det
|
|
# tan(2*t0) = 1/det
|
|
# tan(2*t0) = idet
|
|
# 2*t0 = atan(idet)
|
|
t0 = 0.5* math.atan(idet)
|
|
V1 = p(t0)
|
|
V2 = p(t0 + 0.5*math.pi)
|
|
V3 = p(t0 - 0.5*math.pi)
|
|
V4 = p(t0 + math.pi)
|
|
if (V1-V4).Length < (V2-V3).Length:
|
|
# V1-V4 should be the major axis.
|
|
(V1,V4, V2,V3) = (V2,V3, V1,V4)
|
|
return (V1, V2, V3, V4)
|
|
|
|
|
|
def _decompose_matrix2d(self, m=None):
|
|
"""
|
|
Decompose a 4x4 matrix into 2d translation vector,
|
|
2d scale vector, and rotation angle.
|
|
|
|
Inspired by https://math.stackexchange.com/questions/13150/extracting-rotation-scale-values-from-2d-transformation-matrix
|
|
"""
|
|
if m is None: m = self.m
|
|
a = m.A11
|
|
b = m.A12
|
|
xc = m.A14 # FIXME: check if correct in _matrix_from_svg()
|
|
c = m.A21
|
|
d = m.A22
|
|
yc = m.A24 # FIXME: check if correct in _matrix_from_svg()
|
|
sign_a = -1 if a < 0 else 1
|
|
sign_d = -1 if d < 0 else 1
|
|
|
|
sx = sign_a * math.sqrt(a*a + b*b)
|
|
sy = sign_d * math.sqrt(c*c + d*d)
|
|
|
|
sign = math.atan(-c / a)
|
|
rad = math.acos(a / sx)
|
|
deg = rad * 180. / math.pi
|
|
if (deg > 90 and sign > 0) or (deg < 90 and sign < 0):
|
|
deg = 360 - deg
|
|
rad = deg / 180. * math.pi
|
|
|
|
return (Base.Vector(xc, yc), Base.Vector(sx, sy), (rad, deg))
|
|
|
|
|
|
def _matrix_from_svg(self, svgmat, coordcvt=True):
|
|
"""
|
|
Convert a 2D matrix from SVG into a FreeCAD 3D Matrix.
|
|
e.g. mat = [[0.9659258262890683, 0.25881904510252074, 0.0],
|
|
[-0.25881904510252074, 0.9659258262890683, 0.0]]
|
|
|
|
If coordcvt is True, then coordinate system conversion from
|
|
SVG to FreeCAD is applied to the matrix. Otherwise only datatype
|
|
conversion is performed.
|
|
|
|
Returns:
|
|
e.g. Matrix ((0.965926,0.258819,0,0),(-0.258819,0.965926,0,0),(0,0,1,0),(0,0,0,1))
|
|
"""
|
|
# FIXME: is the handling of svgmat[*][2] correct?
|
|
self.m = Base.Matrix(svgmat[0][0], svgmat[0][1], 0, svgmat[0][2],
|
|
svgmat[1][0], svgmat[1][1], 0, svgmat[1][2])
|
|
|
|
if coordcvt:
|
|
self._coord_from_svg()
|
|
return self.m
|
|
|
|
|
|
def _from_svg(self, x, y, m=None, bbox=True):
|
|
"""
|
|
Converts a 2D Vector from SVG into a FreeCAD 3D vector applying Base.Matrix m.
|
|
"""
|
|
if m is None: m = self.m
|
|
v = m.multiply(Base.Vector(x, y, 0))
|
|
if bbox: self.bbox.add(v)
|
|
return v
|
|
|
|
def _same_point(self, p1, p2, eps=epsilon):
|
|
if p1 is None or p2 is None: return True
|
|
if abs(p1[0]-p2[0]) > eps: return False
|
|
if abs(p1[1]-p2[1]) > eps: return False
|
|
return True
|
|
|
|
def _round_sigdigs(self, val, n=2):
|
|
"""
|
|
Rounds the value to at most n significant digits.
|
|
0.00426221 -> 0.0043
|
|
3.78 -> 3.8
|
|
997 -> 1000
|
|
994 -> 990
|
|
"""
|
|
n = int(max(1, n))
|
|
exp10 = math.floor(math.log10(abs(val))) if val != 0 else 0
|
|
decimal_shifter = float(10**exp10)
|
|
val = round(val/decimal_shifter, n-1)
|
|
# this final round is mathematically useless, but
|
|
# avoids _round_sigdigs(0.000491) -> 0.0004900000000000001
|
|
return round(val*decimal_shifter, int(-exp10+n-1))
|
|
|
|
def _average_handle_length(self, sp):
|
|
(tra,sca,rot) = self._decompose_matrix2d()
|
|
sca = 0.5 * (abs(sca[0]) + abs(sca[1]))
|
|
|
|
tot = 0
|
|
cnt = 0
|
|
for tri in sp:
|
|
(h1, p, h2) = tri
|
|
if not self._same_point(h1, p):
|
|
tot += math.sqrt( (h1[0]-p[0])*(h1[0]-p[0]) + (h1[1]-p[1])*(h1[1]-p[1]) )
|
|
cnt += 1
|
|
if not self._same_point(h2, p):
|
|
tot += math.sqrt( (h2[0]-p[0])*(h2[0]-p[0]) + (h2[1]-p[1])*(h2[1]-p[1]) )
|
|
cnt += 1
|
|
if (cnt > 0 and tot > 0):
|
|
return self._round_sigdigs(sca*tot/cnt, 2)
|
|
return self._round_sigdigs(sca*10, 2)
|
|
|
|
|
|
def pathString(self, d, node, mat):
|
|
"""
|
|
d is expected formatted as an svg path string here.
|
|
d = "M 30.994048,129.93452 72.571427,88.357143 V 129.93452 H 127" means
|
|
path = [
|
|
[
|
|
# handle_0 point handle_1
|
|
[[30.994048, 129.93452], [30.994048, 129.93452], [30.994048, 129.93452]],
|
|
[[72.571427, 88.357143], [72.571427, 88.357143], [72.571427, 88.357143]],
|
|
[[72.571427, 129.93452], [72.571427, 129.93452], [72.571427, 129.93452]],
|
|
[[127.0, 129.93452], [127.0, 129.93452], [127.0, 129.93452]]
|
|
]
|
|
]
|
|
"""
|
|
self._matrix_from_svg(mat)
|
|
path = cubicsuperpath.parsePath(d)
|
|
for subpath in path:
|
|
spt = SubPathTracker(self.ske, lambda a,b: self._same_point(a, b), self.expose_int,
|
|
circ_r=0.1*self._average_handle_length(subpath), debug=(verbose > 0))
|
|
|
|
# These are the off by one's: four points -> three lines -> two constraints.
|
|
j = 0
|
|
while j < len(subpath)-1:
|
|
# lists of three, http://wiki.inkscape.org/wiki/index.php/Python_modules_for_extensions#cubicsuperpath.py
|
|
(h0,p1,h1) = subpath[j]
|
|
j = j+1
|
|
while j < len(subpath):
|
|
(h2,p2,h3) = subpath[j]
|
|
if not self._same_point(p1, p2):
|
|
break # no null-segments, please!
|
|
j += 1
|
|
if j >= len(subpath):
|
|
break # nothing left.
|
|
|
|
spt.cubic( self._from_svg(p1[0], p1[1]), self._from_svg(h1[0], h1[1]),
|
|
self._from_svg(h2[0], h2[1]), self._from_svg(p2[0], p2[1]), closing=(j+1 == len(subpath)) )
|
|
|
|
self.stats['pathString'] += 1 # count subpaths
|
|
|
|
|
|
def pathList(self, d, node, mat):
|
|
"""
|
|
d is expected as an [[cmd, [args]], ...] arrray
|
|
"""
|
|
print(d, node, mat)
|
|
i = int(self.ske.GeometryCount) # 0
|
|
geo = []
|
|
geo.append(Part.LineSegment(Base.Vector(4,8,0),Base.Vector(9,8,0)))
|
|
geo.append(Part.LineSegment(Base.Vector(9,8,0),Base.Vector(9,2,0)))
|
|
self.ske.addGeometry(geo, False)
|
|
print("GeometryCount changed from %d to %d" % (i, int(self.ske.GeometryCount)))
|
|
print("not impl. simplePath: ", d, node, mat)
|
|
|
|
|
|
def objRoundedRect(self, x, y, w, h, rx, ry, node, mat):
|
|
"""
|
|
Construct four arcs, one for each corner, and
|
|
connect them with line segments, if space permits.
|
|
Connect them directly otherwise.
|
|
"""
|
|
if rx == 0: rx = ry
|
|
if ry == 0: ry = rx
|
|
if rx < epsilon or ry < epsilon:
|
|
return self.objRect(x, y, w, h, node, mat)
|
|
if 2*rx > w-epsilon: rx = 0.5*(w-epsilon) # avoid Part.OCCError: Both points are equal" on LineSegment #12
|
|
if 2*ry > h-epsilon: ry = 0.5*(h-epsilon)
|
|
|
|
if verbose > 0: print("objRoundedRect: ", x, y, w, h, rx, ry, node.get('id'), mat)
|
|
|
|
self._matrix_from_svg(mat)
|
|
i = int(self.ske.GeometryCount)
|
|
ske.addGeometry([
|
|
# construction outline of the box
|
|
Part.LineSegment(self._from_svg(x ,y ), self._from_svg(x+w,y )), # 0
|
|
Part.LineSegment(self._from_svg(x+w,y ), self._from_svg(x+w,y+h)), # 1
|
|
Part.LineSegment(self._from_svg(x+w,y+h), self._from_svg(x ,y+h)), # 2
|
|
Part.LineSegment(self._from_svg(x ,y+h), self._from_svg(x ,y )), # 3
|
|
# construction four corners
|
|
Part.LineSegment(self._from_svg(x+rx,y ), self._from_svg(x+rx,y+ry)), # 4
|
|
Part.LineSegment(self._from_svg(x+rx,y+ry), self._from_svg(x ,y+ry)), # 5
|
|
Part.LineSegment(self._from_svg(x+w-rx,y ), self._from_svg(x+w-rx,y+ry)), # 6
|
|
Part.LineSegment(self._from_svg(x+w-rx,y+ry), self._from_svg(x+w ,y+ry)), # 7
|
|
Part.LineSegment(self._from_svg(x+w-rx,y+h ), self._from_svg(x+w-rx,y+h-ry)), # 8
|
|
Part.LineSegment(self._from_svg(x+w-rx,y+h-ry), self._from_svg(x+w ,y+h-ry)), # 9
|
|
Part.LineSegment(self._from_svg(x+rx,y+h ), self._from_svg(x+rx,y+h-ry)), # 10
|
|
Part.LineSegment(self._from_svg(x+rx,y+h-ry), self._from_svg(x ,y+h-ry)) # 11
|
|
], True)
|
|
self.ske.addConstraint([
|
|
## outer construction corners
|
|
Sketcher.Constraint('Coincident', i+0,2, i+1,1),
|
|
Sketcher.Constraint('Coincident', i+1,2, i+2,1),
|
|
Sketcher.Constraint('Coincident', i+2,2, i+3,1),
|
|
Sketcher.Constraint('Coincident', i+3,2, i+0,1),
|
|
## inner construction corners
|
|
Sketcher.Constraint('Coincident', i+4,2, i+5,1),
|
|
Sketcher.Constraint('Coincident', i+6,2, i+7,1),
|
|
Sketcher.Constraint('Coincident', i+8,2, i+9,1),
|
|
Sketcher.Constraint('Coincident', i+10,2, i+11,1),
|
|
## inner construction equality
|
|
Sketcher.Constraint('Equal', i+4, i+6),
|
|
Sketcher.Constraint('Equal', i+4, i+8),
|
|
Sketcher.Constraint('Equal', i+4, i+10),
|
|
Sketcher.Constraint('Equal', i+5, i+7),
|
|
Sketcher.Constraint('Equal', i+5, i+9),
|
|
Sketcher.Constraint('Equal', i+5, i+11),
|
|
## corner cube outlines construction
|
|
Sketcher.Constraint('PointOnObject',i+4,1, i+0),
|
|
Sketcher.Constraint('PointOnObject',i+5,2, i+3),
|
|
Sketcher.Constraint('PointOnObject',i+6,1, i+0),
|
|
Sketcher.Constraint('PointOnObject',i+7,2, i+1),
|
|
Sketcher.Constraint('PointOnObject',i+8,1, i+2),
|
|
Sketcher.Constraint('PointOnObject',i+9,2, i+1),
|
|
Sketcher.Constraint('PointOnObject',i+10,1, i+2),
|
|
Sketcher.Constraint('PointOnObject',i+11,2, i+3),
|
|
## horizontal construction
|
|
Sketcher.Constraint('Parallel', i, i+2),
|
|
Sketcher.Constraint('Parallel', i, i+5),
|
|
Sketcher.Constraint('Parallel', i, i+7),
|
|
Sketcher.Constraint('Parallel', i, i+9),
|
|
Sketcher.Constraint('Parallel', i, i+11),
|
|
## vertical construction
|
|
Sketcher.Constraint('Parallel', i+1, i+3),
|
|
Sketcher.Constraint('Parallel', i+1, i+4),
|
|
Sketcher.Constraint('Parallel', i+1, i+6),
|
|
Sketcher.Constraint('Parallel', i+1, i+8),
|
|
Sketcher.Constraint('Parallel', i+1, i+10)
|
|
])
|
|
ske.addGeometry([
|
|
# sides of the rect
|
|
Part.LineSegment(self._from_svg(x+rx ,y ), self._from_svg(x+w-rx,y )), # 12
|
|
Part.LineSegment(self._from_svg(x+w ,y+ry ), self._from_svg(x+w ,y+h-ry)), # 13
|
|
Part.LineSegment(self._from_svg(x+w-rx,y+h ), self._from_svg(x+rx ,y+h )), # 14
|
|
Part.LineSegment(self._from_svg(x ,y+h-ry), self._from_svg(x ,y+ry )) # 15
|
|
])
|
|
# arcs top left, top right, botton right, bottom left.
|
|
# circles rotate counter clockwise. pi/2 is north, pi is west, 2*pi is east
|
|
a_tl = self.objArc("", x+rx , y+ry, rx, ry, -2/2.*math.pi, -1/2.*math.pi, False, node, mat)
|
|
a_tr = self.objArc("", x-rx+w, y+ry, rx, ry, -1/2.*math.pi, 0/2.*math.pi, False, node, mat)
|
|
a_br = self.objArc("", x-rx+w, y-ry+h, rx, ry, 0/2.*math.pi, 1/2.*math.pi, False, node, mat)
|
|
a_bl = self.objArc("", x+rx , y-ry+h, rx, ry, 1/2.*math.pi, 2/2.*math.pi, False, node, mat)
|
|
if True:
|
|
self.ske.addConstraint([
|
|
# connect the corners to the edges. smooth
|
|
Sketcher.Constraint('Tangent', a_tl[1][0],a_tl[1][1], i+12,1),
|
|
Sketcher.Constraint('Tangent', i+12,2, a_tr[0][0],a_tr[0][1]),
|
|
Sketcher.Constraint('Tangent', a_tr[1][0],a_tr[1][1], i+13,1),
|
|
Sketcher.Constraint('Tangent', i+13,2, a_br[0][0],a_br[0][1]),
|
|
Sketcher.Constraint('Tangent', a_br[1][0],a_br[1][1], i+14,1),
|
|
Sketcher.Constraint('Tangent', i+14,2, a_bl[0][0],a_bl[0][1]),
|
|
Sketcher.Constraint('Tangent', a_bl[1][0],a_bl[1][1], i+15,1),
|
|
Sketcher.Constraint('Tangent', i+15,2, a_tl[0][0],a_bl[0][1]),
|
|
])
|
|
if False:
|
|
self.ske.addConstraint([
|
|
# stitch the rounded rect to the construction grid
|
|
Sketcher.Constraint('Coincident', i+12,1, i+4,1),
|
|
Sketcher.Constraint('Coincident', i+12,2, i+6,1),
|
|
Sketcher.Constraint('Coincident', i+13,1, i+7,2),
|
|
Sketcher.Constraint('Coincident', i+13,2, i+9,2),
|
|
Sketcher.Constraint('Coincident', i+14,1, i+8,1),
|
|
Sketcher.Constraint('Coincident', i+14,2, i+10,1),
|
|
Sketcher.Constraint('Coincident', i+15,1, i+11,2),
|
|
Sketcher.Constraint('Coincident', i+15,2, i+5,2)
|
|
])
|
|
if False and a_tr[3] is not None: # ArcOfCirle has no majAxis
|
|
self.ske.addConstraint([
|
|
# make all major axis parallel, and same length
|
|
Sketcher.Constraint('Parallel', a_tr[3], a_tl[3]),
|
|
Sketcher.Constraint('Equal', a_tr[3], a_tl[3]),
|
|
Sketcher.Constraint('Parallel', a_tr[3], a_bl[3]),
|
|
Sketcher.Constraint('Equal', a_tr[3], a_bl[3]),
|
|
Sketcher.Constraint('Parallel', a_tr[3], a_br[3]),
|
|
# Sketcher.Constraint('Equal', a_tr[3], a_br[3]) # makes everything immobole
|
|
])
|
|
|
|
self.stats['objRect'] += 1
|
|
|
|
|
|
def objRect(self, x, y, w, h, node, mat):
|
|
self._matrix_from_svg(mat)
|
|
i = int(self.ske.GeometryCount)
|
|
self.ske.addGeometry([
|
|
Part.LineSegment(self._from_svg(x ,y ), self._from_svg(x+w,y )),
|
|
Part.LineSegment(self._from_svg(x+w,y ), self._from_svg(x+w,y+h)),
|
|
Part.LineSegment(self._from_svg(x+w,y+h), self._from_svg(x ,y+h)),
|
|
Part.LineSegment(self._from_svg(x ,y+h), self._from_svg(x ,y ))
|
|
], False)
|
|
self.ske.addConstraint([
|
|
Sketcher.Constraint('Coincident', i+0,2, i+1,1),
|
|
Sketcher.Constraint('Coincident', i+1,2, i+2,1),
|
|
Sketcher.Constraint('Coincident', i+2,2, i+3,1),
|
|
Sketcher.Constraint('Coincident', i+3,2, i+0,1),
|
|
Sketcher.Constraint('Parallel', i+2, i+0),
|
|
Sketcher.Constraint('Parallel', i+3, i+1)
|
|
])
|
|
self.stats['objRect'] += 1
|
|
|
|
|
|
def objEllipse(self, cx, cy, rx, ry, node, mat):
|
|
"""
|
|
We distinguish two cases. If it looks like a circle (after transformation),
|
|
we produce a Circle, else we produce an Ellipse.
|
|
The difference is clearly visible as we exposeInternalGeometry() of the Ellipse.
|
|
"""
|
|
### CAUTION: Keep in sync with objArc() below.
|
|
self._matrix_from_svg(mat)
|
|
c = self._from_svg(cx, cy, bbox=False)
|
|
ori = self._from_svg(0, 0, bbox=False)
|
|
vrx = self._from_svg(rx, 0, bbox=False) - ori
|
|
vry = self._from_svg(0, ry, bbox=False) - ori
|
|
i = int(self.ske.GeometryCount)
|
|
|
|
if abs(vrx.Length - vry.Length) < epsilon:
|
|
# it is a circle.
|
|
self.bbox.add(c+vrx+vry)
|
|
self.bbox.add(c-vrx-vry)
|
|
self.ske.addGeometry([ Part.Circle(Center=c, Normal=Base.Vector(0,0,1), Radius=vrx.Length) ])
|
|
self.stats['objEllipse'] += 1
|
|
else:
|
|
# major axis is defined by Center and S1,
|
|
# major radius is the distance between Center and S1,
|
|
# minor radius is the distance between S2 and the major axis.
|
|
s1 = self._from_svg(cx+rx, cy, bbox=False)
|
|
s2 = self._from_svg(cx, cy+ry, bbox=False)
|
|
(V1,V2,V3,V4) = self._ellipse_vertices2d(c, s1, s2)
|
|
self.bbox.add(V1)
|
|
self.bbox.add(V2)
|
|
self.bbox.add(V3)
|
|
self.bbox.add(V4)
|
|
self.ske.addGeometry([ Part.Ellipse(S1=V1, S2=V2, Center=c), ])
|
|
if self.expose_int: self.ske.exposeInternalGeometry(i)
|
|
self.stats['objEllipse'] += 1
|
|
|
|
|
|
def objArc(self, d, cx, cy, rx, ry, st, en, closed, node, mat):
|
|
"""
|
|
We ignore the path d, and produce a nice arc object.
|
|
We distinguish two cases. If it looks like a circle, we produce ArcOfCircle,
|
|
else we produce ArcOfEllipse.
|
|
|
|
To find the arc end points, we use the value() property of the Circle and Ellipse
|
|
objects. With Circle we have to take care of mirror and rotation ourselves.
|
|
|
|
If closed, we connect the two ends to the center with lines. The connections
|
|
are secured with constraints.
|
|
Radii are currently not secured with constraints.
|
|
"""
|
|
### CAUTION: Keep in sync with objEllipse() above.
|
|
# print("objArc: st,en,closed", st, en, closed)
|
|
self._matrix_from_svg(mat)
|
|
c = self._from_svg(cx, cy, bbox=False)
|
|
ori = self._from_svg(0, 0, bbox=False)
|
|
vrx = self._from_svg(rx, 0, bbox=False) - ori
|
|
vry = self._from_svg(0, ry, bbox=False) - ori
|
|
i = self.ske.GeometryCount
|
|
(st_idx,en_idx) = (1,2)
|
|
majAxisIdx = None
|
|
|
|
if abs(vrx.Length - vry.Length) < epsilon:
|
|
# it is a circle.
|
|
self.bbox.add(c+vrx+vry)
|
|
self.bbox.add(c-vrx-vry)
|
|
ce = Part.Circle(Center=c, Normal=Base.Vector(0,0,1), Radius=vrx.Length)
|
|
|
|
# Circles are immune to rotation and mirorring. Apply this manually.
|
|
if self.yflip:
|
|
(st,en) = (-en,-st) ## coord system is mirrored.
|
|
else:
|
|
(st,en) = (en,st) ## hmm.
|
|
print("FIXME: ArcOfCircle() with yflip=False needs debugging.")
|
|
r = Base.Matrix()
|
|
r.rotateZ(st)
|
|
pst = r.multiply(vrx)
|
|
st = pst.getAngle(Base.Vector(1,0,0)) # ce.rotateZ() is a strange beast.
|
|
pst = pst + c
|
|
r = Base.Matrix()
|
|
r.rotateZ(en)
|
|
pen = r.multiply(vrx)
|
|
en = pen.getAngle(Base.Vector(1,0,0)) # ce.rotateZ() is a strange beast.
|
|
pen = pen + c
|
|
|
|
self.ske.addGeometry([ Part.ArcOfCircle(ce, st, en) ])
|
|
self.stats['objArc'] += 1
|
|
|
|
else:
|
|
# major axis is defined by Center and S1,
|
|
# major radius is the distance between Center and S1,
|
|
# minor radius is the distance between S2 and the major axis.
|
|
s1 = self._from_svg(cx+rx, cy, bbox=False)
|
|
s2 = self._from_svg(cx, cy+ry, bbox=False)
|
|
(V1,V2,V3,V4) = self._ellipse_vertices2d(c, s1, s2)
|
|
self.bbox.add(V1)
|
|
self.bbox.add(V2)
|
|
self.bbox.add(V3)
|
|
self.bbox.add(V4)
|
|
i = int(self.ske.GeometryCount)
|
|
ce = Part.Ellipse(S1=V1, S2=V2, Center=c)
|
|
self.ske.addGeometry([ Part.ArcOfEllipse(ce, st, en) ])
|
|
if self.expose_int: self.ske.exposeInternalGeometry(i)
|
|
majAxisIdx = i+1 # CAUTION: is that a safe assumption?
|
|
self.stats['objArc'] += 1
|
|
## CAUTION: with yflip=True sketcher reverses the endpoints of
|
|
## an ArcOfEllipse to: en=1, st=2
|
|
## ArcOfCircle seems unaffected.
|
|
if self.yflip: (st_idx,en_idx) = (2,1)
|
|
r = Base.Matrix()
|
|
r.rotateZ(st)
|
|
pst = r.multiply(vrx) + c
|
|
r = Base.Matrix()
|
|
r.rotateZ(en)
|
|
pen = r.multiply(vrx) + c
|
|
|
|
j = self.ske.GeometryCount
|
|
if closed:
|
|
self.ske.addGeometry([
|
|
Part.LineSegment(ce.value(en),c),
|
|
Part.LineSegment(c,ce.value(st)) ])
|
|
|
|
if True: # when debugging deformations, switch off constriants first.
|
|
self.ske.addConstraint([
|
|
Sketcher.Constraint('Coincident', i+0,en_idx, j+0,1), # arc with line
|
|
Sketcher.Constraint('Coincident', j+1,2, i+0,st_idx), # line with arc
|
|
Sketcher.Constraint('Coincident', j+0,2, j+1,1), # line with line
|
|
Sketcher.Constraint('Coincident', j+0,2, i+0,3) ]) # line with center
|
|
|
|
if False: # some debugging circles.
|
|
self.ske.addGeometry([
|
|
# Part.Circle(Center=pst, Normal=Base.Vector(0,0,1), Radius=2),
|
|
# Part.Circle(Center=pen, Normal=Base.Vector(0,0,1), Radius=3),
|
|
# Part.Circle(Center=ce.value(st), Normal=Base.Vector(0,0,1), Radius=4),
|
|
Part.Circle(Center=ce.value(en), Normal=Base.Vector(0,0,1), Radius=5)
|
|
], True)
|
|
|
|
# we return the start, end and center points, as triple (sketcher_index, sketcher_index_point, Vector)
|
|
return ((i+0, st_idx, ce.value(st)), (i+0, en_idx, ce.value(en)), (i+0, 3, c), majAxisIdx)
|
|
|
|
|
|
fcdoc = FreeCAD.newDocument(docname)
|
|
ske = fcdoc.addObject('Sketcher::SketchObject', 'Sketch_'+docname)
|
|
|
|
svg = InkSvg(pathgen=SketchPathGen(ske, yflip=True, expose_int=((options.expose_internal_geometry+"0")[0] not in "Ff0")))
|
|
svg.load(svgfile) # respin of inkex.affect()
|
|
svg.traverse(options.ids)
|
|
|
|
if verbose > 0:
|
|
print("svg2fcsketch %s, InkSvg %s" % (__version__, InkSvg.__version__))
|
|
if verbose >= 0:
|
|
print(svg.stats)
|
|
# print(svg.docTransform, svg.docWidth, svg.docHeight, svg.dpi)
|
|
|
|
# print(ske)
|
|
#fcdoc.recompute()
|
|
|
|
if not options.outfile:
|
|
import tempfile
|
|
fcstdfile = tempfile.mktemp(prefix=docname, suffix='.fcstd')
|
|
|
|
fcdoc.saveAs(fcstdfile)
|
|
## Add GuiDocument.xml to the zip archive of fcstdfile
|
|
## to switch on default visibilitiy, and set a default camera.
|
|
camera_xml = ''
|
|
if True: # switch off, if this causes errors. Nice to have.
|
|
bb = svg.pathgen.bbox
|
|
if verbose >= 0: print(bb)
|
|
cx = bb.Center.x # 35.246845 # bbox center
|
|
cy = bb.Center.y # 37.463238 # bbox center
|
|
cz = bb.DiagonalLength * 0.5 # 51.437702 # focal distance: 1/2 of bbox diagonal
|
|
zd = cz * 0.001 # 0.05 # +/- for far/near distance
|
|
camera_xml = """<Camera settings=" OrthographicCamera { viewportMapping ADJUST_CAMERA position %f %f %f orientation 0 0 1 0 nearDistance %f farDistance %f aspectRatio 1 focalDistance %f height %f } "/>""" % (cx,cy,cz, cz-zd, cz+zd, cz, 2*cz)
|
|
guidoc_xml = """<?xml version='1.0' encoding='utf-8'?>
|
|
<Document SchemaVersion="1"><!-- as seen in FreeCAD 0.17 -->
|
|
<ViewProviderData Count="1">
|
|
<ViewProvider name="%s" expanded="0">
|
|
<Properties Count="1">
|
|
<Property name="Visibility" type="App::PropertyBool">
|
|
<Bool value="true"/>
|
|
</Property>
|
|
</Properties>
|
|
</ViewProvider>
|
|
</ViewProviderData>
|
|
%s
|
|
</Document>
|
|
""" % ('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)
|