added cookie_cutter extension

This commit is contained in:
Mario Voigt 2022-12-06 01:25:17 +01:00
parent 466df1e710
commit 1dd7a4e368
8 changed files with 1776 additions and 0 deletions

View File

@ -0,0 +1,18 @@
Convert SVG (text is not supported; convert text to paths) and HPGL to gcode for a 3-axis GCode machine,
where the Z-axis controls the pen height.
You can also convert the same SVG subset to HPGL.
Run with no arguments for some help.
Note on multiple pen usage:
The pen definition file is one-pen per line, in the format:
n (x,y) svgcolor comment
Here, n is the pen number (pen 1 is assumed to be loaded at the start), (x,y) is the offset from the
default pen position (note: gcodeplot.py will correct the offset and will NOT check for clipping at
drawing edges--it is your responsibility to make sure your tool doesn't crash into anything due to
offset), svgcolor is a color specification in svg format, e.g., rgb(255,255,00), #FFFF00 or yellow,
and the comment is a human-readable comment.

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<inkscape-extension xmlns="http://www.inkscape.org/namespace/inkscape/extension">
<name>OpenSCAD Cookie Cutter Export</name>
<id>fablabchemnitz.de.cookie_cutter</id>
<output>
<extension>.scad</extension>
<mimetype>text/plain</mimetype>
<filetypename>OpenSCAD cookie cutter file (*.scad)</filetypename>
<filetypetooltip>Export an OpenSCAD cookie cutter file</filetypetooltip>
<dataloss>true</dataloss>
</output>
<script>
<command location="inx" interpreter="python">svg2cookiecutter.py</command>
</script>
</inkscape-extension>

View File

@ -0,0 +1,21 @@
[
{
"name": "OpenSCAD Cookie Cutter Export",
"id": "fablabchemnitz.de.cookie_cutter",
"path": "cookie_cutte",
"dependent_extensions": null,
"original_name": "OpenSCAD Cookie Cutter Export",
"original_id": "mobi.omegacentauri.cookiecutter",
"license": "MIT License",
"license_url": "https://inkscape.org/~arpruss/%E2%98%85openscad-cookie-cutter-file-output",
"comment": "",
"source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.2/src/branch/master/extensions/fablabchemnitz/cookie_cutter",
"fork_url": "https://github.com/arpruss/gcodeplot",
"documentation_url": "https://stadtfabrikanten.org/display/IFM/OpenSCAD+cookie+cutter+file+output",
"inkscape_gallery_url": null,
"main_authors": [
"github.com/arpruss",
"github.com/eridur-de"
]
}
]

View File

@ -0,0 +1,238 @@
import sys
import svgpath.parser as parser
# colors: RED = outer wall
# GREEN = inner wall
# BLACK = inside feature (not cutting all the way)
PRELIM = """// OpenSCAD file automatically generated by svg2cookiercutter.py
// parameters tunable by user
wallHeight = 12;
minWallThickness = 2;
maxWallThickness = 3;
minInsideWallThickness = 1;
maxInsideWallThickness = 3;
wallFlareWidth = 5;
wallFlareThickness = 3;
insideWallFlareWidth = 5;
insideWallFlareThickness = 3;
featureHeight = 8;
minFeatureThickness = 1;
maxFeatureThickness = 3;
connectorThickness = 1.75;
cuttingTaperHeight = 2.5;
cuttingEdgeThickness = 1.25;
// set to non-zero value to generate a demoulding plate
demouldingPlateHeight = 0;
demouldingPlateSlack = 1.5;
// sizing
function clamp(t,minimum,maximum) = min(maximum,max(t,minimum));
function featureThickness(t) = clamp(t,minFeatureThickness,maxFeatureThickness);
function wallThickness(t) = clamp(t,minWallThickness,maxWallThickness);
function insideWallThickness(t) = clamp(t,minInsideWallThickness,maxInsideWallThickness);
size = $OVERALL_SIZE$;
scale = size/$OVERALL_SIZE$;
// helper modules: subshapes
module ribbon(points, thickness=1) {
union() {
for (i=[1:len(points)-1]) {
hull() {
translate(points[i-1]) circle(d=thickness, $fn=8);
translate(points[i]) circle(d=thickness, $fn=8);
}
}
}
}
module wall(points,height,thickness) {
module profile() {
if (height>=cuttingTaperHeight && cuttingTaperHeight>0 && cuttingEdgeThickness<thickness) {
cylinder(h=height-cuttingTaperHeight+0.001,d=thickness,$fn=8);
translate([0,0,height-cuttingTaperHeight]) cylinder(h=cuttingTaperHeight,d1=thickness,d2=cuttingEdgeThickness);
}
else {
cylinder(h=height,d=thickness,$fn=8);
}
}
for (i=[1:len(points)-1]) {
hull() {
translate(points[i-1]) profile();
translate(points[i]) profile();
}
}
}
module outerFlare(path) {
difference() {
render(convexity=10) linear_extrude(height=wallFlareThickness) ribbon(path,thickness=wallFlareWidth);
translate([0,0,-0.01]) linear_extrude(height=wallFlareThickness+0.02) polygon(points=path);
}
}
module innerFlare(path) {
intersection() {
render(convexity=10) linear_extrude(height=insideWallFlareThickness) ribbon(path,thickness=insideWallFlareWidth);
translate([0,0,-0.01]) linear_extrude(height=insideWallFlareThickness+0.02) polygon(points=path);
}
}
module fill(path,height) {
render(convexity=10) linear_extrude(height=height) polygon(points=path);
}
"""
def isRed(rgb):
return rgb is not None and rgb[0] >= 0.4 and rgb[1]+rgb[2] < rgb[0] * 0.25
def isGreen(rgb):
return rgb is not None and rgb[1] >= 0.4 and rgb[0]+rgb[2] < rgb[1] * 0.25
def isBlack(rgb):
return rgb is not None and rgb[0]+rgb[1]+rgb[2]<0.2
class Line(object):
def __init__(self, pathName, points, fill, stroke, strokeWidth):
self.pathName = pathName
self.points = points
self.fill = fill
self.stroke = stroke
self.strokeWidth = strokeWidth
def pathCode(self):
return self.pathName + ' = scale * [' + ','.join(('[%.3f,%.3f]'%tuple(p) for p in self.points)) + '];'
def shapesCode(self):
code = []
if self.stroke:
code.append('wall('+self.pathName+','+self.height+','+self.width+');')
if self.hasOuterFlare:
code.append(' outerFlare('+self.pathName+');')
elif self.hasInnerFlare:
code.append(' innerFlare('+self.pathName+');')
if self.fill:
code.append(' fill('+self.pathName+','+self.fillHeight+');')
return '\n'.join(code) # + '\n'
class OuterWall(Line):
def __init__(self, pathName, points, fill, stroke, strokeWidth):
super(OuterWall, self).__init__(pathName, points, fill, stroke, strokeWidth)
self.height = "wallHeight"
self.width = "wallThickness(%.3f)" % self.strokeWidth
self.fillHeight = "wallHeight"
self.hasOuterFlare = True
self.hasInnerFlare = False
class InnerWall(Line):
def __init__(self, pathName, points, fill, stroke, strokeWidth):
super(InnerWall, self).__init__(pathName, points, fill, stroke, strokeWidth)
self.height = "wallHeight"
self.width = "insideWallThickness(%.3f)" % self.strokeWidth
self.fillHeight = "wallHeight"
self.hasOuterFlare = False
self.hasInnerFlare = True
class Feature(Line):
def __init__(self, pathName, points, fill, stroke, strokeWidth):
super(Feature, self).__init__(pathName, points, fill, stroke, strokeWidth)
self.height = "featureHeight"
self.width = "featureThickness(%.3f)" % self.strokeWidth
self.fillHeight = "featureHeight"
self.hasOuterFlare = False
self.hasInnerFlare = False
class Connector(Line):
def __init__(self, pathName, points, fill):
super(Connector, self).__init__(pathName, points, fill, False, None) # no stroke for connectors, thus no use of self.height and self.width
self.width = None
self.fillHeight = "connectorThickness"
self.hasOuterFlare = False
self.hasInnerFlare = False
def svgToCookieCutter(filename, tolerance=0.1, strokeAll = False):
lines = []
pathCount = 0;
minXY = [float("inf"), float("inf")]
maxXY = [float("-inf"), float("-inf")]
for superpath in parser.getPathsFromSVGFile(filename)[0]:
for path in superpath.breakup():
pathName = '_'+str(pathCount)
pathCount += 1
fill = path.svgState.fill is not None
stroke = strokeAll or path.svgState.stroke is not None
if not stroke and not fill: continue
linearPath = path.linearApproximation(error=tolerance)
points = [(-l.start.real,l.start.imag) for l in linearPath]
points.append((-linearPath[-1].end.real, linearPath[-1].end.imag))
if isRed (path.svgState.fill) or isRed (path.svgState.stroke):
line = OuterWall('outerWall'+pathName, points, fill, stroke, path.svgState.strokeWidth)
elif isGreen(path.svgState.fill) or isGreen(path.svgState.stroke):
line = InnerWall('innerWall'+pathName, points, fill, stroke, path.svgState.strokeWidth)
elif isBlack(path.svgState.fill) or isBlack(path.svgState.stroke):
line = Feature ('feature' +pathName, points, fill, stroke, path.svgState.strokeWidth)
else:
line = Connector('connector'+pathName, points, fill)
for i in range(2):
minXY[i] = min(minXY[i], min(p[i] for p in line.points))
maxXY[i] = max(maxXY[i], max(p[i] for p in line.points))
lines.append(line)
size = max(maxXY[0]-minXY[0], maxXY[1]-minXY[1])
code = [PRELIM]
code.append('// data from svg file')
code += [line.pathCode()+'\n' for line in lines]
code.append(
'// main modules\n'
'module cookieCutter() {')
code += [' ' + line.shapesCode() for line in lines]
code.append('}\n')
# demoulding plate module
positives = [line for line in lines if isinstance(line, OuterWall) and line.stroke and not line.fill]
negatives_stroke = [line for line in lines]
negatives_fill = [line for line in lines if not isinstance(line, OuterWall) and line.fill]
code.append(
"module demouldingPlate(){\n"
" // a plate to help push on the cookie to turn it out\n"
" render(convexity=10) difference() {\n"
" linear_extrude(height=demouldingPlateHeight) union() {")
for line in positives:
code.append(' polygon(points='+line.pathName+');')
code.append(" }\n"
" translate([0,0,-0.01]) linear_extrude(height=demouldingPlateHeight+0.02) union() {")
for line in negatives_stroke:
code.append(' ribbon('+line.pathName+',thickness=demouldingPlateSlack'+('+'+line.width if line.stroke else '')+');')
for line in negatives_fill:
code.append(' polygon(points='+line.pathName+');')
# TODO: we should remove the interior of polygonal inner walls
code.append(' }\n }\n}\n')
code.append('////////////////////////////////////////////////////////////////////////////////')
code.append('// final call, use main modules')
code.append('translate([%.3f*scale + wallFlareWidth/2, %.3f*scale + wallFlareWidth/2,0])' % (-minXY[0],-minXY[1]))
code.append(' cookieCutter();\n')
code.append('// translate([-40,15,0]) cylinder(h=wallHeight+10,d=5,$fn=20); // handle')
code.append('if (demouldingPlateHeight>0)')
code.append(' mirror([1,0,0])')
code.append(' translate([%.3f*scale + wallFlareWidth/2, %.3f*scale + wallFlareWidth/2,0])' % (-minXY[0],-minXY[1]))
code.append(' demouldingPlate();')
return '\n'.join(code).replace('$OVERALL_SIZE$', '%.3f' % size)
if __name__ == '__main__':
print(svgToCookieCutter(sys.argv[1]))

View File

@ -0,0 +1,2 @@
from .path import Path, Line, Arc, CubicBezier, QuadraticBezier
from .parser import parse_path

View File

@ -0,0 +1,700 @@
# SVG Path specification parser
import re
from . import path
import xml.etree.ElementTree as ET
import re
import math
COMMANDS = set('MmZzLlHhVvCcSsQqTtAa')
UPPERCASE = set('MZLHVCSQTA')
COMMAND_RE = re.compile("([MmZzLlHhVvCcSsQqTtAa])")
FLOAT_RE = re.compile("[-+]?[0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?")
SVG_COLORS = {
"aliceblue": (0.941176,0.972549,1),
"antiquewhite": (0.980392,0.921569,0.843137),
"aqua": (0,1,1),
"aquamarine": (0.498039,1,0.831373),
"azure": (0.941176,1,1),
"beige": (0.960784,0.960784,0.862745),
"bisque": (1,0.894118,0.768627),
"black": (0,0,0),
"blanchedalmond": (1,0.921569,0.803922),
"blue": (0,0,1),
"blueviolet": (0.541176,0.168627,0.886275),
"brown": (0.647059,0.164706,0.164706),
"burlywood": (0.870588,0.721569,0.529412),
"cadetblue": (0.372549,0.619608,0.627451),
"chartreuse": (0.498039,1,0),
"chocolate": (0.823529,0.411765,0.117647),
"coral": (1,0.498039,0.313725),
"cornflowerblue": (0.392157,0.584314,0.929412),
"cornsilk": (1,0.972549,0.862745),
"crimson": (0.862745,0.0784314,0.235294),
"cyan": (0,1,1),
"darkblue": (0,0,0.545098),
"darkcyan": (0,0.545098,0.545098),
"darkgoldenrod": (0.721569,0.52549,0.0431373),
"darkgray": (0.662745,0.662745,0.662745),
"darkgreen": (0,0.392157,0),
"darkgrey": (0.662745,0.662745,0.662745),
"darkkhaki": (0.741176,0.717647,0.419608),
"darkmagenta": (0.545098,0,0.545098),
"darkolivegreen": (0.333333,0.419608,0.184314),
"darkorange": (1,0.54902,0),
"darkorchid": (0.6,0.196078,0.8),
"darkred": (0.545098,0,0),
"darksalmon": (0.913725,0.588235,0.478431),
"darkseagreen": (0.560784,0.737255,0.560784),
"darkslateblue": (0.282353,0.239216,0.545098),
"darkslategray": (0.184314,0.309804,0.309804),
"darkslategrey": (0.184314,0.309804,0.309804),
"darkturquoise": (0,0.807843,0.819608),
"darkviolet": (0.580392,0,0.827451),
"deeppink": (1,0.0784314,0.576471),
"deepskyblue": (0,0.74902,1),
"dimgray": (0.411765,0.411765,0.411765),
"dimgrey": (0.411765,0.411765,0.411765),
"dodgerblue": (0.117647,0.564706,1),
"firebrick": (0.698039,0.133333,0.133333),
"floralwhite": (1,0.980392,0.941176),
"forestgreen": (0.133333,0.545098,0.133333),
"fuchsia": (1,0,1),
"gainsboro": (0.862745,0.862745,0.862745),
"ghostwhite": (0.972549,0.972549,1),
"gold": (1,0.843137,0),
"goldenrod": (0.854902,0.647059,0.12549),
"gray": (0.501961,0.501961,0.501961),
"grey": (0.501961,0.501961,0.501961),
"green": (0,0.501961,0),
"greenyellow": (0.678431,1,0.184314),
"honeydew": (0.941176,1,0.941176),
"hotpink": (1,0.411765,0.705882),
"indianred": (0.803922,0.360784,0.360784),
"indigo": (0.294118,0,0.509804),
"ivory": (1,1,0.941176),
"khaki": (0.941176,0.901961,0.54902),
"lavender": (0.901961,0.901961,0.980392),
"lavenderblush": (1,0.941176,0.960784),
"lawngreen": (0.486275,0.988235,0),
"lemonchiffon": (1,0.980392,0.803922),
"lightblue": (0.678431,0.847059,0.901961),
"lightcoral": (0.941176,0.501961,0.501961),
"lightcyan": (0.878431,1,1),
"lightgoldenrodyellow": (0.980392,0.980392,0.823529),
"lightgray": (0.827451,0.827451,0.827451),
"lightgreen": (0.564706,0.933333,0.564706),
"lightgrey": (0.827451,0.827451,0.827451),
"lightpink": (1,0.713725,0.756863),
"lightsalmon": (1,0.627451,0.478431),
"lightseagreen": (0.12549,0.698039,0.666667),
"lightskyblue": (0.529412,0.807843,0.980392),
"lightslategray": (0.466667,0.533333,0.6),
"lightslategrey": (0.466667,0.533333,0.6),
"lightsteelblue": (0.690196,0.768627,0.870588),
"lightyellow": (1,1,0.878431),
"lime": (0,1,0),
"limegreen": (0.196078,0.803922,0.196078),
"linen": (0.980392,0.941176,0.901961),
"magenta": (1,0,1),
"maroon": (0.501961,0,0),
"mediumaquamarine": (0.4,0.803922,0.666667),
"mediumblue": (0,0,0.803922),
"mediumorchid": (0.729412,0.333333,0.827451),
"mediumpurple": (0.576471,0.439216,0.858824),
"mediumseagreen": (0.235294,0.701961,0.443137),
"mediumslateblue": (0.482353,0.407843,0.933333),
"mediumspringgreen": (0,0.980392,0.603922),
"mediumturquoise": (0.282353,0.819608,0.8),
"mediumvioletred": (0.780392,0.0823529,0.521569),
"midnightblue": (0.0980392,0.0980392,0.439216),
"mintcream": (0.960784,1,0.980392),
"mistyrose": (1,0.894118,0.882353),
"moccasin": (1,0.894118,0.709804),
"navajowhite": (1,0.870588,0.678431),
"navy": (0,0,0.501961),
"oldlace": (0.992157,0.960784,0.901961),
"olive": (0.501961,0.501961,0),
"olivedrab": (0.419608,0.556863,0.137255),
"orange": (1,0.647059,0),
"orangered": (1,0.270588,0),
"orchid": (0.854902,0.439216,0.839216),
"palegoldenrod": (0.933333,0.909804,0.666667),
"palegreen": (0.596078,0.984314,0.596078),
"paleturquoise": (0.686275,0.933333,0.933333),
"palevioletred": (0.858824,0.439216,0.576471),
"papayawhip": (1,0.937255,0.835294),
"peachpuff": (1,0.854902,0.72549),
"peru": (0.803922,0.521569,0.247059),
"pink": (1,0.752941,0.796078),
"plum": (0.866667,0.627451,0.866667),
"powderblue": (0.690196,0.878431,0.901961),
"purple": (0.501961,0,0.501961),
"red": (1,0,0),
"rosybrown": (0.737255,0.560784,0.560784),
"royalblue": (0.254902,0.411765,0.882353),
"saddlebrown": (0.545098,0.270588,0.0745098),
"salmon": (0.980392,0.501961,0.447059),
"sandybrown": (0.956863,0.643137,0.376471),
"seagreen": (0.180392,0.545098,0.341176),
"seashell": (1,0.960784,0.933333),
"sienna": (0.627451,0.321569,0.176471),
"silver": (0.752941,0.752941,0.752941),
"skyblue": (0.529412,0.807843,0.921569),
"slateblue": (0.415686,0.352941,0.803922),
"slategray": (0.439216,0.501961,0.564706),
"slategrey": (0.439216,0.501961,0.564706),
"snow": (1,0.980392,0.980392),
"springgreen": (0,1,0.498039),
"steelblue": (0.27451,0.509804,0.705882),
"tan": (0.823529,0.705882,0.54902),
"teal": (0,0.501961,0.501961),
"thistle": (0.847059,0.74902,0.847059),
"tomato": (1,0.388235,0.278431),
"turquoise": (0.25098,0.878431,0.815686),
"violet": (0.933333,0.509804,0.933333),
"wheat": (0.960784,0.870588,0.701961),
"white": (1,1,1),
"whitesmoke": (0.960784,0.960784,0.960784),
"yellow": (1,1,0),
"yellowgreen": (0.603922,0.803922,0.196078),
}
def _tokenize_path(pathdef):
for x in COMMAND_RE.split(pathdef):
if x in COMMANDS:
yield x
for token in FLOAT_RE.findall(x):
yield token
def applyMatrix(matrix, z):
return complex(z.real * matrix[0] + z.imag * matrix[1] + matrix[2],
z.real * matrix[3] + z.imag * matrix[4] + matrix[5] )
def matrixMultiply(matrix1, matrix2):
if matrix1 is None:
return matrix2
elif matrix2 is None:
return matrix1
m1 = [matrix1[0:3], matrix1[3:6] ] # don't need last row
m2 = [matrix2[0:3], matrix2[3:6], [0,0,1]]
out = []
for i in range(2):
for j in range(3):
out.append( sum(m1[i][k]*m2[k][j] for k in range(3)) )
return out
def parse_path(pathdef, current_pos=0j, matrix = None, svgState=None):
if matrix is None:
scaler=lambda z : z
else:
scaler=lambda z : applyMatrix(matrix, z)
if svgState is None:
svgState = path.SVGState()
# In the SVG specs, initial movetos are absolute, even if
# specified as 'm'. This is the default behavior here as well.
# But if you pass in a current_pos variable, the initial moveto
# will be relative to that current_pos. This is useful.
elements = list(_tokenize_path(pathdef))
# Reverse for easy use of .pop()
elements.reverse()
segments = path.Path(svgState = svgState)
start_pos = None
command = None
while elements:
if elements[-1] in COMMANDS:
# New command.
last_command = command # Used by S and T
command = elements.pop()
absolute = command in UPPERCASE
command = command.upper()
else:
# If this element starts with numbers, it is an implicit command
# and we don't change the command. Check that it's allowed:
if command is None:
raise ValueError("Unallowed implicit command in %s, position %s" % (
pathdef, len(pathdef.split()) - len(elements)))
last_command = command # Used by S and T
if command == 'M':
# Moveto command.
x = elements.pop()
y = elements.pop()
pos = float(x) + float(y) * 1j
if absolute:
current_pos = pos
else:
current_pos += pos
# when M is called, reset start_pos
# This behavior of Z is defined in svg spec:
# http://www.w3.org/TR/SVG/paths.html#PathDataClosePathCommand
start_pos = current_pos
# Implicit moveto commands are treated as lineto commands.
# So we set command to lineto here, in case there are
# further implicit commands after this moveto.
command = 'L'
elif command == 'Z':
# Close path
if current_pos != start_pos:
segments.append(path.Line(scaler(current_pos), scaler(start_pos)))
if len(segments):
segments.closed = True
current_pos = start_pos
start_pos = None
command = None # You can't have implicit commands after closing.
elif command == 'L':
x = elements.pop()
y = elements.pop()
pos = float(x) + float(y) * 1j
if not absolute:
pos += current_pos
segments.append(path.Line(scaler(current_pos), scaler(pos)))
current_pos = pos
elif command == 'H':
x = elements.pop()
pos = float(x) + current_pos.imag * 1j
if not absolute:
pos += current_pos.real
segments.append(path.Line(scaler(current_pos), scaler(pos)))
current_pos = pos
elif command == 'V':
y = elements.pop()
pos = current_pos.real + float(y) * 1j
if not absolute:
pos += current_pos.imag * 1j
segments.append(path.Line(scaler(current_pos), scaler(pos)))
current_pos = pos
elif command == 'C':
control1 = float(elements.pop()) + float(elements.pop()) * 1j
control2 = float(elements.pop()) + float(elements.pop()) * 1j
end = float(elements.pop()) + float(elements.pop()) * 1j
if not absolute:
control1 += current_pos
control2 += current_pos
end += current_pos
segments.append(path.CubicBezier(scaler(current_pos), scaler(control1), scaler(control2), scaler(end)))
current_pos = end
elif command == 'S':
# Smooth curve. First control point is the "reflection" of
# the second control point in the previous path.
if last_command not in 'CS':
# If there is no previous command or if the previous command
# was not an C, c, S or s, assume the first control point is
# coincident with the current point.
control1 = scaler(current_pos)
else:
# The first control point is assumed to be the reflection of
# the second control point on the previous command relative
# to the current point.
control1 = 2 * scaler(current_pos) - segments[-1].control2
control2 = float(elements.pop()) + float(elements.pop()) * 1j
end = float(elements.pop()) + float(elements.pop()) * 1j
if not absolute:
control2 += current_pos
end += current_pos
segments.append(path.CubicBezier(scaler(current_pos), control1, scaler(control2), scaler(end)))
current_pos = end
elif command == 'Q':
control = float(elements.pop()) + float(elements.pop()) * 1j
end = float(elements.pop()) + float(elements.pop()) * 1j
if not absolute:
control += current_pos
end += current_pos
segments.append(path.QuadraticBezier(scaler(current_pos), scaler(control), scaler(end)))
current_pos = end
elif command == 'T':
# Smooth curve. Control point is the "reflection" of
# the second control point in the previous path.
if last_command not in 'QT':
# If there is no previous command or if the previous command
# was not an Q, q, T or t, assume the first control point is
# coincident with the current point.
control = scaler(current_pos)
else:
# The control point is assumed to be the reflection of
# the control point on the previous command relative
# to the current point.
control = 2 * scaler(current_pos) - segments[-1].control
end = float(elements.pop()) + float(elements.pop()) * 1j
if not absolute:
end += current_pos
segments.append(path.QuadraticBezier(scaler(current_pos), control, scaler(end)))
current_pos = end
elif command == 'A':
radius = float(elements.pop()) + float(elements.pop()) * 1j
rotation = float(elements.pop())
arc = float(elements.pop())
sweep = float(elements.pop())
end = float(elements.pop()) + float(elements.pop()) * 1j
if not absolute:
end += current_pos
segments.append(path.Arc(current_pos, radius, rotation, arc, sweep, end, scaler))
current_pos = end
return segments
def path_from_ellipse(x, y, rx, ry, matrix, state):
arc = "M %.9f %.9f " % (x-rx,y)
arc += "A %.9f %.9f 0 0 1 %.9f %.9f " % (rx, ry, x+rx,y)
arc += "A %.9f %.9f 0 0 1 %.9f %.9f" % (rx, ry, x-rx,y)
return parse_path(arc, matrix=matrix, svgState=state)
def path_from_rect(x,y,w,h,rx,ry, matrix,state):
if not rx and not ry:
rect = "M %.9f %.9f h %.9f v %.9f h %.9f Z" % (x,y,w,h,-w)
else:
if rx is None:
rx = ry
elif ry is None:
ry = rx
rect = "M %.9f %.9f h %.9f " % (x+rx,y,w-2*rx)
rect += "a %.9f %.9f 0 0 1 %.9f %.9f " % (rx, ry, rx, ry)
rect += "v %.9f " % (h-2*ry)
rect += "a %.9f %.9f 0 0 1 %.9f %.9f " % (rx, ry, -rx, ry)
rect += "h %.9f " % -(w-2*rx)
rect += "a %.9f %.9f 0 0 1 %.9f %.9f " % (rx, ry, -rx, -ry)
rect += "v %.9f " % -(h-2*ry)
rect += "a %.9f %.9f 0 0 1 %.9f %.9f Z" % (rx, ry, rx, -ry)
return parse_path(rect, matrix=matrix, svgState=state)
def sizeFromString(text):
"""
Returns size in mm, if possible.
"""
text = re.sub(r'\s',r'', text)
try:
return float(text)*25.4/96 # px
except:
if text[-1] == '%':
return float(text[:-1]) # NOT mm
units = text[-2:].lower()
x = float(text[:-2])
convert = { 'mm':1, 'cm':10, 'in':25.4, 'px':25.4/96, 'pt':25.4/72, 'pc':12*25.4/72 }
try:
return x * convert[units]
except:
return x # NOT mm
def rgbFromColor(colorName):
colorName = colorName.strip().lower()
if colorName == 'none':
return None
cmd = re.split(r'[\s(),]+', colorName)
if cmd[0] == 'rgb':
colors = cmd[1:4]
outColor = []
for c in colors:
if c.endswith('%'):
outColor.append(float(c[:-1]) / 100.)
else:
outColor.append(float(c) / 255.)
return tuple(outColor)
elif colorName.startswith('#'):
if len(colorName) == 4:
return (int(colorName[1],16)/15., int(colorName[2],16)/15., int(colorName[3],16)/15.)
else:
return (int(colorName[1:3],16)/255., int(colorName[3:5],16)/255., int(colorName[5:7],16)/255.)
else:
return SVG_COLORS[colorName]
def getPathsFromSVG(svg):
def updateStateCommand(state,cmd,arg):
if cmd == 'fill':
state.fill = rgbFromColor(arg)
elif cmd == 'fill-opacity':
state.fillOpacity = float(arg)
elif cmd == 'fill-rule':
state.fillRule = arg
# if state.fill is None:
# state.fill = (0.,0.,0.)
elif cmd == 'stroke':
state.stroke = rgbFromColor(arg)
elif cmd == 'stroke-opacity':
state.strokeOpacity = rgbFromColor(arg)
elif cmd == 'stroke-width':
state.strokeWidth = float(arg)
elif cmd == 'vector-effect':
state.strokeWidthScaling = 'non-scaling-stroke' not in cmd
# todo better scaling for non-uniform cases?
def updateState(tree,state,matrix):
state = state.clone()
try:
style = re.sub(r'\s',r'', tree.attrib['style']).lower()
for item in style.split(';'):
cmd,arg = item.split(':')[:2]
updateStateCommand(state,cmd,arg)
except:
pass
for item in tree.attrib:
try:
updateStateCommand(state,item,tree.attrib[item])
except:
pass
if state.strokeWidth and state.strokeWidthScaling:
# this won't work great for non-uniform scaling
h = abs(applyMatrix(matrix, complex(0,state.strokeWidth)) - applyMatrix(matrix, 0j))
w = abs(applyMatrix(matrix, complex(state.strokeWidth,0)) - applyMatrix(matrix, 0j))
state.strokeWidth = (h+w)/2
return state
def reorder(a,b,c,d,e,f):
return [a,c,e, b,d,f]
def updateMatrix(tree, matrix):
try:
transformList = re.split(r'\)[\s,]+', tree.attrib['transform'].strip().lower())
except KeyError:
return matrix
for transform in transformList:
cmd = re.split(r'[,()\s]+', transform)
updateMatrix = None
if cmd[0] == 'matrix':
updateMatrix = reorder(*list(map(float, cmd[1:7])))
elif cmd[0] == 'translate':
x = float(cmd[1])
if len(cmd) >= 3 and cmd[2] != '':
y = float(cmd[2])
else:
y = 0
updateMatrix = reorder(1,0,0,1,x,y)
elif cmd[0] == 'scale':
x = float(cmd[1])
if len(cmd) >= 3 and cmd[2] != '':
y = float(cmd[2])
else:
y = x
updateMatrix = reorder(x,0,0, y,0,0)
elif cmd[0] == 'rotate':
theta = float(cmd[1]) * math.pi / 180.
c = math.cos(theta)
s = math.sin(theta)
updateMatrix = [c, -s, 0, s, c, 0]
if len(cmd) >= 4 and cmd[2] != '':
x = float(cmd[2])
y = float(cmd[3])
updateMatrix = matrixMultiply(updateMatrix, [1,0,-x, 0,1,-y])
updateMatrix = matrixMultiply([1,0,x, 0,1,y], updateMatrix)
elif cmd[0] == 'skewX':
theta = float(cmd[1]) * math.pi / 180.
updateMatrix = [1, math.tan(theta), 0, 0,1,0]
elif cmd[0] == 'skewY':
theta = float(cmd[1]) * math.pi / 180.
updateMatrix = [1,0,0, math.tan(theta),1,0]
matrix = matrixMultiply(matrix, updateMatrix)
return matrix
def updateStateAndMatrix(tree,state,matrix):
matrix = updateMatrix(tree,matrix)
return updateState(tree,state,matrix),matrix
def getPaths(paths, matrix, tree, state, savedElements):
def getFloat(attribute,default=0.):
try:
return float(tree.attrib[attribute].strip())
except KeyError:
return default
tag = re.sub(r'.*}', '', tree.tag).lower()
try:
savedElements[tree.attrib['id']] = tree
except KeyError:
pass
state, matrix = updateStateAndMatrix(tree, state, matrix)
if tag == 'path':
path = parse_path(tree.attrib['d'], matrix=matrix, svgState=state)
if len(path):
paths.append(path)
elif tag == 'circle':
path = path_from_ellipse(getFloat('cx'), getFloat('cy'), getFloat('r'), getFloat('r'), matrix, state)
paths.append(path)
elif tag == 'ellipse':
path = path_from_ellipse(getFloat('cx'), getFloat('cy'), getFloat('rx'), getFloat('ry'), matrix, state)
paths.append(path)
elif tag == 'line':
x1 = getFloat('x1')
y1 = getFloat('y1')
x2 = getFloat('x2')
y2 = getFloat('y2')
p = 'M %.9f %.9f L %.9f %.9f' % (x1,y1,x2,y2)
path = parse_path(p, matrix=matrix, svgState=state)
paths.append(path)
elif tag == 'polygon':
points = re.split(r'[\s,]+', tree.attrib['points'].strip())
p = ' '.join(['M', points[0], points[1], 'L'] + points[2:] + ['Z'])
path = parse_path(p, matrix=matrix, svgState=state)
paths.append(path)
elif tag == 'polyline':
points = re.split(r'[\s,]+', tree.attrib['points'].strip())
p = ' '.join(['M', points[0], points[1], 'L'] + points[2:])
path = parse_path(p, matrix=matrix, svgState=state)
paths.append(path)
elif tag == 'rect':
x = getFloat('x')
y = getFloat('y')
w = getFloat('width')
h = getFloat('height')
rx = getFloat('rx',default=None)
ry = getFloat('ry',default=None)
path = path_from_rect(x,y,w,h,rx,ry, matrix,state)
paths.append(path)
elif tag == 'g' or tag == 'svg':
for child in tree:
getPaths(paths, matrix, child, state, savedElements)
elif tag == 'use':
try:
link = None
for tag in tree.attrib:
if tag.strip().lower().endswith("}href"):
link = tree.attrib[tag]
break
if link is None or link[0] != '#':
raise KeyError
source = savedElements[link[1:]]
x = 0
y = 0
try:
x = float(tree.attrib['x'])
except:
pass
try:
y = float(tree.attrib['y'])
except:
pass
# TODO: handle width and height? (Inkscape does not)
matrix = matrixMultiply(matrix, reorder(1,0,0,1,x,y))
getPaths(paths, matrix, source, state, dict(savedElements))
except KeyError:
pass
def scale(width, height, viewBox, z):
x = (z.real - viewBox[0]) / (viewBox[2] - viewBox[0]) * width
y = (viewBox[3]-z.imag) / (viewBox[3] - viewBox[1]) * height
return complex(x,y)
paths = []
try:
width = sizeFromString(svg.attrib['width'].strip())
except KeyError:
width = None
try:
height = sizeFromString(svg.attrib['height'].strip())
except KeyError:
height = None
try:
viewBox = list(map(float, re.split(r'[\s,]+', svg.attrib['viewBox'].strip())))
except KeyError:
if width is None or height is None:
raise KeyError
viewBox = [0, 0, width*96/25.4, height*96/25.4]
if width is None:
width = viewBox[2] * 25.4/96
if height is None:
height = viewBox[3] * 25.4/96
viewBoxWidth = viewBox[2]
viewBoxHeight = viewBox[3]
viewBox[2] += viewBox[0]
viewBox[3] += viewBox[1]
try:
preserve = svg.attrib['preserveAspectRatio'].strip().lower().split()
if len(preserve[0]) != 8:
raise KeyError
if len(preserve)>=2 and preserve[1] == 'slice':
if viewBoxWidth/viewBoxHeight > width/height:
# viewbox is wider than viewport, so scale by height to ensure
# viewbox covers the viewport
rescale = height / viewBoxHeight
else:
rescale = width / viewBoxWidth
else:
if viewBoxWidth/viewBoxHeight > width/height:
# viewbox is wider than viewport, so scale by width to ensure
# viewport covers the viewbox
rescale = width / viewBoxWidth
else:
rescale = height / viewBoxHeight
matrix = [rescale, 0, 0,
0, rescale, 0];
if preserve[0][0:4] == 'xmin':
# viewBox[0] to 0
matrix[2] = -viewBox[0] * rescale
elif preserve[0][0:4] == 'xmid':
# viewBox[0] to width/2
matrix[2] = -viewBox[0] * rescale + width/2
else: # preserve[0][0:4] == 'xmax':
# viewBox[0] to width
matrix[2] = -viewBox[0] * rescale + width
if preserve[0][4:8] == 'ymin':
# viewBox[1] to 0
matrix[5] = -viewBox[1] * rescale
elif preserve[0][4:8] == 'ymid':
# viewBox[0] to width/2
matrix[5] = -viewBox[1] * rescale + height/2
else: # preserve[0][4:8] == 'xmax':
# viewBox[0] to width
matrix[5] = -viewBox[1] * rescale + height
except:
matrix = [ width/viewBoxWidth, 0, -viewBox[0]* width/viewBoxWidth,
0, -height/viewBoxHeight, viewBox[3]*height/viewBoxHeight ]
getPaths(paths, matrix, svg, path.SVGState(), {})
return ( paths, applyMatrix(matrix, complex(viewBox[0], viewBox[1])),
applyMatrix(matrix, complex(viewBox[2], viewBox[3])) )
def getPathsFromSVGFile(filename):
return getPathsFromSVG(ET.parse(filename).getroot())

View File

@ -0,0 +1,646 @@
from __future__ import division
from math import sqrt, cos, sin, acos, degrees, radians, log
from collections.abc import MutableSequence
# This file contains classes for the different types of SVG path segments as
# well as a Path object that contains a sequence of path segments.
MIN_DEPTH = 5
ERROR = 1e-12
def segment_length(curve, start, end, start_point, end_point, error, min_depth, depth):
"""Recursively approximates the length by straight lines"""
mid = (start + end) / 2
mid_point = curve.point(mid)
length = abs(end_point - start_point)
first_half = abs(mid_point - start_point)
second_half = abs(end_point - mid_point)
length2 = first_half + second_half
if (length2 - length > error) or (depth < min_depth):
# Calculate the length of each segment:
depth += 1
return (segment_length(curve, start, mid, start_point, mid_point,
error, min_depth, depth) +
segment_length(curve, mid, end, mid_point, end_point,
error, min_depth, depth))
# This is accurate enough.
return length2
def approximate(path, start, end, start_point, end_point, max_error, depth, max_depth):
if depth >= max_depth:
return [start_point, end_point]
actual_length = path.measure(start, end, error=max_error/4)
linear_length = abs(end_point - start_point)
# Worst case deviation given a fixed linear_length and actual_length would probably be
# a symmetric tent shape (I haven't proved it -- TODO).
deviationSquared = (actual_length/2)**2 - (linear_length/2)**2
if deviationSquared <= max_error ** 2:
return [start_point, end_point]
else:
mid = (start+end)/2.
mid_point = path.point(mid)
return ( approximate(path, start, mid, start_point, mid_point, max_error, depth+1, max_depth)[:-1] +
approximate(path, mid, end, mid_point, end_point, max_error, depth+1, max_depth) )
def removeCollinear(points, error, pointsToKeep=set()):
out = []
lengths = [0]
for i in range(1,len(points)):
lengths.append(lengths[-1] + abs(points[i]-points[i-1]))
def length(a,b):
return lengths[b] - lengths[a]
i = 0
while i < len(points):
j = len(points) - 1
while i < j:
deviationSquared = (length(i, j)/2)**2 - (abs(points[j]-points[i])/2)**2
if deviationSquared <= error ** 2 and set(range(i+1,j)).isdisjoint(pointsToKeep):
out.append(points[i])
i = j
break
j -= 1
out.append(points[j])
i += 1
return out
class Segment(object):
def __init__(self, start, end):
self.start = start
self.end = end
def measure(self, start, end, error=ERROR, min_depth=MIN_DEPTH):
return Path(self).measure(start, end, error=error, min_depth=min_depth)
def getApproximatePoints(self, error=0.001, max_depth=32):
points = approximate(self, 0., 1., self.point(0.), self.point(1.), error, 0, max_depth)
return points
class Line(Segment):
def __init__(self, start, end):
super(Line, self).__init__(start,end)
def __repr__(self):
return 'Line(start=%s, end=%s)' % (self.start, self.end)
def __eq__(self, other):
if not isinstance(other, Line):
return NotImplemented
return self.start == other.start and self.end == other.end
def __ne__(self, other):
if not isinstance(other, Line):
return NotImplemented
return not self == other
def getApproximatePoints(self, error=0.001, max_depth=32):
return [self.start, self.end]
def point(self, pos):
if pos == 0.:
return self.start
elif pos == 1.:
return self.end
distance = self.end - self.start
return self.start + distance * pos
def length(self, error=None, min_depth=None):
distance = (self.end - self.start)
return sqrt(distance.real ** 2 + distance.imag ** 2)
class CubicBezier(Segment):
def __init__(self, start, control1, control2, end):
super(CubicBezier, self).__init__(start,end)
self.control1 = control1
self.control2 = control2
def __repr__(self):
return 'CubicBezier(start=%s, control1=%s, control2=%s, end=%s)' % (
self.start, self.control1, self.control2, self.end)
def __eq__(self, other):
if not isinstance(other, CubicBezier):
return NotImplemented
return self.start == other.start and self.end == other.end and \
self.control1 == other.control1 and self.control2 == other.control2
def __ne__(self, other):
if not isinstance(other, CubicBezier):
return NotImplemented
return not self == other
def is_smooth_from(self, previous):
"""Checks if this segment would be a smooth segment following the previous"""
if isinstance(previous, CubicBezier):
return (self.start == previous.end and
(self.control1 - self.start) == (previous.end - previous.control2))
else:
return self.control1 == self.start
def point(self, pos):
"""Calculate the x,y position at a certain position of the path"""
if pos == 0.:
return self.start
elif pos == 1.:
return self.end
return ((1 - pos) ** 3 * self.start) + \
(3 * (1 - pos) ** 2 * pos * self.control1) + \
(3 * (1 - pos) * pos ** 2 * self.control2) + \
(pos ** 3 * self.end)
def length(self, error=ERROR, min_depth=MIN_DEPTH):
"""Calculate the length of the path up to a certain position"""
start_point = self.point(0)
end_point = self.point(1)
return segment_length(self, 0, 1, start_point, end_point, error, min_depth, 0)
class QuadraticBezier(Segment):
def __init__(self, start, control, end):
super(QuadraticBezier, self).__init__(start,end)
self.control = control
def __repr__(self):
return 'QuadraticBezier(start=%s, control=%s, end=%s)' % (
self.start, self.control, self.end)
def __eq__(self, other):
if not isinstance(other, QuadraticBezier):
return NotImplemented
return self.start == other.start and self.end == other.end and \
self.control == other.control
def __ne__(self, other):
if not isinstance(other, QuadraticBezier):
return NotImplemented
return not self == other
def is_smooth_from(self, previous):
"""Checks if this segment would be a smooth segment following the previous"""
if isinstance(previous, QuadraticBezier):
return (self.start == previous.end and
(self.control - self.start) == (previous.end - previous.control))
else:
return self.control == self.start
def point(self, pos):
if pos == 0.:
return self.start
elif pos == 1.:
return self.end
return (1 - pos) ** 2 * self.start + 2 * (1 - pos) * pos * self.control + \
pos ** 2 * self.end
def length(self, error=None, min_depth=None):
a = self.start - 2*self.control + self.end
b = 2*(self.control - self.start)
a_dot_b = a.real*b.real + a.imag*b.imag
if abs(a) < 1e-12:
s = abs(b)
elif abs(a_dot_b + abs(a)*abs(b)) < 1e-12:
k = abs(b)/abs(a)
if k >= 2:
s = abs(b) - abs(a)
else:
s = abs(a)*(k**2/2 - k + 1)
else:
# For an explanation of this case, see
# http://www.malczak.info/blog/quadratic-bezier-curve-length/
A = 4 * (a.real ** 2 + a.imag ** 2)
B = 4 * (a.real * b.real + a.imag * b.imag)
C = b.real ** 2 + b.imag ** 2
Sabc = 2 * sqrt(A + B + C)
A2 = sqrt(A)
A32 = 2 * A * A2
C2 = 2 * sqrt(C)
BA = B / A2
s = (A32 * Sabc + A2 * B * (Sabc - C2) + (4 * C * A - B ** 2) *
log((2 * A2 + BA + Sabc) / (BA + C2))) / (4 * A32)
return s
class Arc(Segment):
def __init__(self, start, radius, rotation, arc, sweep, end, scaler=lambda z:z):
"""radius is complex, rotation is in degrees,
large and sweep are 1 or 0 (True/False also work)"""
super(Arc, self).__init__(scaler(start),scaler(end))
self.start0 = start
self.end0 = end
self.radius = radius
self.rotation = rotation
self.arc = bool(arc)
self.sweep = bool(sweep)
self.scaler = scaler
self._parameterize()
def __repr__(self):
return 'Arc(start0=%s, radius=%s, rotation=%s, arc=%s, sweep=%s, end0=%s, scaler=%s)' % (
self.start0, self.radius, self.rotation, self.arc, self.sweep, self.end0, self.scaler)
def __eq__(self, other):
if not isinstance(other, Arc):
return NotImplemented
return self.start == other.start and self.end == other.end and \
self.radius == other.radius and self.rotation == other.rotation and \
self.arc == other.arc and self.sweep == other.sweep
def __ne__(self, other):
if not isinstance(other, Arc):
return NotImplemented
return not self == other
def _parameterize(self):
# Conversion from endpoint to center parameterization
# http://www.w3.org/TR/SVG/implnote.html#ArcImplementationNotes
cosr = cos(radians(self.rotation))
sinr = sin(radians(self.rotation))
dx = (self.start0.real - self.end0.real) / 2
dy = (self.start0.imag - self.end0.imag) / 2
x1prim = cosr * dx + sinr * dy
x1prim_sq = x1prim * x1prim
y1prim = -sinr * dx + cosr * dy
y1prim_sq = y1prim * y1prim
rx = self.radius.real
rx_sq = rx * rx
ry = self.radius.imag
ry_sq = ry * ry
# Correct out of range radii
radius_check = (x1prim_sq / rx_sq) + (y1prim_sq / ry_sq)
if radius_check > 1:
rx *= sqrt(radius_check)
ry *= sqrt(radius_check)
rx_sq = rx * rx
ry_sq = ry * ry
t1 = rx_sq * y1prim_sq
t2 = ry_sq * x1prim_sq
c = sqrt(abs((rx_sq * ry_sq - t1 - t2) / (t1 + t2)))
if self.arc == self.sweep:
c = -c
cxprim = c * rx * y1prim / ry
cyprim = -c * ry * x1prim / rx
self.center = complex((cosr * cxprim - sinr * cyprim) +
((self.start0.real + self.end0.real) / 2),
(sinr * cxprim + cosr * cyprim) +
((self.start0.imag + self.end0.imag) / 2))
ux = (x1prim - cxprim) / rx
uy = (y1prim - cyprim) / ry
vx = (-x1prim - cxprim) / rx
vy = (-y1prim - cyprim) / ry
n = sqrt(ux * ux + uy * uy)
p = ux
theta = degrees(acos(p / n))
if uy < 0:
theta = -theta
self.theta = theta % 360
n = sqrt((ux * ux + uy * uy) * (vx * vx + vy * vy))
p = ux * vx + uy * vy
d = p/n
# In certain cases the above calculation can through inaccuracies
# become just slightly out of range, f ex -1.0000000000000002.
if d > 1.0:
d = 1.0
elif d < -1.0:
d = -1.0
delta = degrees(acos(d))
if (ux * vy - uy * vx) < 0:
delta = -delta
self.delta = delta % 360
if not self.sweep:
self.delta -= 360
def point(self, pos):
if pos == 0.:
return self.start
elif pos == 1.:
return self.end
angle = radians(self.theta + (self.delta * pos))
cosr = cos(radians(self.rotation))
sinr = sin(radians(self.rotation))
x = (cosr * cos(angle) * self.radius.real - sinr * sin(angle) *
self.radius.imag + self.center.real)
y = (sinr * cos(angle) * self.radius.real + cosr * sin(angle) *
self.radius.imag + self.center.imag)
return self.scaler(complex(x, y))
def length(self, error=ERROR, min_depth=MIN_DEPTH):
"""The length of an elliptical arc segment requires numerical
integration, and in that case it's simpler to just do a geometric
approximation, as for cubic bezier curves.
"""
start_point = self.point(0)
end_point = self.point(1)
return segment_length(self, 0, 1, start_point, end_point, error, min_depth, 0)
class SVGState(object):
def __init__(self, fill=(0.,0.,0.), fillOpacity=None, fillRule='nonzero', stroke=None, strokeOpacity=None, strokeWidth=0.1, strokeWidthScaling=True):
self.fill = fill
self.fillOpacity = fillOpacity
self.fillRule = fillRule
self.stroke = stroke
self.strokeOpacity = strokeOpacity
self.strokeWidth = strokeWidth
self.strokeWidthScaling = strokeWidthScaling
def clone(self):
return SVGState(fill=self.fill, fillOpacity=self.fillOpacity, fillRule=self.fillRule, stroke=self.stroke, strokeOpacity=self.strokeOpacity,
strokeWidth=self.strokeWidth, strokeWidthScaling=self.strokeWidthScaling)
class Path(MutableSequence):
"""A Path is a sequence of path segments"""
# Put it here, so there is a default if unpickled.
_closed = False
def __init__(self, *segments, **kw):
self._segments = list(segments)
self._length = None
self._lengths = None
if 'closed' in kw:
self.closed = kw['closed']
if 'svgState' in kw:
self.svgState = kw['svgState']
else:
self.svgState = SVGState()
def __getitem__(self, index):
return self._segments[index]
def __setitem__(self, index, value):
self._segments[index] = value
self._length = None
def __delitem__(self, index):
del self._segments[index]
self._length = None
def insert(self, index, value):
self._segments.insert(index, value)
self._length = None
def reverse(self):
# Reversing the order of a path would require reversing each element
# as well. That's not implemented.
raise NotImplementedError
def __len__(self):
return len(self._segments)
def __repr__(self):
return 'Path(%s, closed=%s)' % (
', '.join(repr(x) for x in self._segments), self.closed)
def __eq__(self, other):
if not isinstance(other, Path):
return NotImplemented
if len(self) != len(other):
return False
for s, o in zip(self._segments, other._segments):
if not s == o:
return False
return True
def __ne__(self, other):
if not isinstance(other, Path):
return NotImplemented
return not self == other
def _calc_lengths(self, error=ERROR, min_depth=MIN_DEPTH):
## TODO: check if error has decreased since last calculation
if self._length is not None:
return
lengths = [each.length(error=error, min_depth=min_depth) for each in self._segments]
self._length = sum(lengths)
self._lengths = [each / (1 if self._length==0. else self._length) for each in lengths]
def point(self, pos, error=ERROR):
# Shortcuts
if pos == 0.0:
return self._segments[0].point(pos)
if pos == 1.0:
return self._segments[-1].point(pos)
self._calc_lengths(error=error)
# Find which segment the point we search for is located on:
segment_start = 0
for index, segment in enumerate(self._segments):
segment_end = segment_start + self._lengths[index]
if segment_end >= pos:
# This is the segment! How far in on the segment is the point?
segment_pos = (pos - segment_start) / (segment_end - segment_start)
break
segment_start = segment_end
return segment.point(segment_pos)
def length(self, error=ERROR, min_depth=MIN_DEPTH):
self._calc_lengths(error, min_depth)
return self._length
def measure(self, start, end, error=ERROR, min_depth=MIN_DEPTH):
self._calc_lengths(error=error)
if start == 0.0 and end == 1.0:
return self.length()
length = 0
segment_start = 0
for index, segment in enumerate(self._segments):
if end <= segment_start:
break
segment_end = segment_start + self._lengths[index]
if start < segment_end:
# this segment intersects the part of the path we want
if start <= segment_start and segment_end <= end:
# whole segment is contained in the part of the path
length += self._lengths[index] * self._length
else:
if start <= segment_start:
start_in_segment = 0.
else:
start_in_segment = (start-segment_start)/(segment_end-segment_start)
if segment_end <= end:
end_in_segment = 1.
else:
end_in_segment = (end-segment_start)/(segment_end-segment_start)
segment = self._segments[index]
length += segment_length(segment, start_in_segment, end_in_segment, segment.point(start_in_segment),
segment.point(end_in_segment), error, MIN_DEPTH, 0)
segment_start = segment_end
return length
def _is_closable(self):
"""Returns true if the end is on the start of a segment"""
try:
end = self[-1].end
except:
return False
for segment in self:
if segment.start == end:
return True
return False
def breakup(self):
paths = []
prevEnd = None
segments = []
for segment in self._segments:
if prevEnd is None or segment.point(0.) == prevEnd:
segments.append(segment)
else:
paths.append(Path(*segments, svgState=self.svgState))
segments = [segment]
prevEnd = segment.point(1.)
if len(segments) > 0:
paths.append(Path(*segments, svgState=self.svgState))
return paths
def linearApproximation(self, error=0.001, max_depth=32):
closed = False
keepSegmentIndex = 0
if self.closed:
end = self[-1].end
for i,segment in enumerate(self):
if segment.start == end:
keepSegmentIndex = i
closed = True
break
keepSubpathIndex = 0
keepPointIndex = 0
subpaths = []
subpath = []
prevEnd = None
for i,segment in enumerate(self._segments):
if prevEnd is None or segment.start == prevEnd:
if i == keepSegmentIndex:
keepSubpathIndex = len(subpaths)
keepPointIndex = len(subpath)
else:
subpaths.append(subpath)
subpath = []
subpath += segment.getApproximatePoints(error=error/2., max_depth=max_depth)
prevEnd = segment.end
if len(subpath) > 0:
subpaths.append(subpath)
linearPath = Path(svgState=self.svgState)
for i,subpath in enumerate(subpaths):
keep = set((keepPointIndex,)) if i == keepSubpathIndex else set()
special = None
if i == keepSubpathIndex:
special = subpath[keepPointIndex]
points = removeCollinear(subpath, error=error/2., pointsToKeep=keep)
# points = subpath
for j in range(len(points)-1):
linearPath.append(Line(points[j], points[j+1]))
linearPath.closed = self.closed and linearPath._is_closable()
linearPath.svgState = self.svgState
return linearPath
def getApproximateLines(self, error=0.001, max_depth=32):
lines = []
for subpath in self.breakup():
points = subpath.getApproximatePoints(error=error, max_depth=max_depth)
for i in range(len(points)-1):
lines.append(points[i],points[i+1])
return lines
@property
def closed(self):
"""Checks that the path is closed"""
return self._closed and self._is_closable()
@closed.setter
def closed(self, value):
value = bool(value)
if value and not self._is_closable():
raise ValueError("End does not coincide with a segment start.")
self._closed = value
def d(self):
if self.closed:
segments = self[:-1]
else:
segments = self[:]
current_pos = None
parts = []
previous_segment = None
end = self[-1].end
for segment in segments:
start = segment.start
# If the start of this segment does not coincide with the end of
# the last segment or if this segment is actually the close point
# of a closed path, then we should start a new subpath here.
if current_pos != start or (self.closed and start == end):
parts.append('M {0:G},{1:G}'.format(start.real, start.imag))
if isinstance(segment, Line):
parts.append('L {0:G},{1:G}'.format(
segment.end.real, segment.end.imag)
)
elif isinstance(segment, CubicBezier):
if segment.is_smooth_from(previous_segment):
parts.append('S {0:G},{1:G} {2:G},{3:G}'.format(
segment.control2.real, segment.control2.imag,
segment.end.real, segment.end.imag)
)
else:
parts.append('C {0:G},{1:G} {2:G},{3:G} {4:G},{5:G}'.format(
segment.control1.real, segment.control1.imag,
segment.control2.real, segment.control2.imag,
segment.end.real, segment.end.imag)
)
elif isinstance(segment, QuadraticBezier):
if segment.is_smooth_from(previous_segment):
parts.append('T {0:G},{1:G}'.format(
segment.end.real, segment.end.imag)
)
else:
parts.append('Q {0:G},{1:G} {2:G},{3:G}'.format(
segment.control.real, segment.control.imag,
segment.end.real, segment.end.imag)
)
elif isinstance(segment, Arc):
parts.append('A {0:G},{1:G} {2:G} {3:d},{4:d} {5:G},{6:G}'.format(
segment.radius.real, segment.radius.imag, segment.rotation,
int(segment.arc), int(segment.sweep),
segment.end.real, segment.end.imag)
)
current_pos = segment.end
previous_segment = segment
if self.closed:
parts.append('Z')
return ' '.join(parts)

View File

@ -0,0 +1,136 @@
import math
from operator import itemgetter
class Shader(object):
MODE_EVEN_ODD = 0
MODE_NONZERO = 1
def __init__(self, unshadedThreshold=1., lightestSpacing=3., darkestSpacing=0.5, angle=45, crossHatch=False):
self.unshadedThreshold = unshadedThreshold
self.lightestSpacing = lightestSpacing
self.darkestSpacing = darkestSpacing
self.angle = angle
self.secondaryAngle = angle + 90
self.crossHatch = False
def isActive(self):
return self.unshadedThreshold > 0.000001
def setDrawingDirectionAngle(self, drawingDirectionAngle):
self.drawingDirectionAngle = drawingDirectionAngle
if drawingDirectionAngle is None:
return
if 90 < (self.angle - drawingDirectionAngle) % 360 < 270:
self.angle = (self.angle + 180) % 360
if 90 < (self.secondaryAngle - drawingDirectionAngle) % 360 < 270:
self.secondaryAngle = (self.secondaryAngle + 180) % 360
def shade(self, polygon, grayscale, avoidOutline=True, mode=None):
if mode is None:
mode = Shader.MODE_EVEN_ODD
if grayscale >= self.unshadedThreshold:
return []
intensity = (self.unshadedThreshold-grayscale) / float(self.unshadedThreshold)
spacing = self.lightestSpacing * (1-intensity) + self.darkestSpacing * intensity
lines = Shader.shadePolygon(polygon, self.angle, spacing, avoidOutline=avoidOutline, mode=mode, alternate=(self.drawingDirectionAngle is None))
if self.crossHatch:
lines += Shader.shadePolygon(polygon, self.angle+90, spacing, avoidOutline=avoidOutline, mode=mode, alternate=(self.drawingDirectionAngle is None))
return lines
@staticmethod
def shadePolygon(polygon, angleDegrees, spacing, avoidOutline=True, mode=None, alternate=True):
if mode is None:
mode = Shader.MODE_EVEN_ODD
rotate = complex(math.cos(angleDegrees * math.pi / 180.), math.sin(angleDegrees * math.pi / 180.))
polygon = [(line[0] / rotate,line[1] / rotate) for line in polygon]
spacing = float(spacing)
toAvoid = list(set(line[0].imag for line in polygon)|set(line[1].imag for line in polygon))
if len(toAvoid) <= 1:
deltaY = (toAvoid[0]-spacing/2.) % spacing
else:
# find largest interval
toAvoid.sort()
largestIndex = 0
largestLen = 0
for i in range(len(toAvoid)):
l = ( toAvoid[i] - toAvoid[i-1] ) % spacing
if l > largestLen:
largestIndex = i
largestLen = l
deltaY = (toAvoid[largestIndex-1] + largestLen / 2.) % spacing
minY = min(min(line[0].imag,line[1].imag) for line in polygon)
maxY = max(max(line[0].imag,line[1].imag) for line in polygon)
y = minY + ( - minY ) % spacing + deltaY
if y > minY + spacing:
y -= spacing
y += 0.01
odd = False
all = []
while y < maxY:
intersections = []
for line in polygon:
z = line[0]
z1 = line[1]
if z1.imag == y or z.imag == y: # roundoff generated corner case -- ignore -- TODO
break
if z1.imag < y < z.imag or z.imag < y < z1.imag:
if z1.real == z.real:
intersections.append(( complex(z.real, y), z.imag<y, line))
else:
m = (z1.imag-z.imag)/(z1.real-z.real)
# m * (x - z.real) = y - z.imag
# so: x = (y - z.imag) / m + z.real
intersections.append( (complex((y-z.imag)/m + z.real, y), z.imag<y, line) )
intersections.sort(key=lambda datum: datum[0].real)
thisLine = []
if mode == Shader.MODE_EVEN_ODD:
for i in range(0,len(intersections)-1,2):
thisLine.append((intersections[i], intersections[i+1]))
elif mode == Shader.MODE_NONZERO:
count = 0
for i in range(0,len(intersections)-1):
if intersections[i][1]:
count += 1
else:
count -= 1
if count != 0:
thisLine.append((intersections[i], intersections[i+1]))
else:
raise ValueError()
if odd and alternate:
thisLine = list(reversed([(l[1],l[0]) for l in thisLine]))
if not avoidOutline and len(thisLine) and len(all) and all[-1][1][2] == thisLine[0][0][2]:
# follow along outline to avoid an extra pen bob
all.append( (all[-1][1], thisLine[0][0]) )
all += thisLine
odd = not odd
y += spacing
return [(line[0][0]*rotate, line[1][0]*rotate) for line in all]
if __name__ == '__main__':
polygon=(0+0j, 10+10j, 10+0j, 0+0j)
print(shadePolygon(polygon,90,1))