From 1dd7a4e368874664535e4116c1e971d959b49b6a Mon Sep 17 00:00:00 2001 From: Mario Voigt Date: Tue, 6 Dec 2022 01:25:17 +0100 Subject: [PATCH] added cookie_cutter extension --- .../fablabchemnitz/cookie_cutter/README.md | 18 + .../cookie_cutter/cookiecutter.inx | 15 + .../fablabchemnitz/cookie_cutter/meta.json | 21 + .../cookie_cutter/svg2cookiecutter.py | 238 ++++++ .../cookie_cutter/svgpath/__init__.py | 2 + .../cookie_cutter/svgpath/parser.py | 700 ++++++++++++++++++ .../cookie_cutter/svgpath/path.py | 646 ++++++++++++++++ .../cookie_cutter/svgpath/shader.py | 136 ++++ 8 files changed, 1776 insertions(+) create mode 100644 extensions/fablabchemnitz/cookie_cutter/README.md create mode 100644 extensions/fablabchemnitz/cookie_cutter/cookiecutter.inx create mode 100644 extensions/fablabchemnitz/cookie_cutter/meta.json create mode 100755 extensions/fablabchemnitz/cookie_cutter/svg2cookiecutter.py create mode 100644 extensions/fablabchemnitz/cookie_cutter/svgpath/__init__.py create mode 100644 extensions/fablabchemnitz/cookie_cutter/svgpath/parser.py create mode 100644 extensions/fablabchemnitz/cookie_cutter/svgpath/path.py create mode 100755 extensions/fablabchemnitz/cookie_cutter/svgpath/shader.py diff --git a/extensions/fablabchemnitz/cookie_cutter/README.md b/extensions/fablabchemnitz/cookie_cutter/README.md new file mode 100644 index 0000000..e8dad9d --- /dev/null +++ b/extensions/fablabchemnitz/cookie_cutter/README.md @@ -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. diff --git a/extensions/fablabchemnitz/cookie_cutter/cookiecutter.inx b/extensions/fablabchemnitz/cookie_cutter/cookiecutter.inx new file mode 100644 index 0000000..01dcd4c --- /dev/null +++ b/extensions/fablabchemnitz/cookie_cutter/cookiecutter.inx @@ -0,0 +1,15 @@ + + + OpenSCAD Cookie Cutter Export + fablabchemnitz.de.cookie_cutter + + .scad + text/plain + OpenSCAD cookie cutter file (*.scad) + Export an OpenSCAD cookie cutter file + true + + + \ No newline at end of file diff --git a/extensions/fablabchemnitz/cookie_cutter/meta.json b/extensions/fablabchemnitz/cookie_cutter/meta.json new file mode 100644 index 0000000..3798f62 --- /dev/null +++ b/extensions/fablabchemnitz/cookie_cutter/meta.json @@ -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" + ] + } +] \ No newline at end of file diff --git a/extensions/fablabchemnitz/cookie_cutter/svg2cookiecutter.py b/extensions/fablabchemnitz/cookie_cutter/svg2cookiecutter.py new file mode 100755 index 0000000..4cd775e --- /dev/null +++ b/extensions/fablabchemnitz/cookie_cutter/svg2cookiecutter.py @@ -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= 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])) diff --git a/extensions/fablabchemnitz/cookie_cutter/svgpath/__init__.py b/extensions/fablabchemnitz/cookie_cutter/svgpath/__init__.py new file mode 100644 index 0000000..ad78c9a --- /dev/null +++ b/extensions/fablabchemnitz/cookie_cutter/svgpath/__init__.py @@ -0,0 +1,2 @@ +from .path import Path, Line, Arc, CubicBezier, QuadraticBezier +from .parser import parse_path diff --git a/extensions/fablabchemnitz/cookie_cutter/svgpath/parser.py b/extensions/fablabchemnitz/cookie_cutter/svgpath/parser.py new file mode 100644 index 0000000..2f6f867 --- /dev/null +++ b/extensions/fablabchemnitz/cookie_cutter/svgpath/parser.py @@ -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()) + \ No newline at end of file diff --git a/extensions/fablabchemnitz/cookie_cutter/svgpath/path.py b/extensions/fablabchemnitz/cookie_cutter/svgpath/path.py new file mode 100644 index 0000000..1ea1206 --- /dev/null +++ b/extensions/fablabchemnitz/cookie_cutter/svgpath/path.py @@ -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) + diff --git a/extensions/fablabchemnitz/cookie_cutter/svgpath/shader.py b/extensions/fablabchemnitz/cookie_cutter/svgpath/shader.py new file mode 100755 index 0000000..a49a104 --- /dev/null +++ b/extensions/fablabchemnitz/cookie_cutter/svgpath/shader.py @@ -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