├── inflateutils ├── __init__.py ├── svgpath │ ├── __init__.py │ ├── shader.py │ ├── path.py │ └── parser.py ├── formatdecimal.py ├── exportmesh.py ├── vector.py └── surface.py ├── svg2scad.zip ├── LICENSE ├── inflatemesh-stl.inx ├── demo ├── heart.svg └── B.svg ├── inflatemesh.inx ├── svg2scad.inx ├── vector.py ├── inflatemesh.py └── svg2scad.py /inflateutils/__init__.py: -------------------------------------------------------------------------------- 1 | # lib -------------------------------------------------------------------------------- /svg2scad.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arpruss/inflatemesh/HEAD/svg2scad.zip -------------------------------------------------------------------------------- /inflateutils/svgpath/__init__.py: -------------------------------------------------------------------------------- 1 | from .path import Path, Line, Arc, CubicBezier, QuadraticBezier 2 | from .parser import parse_path 3 | -------------------------------------------------------------------------------- /inflateutils/formatdecimal.py: -------------------------------------------------------------------------------- 1 | def decimal(x,precision=9): 2 | s = ("%."+str(precision)+"f") % x 3 | if '.' not in s or s[-1] != '0': 4 | return s 5 | n = -1 6 | while s[n] == '0': 7 | n -= 1 8 | s = s[:n+1] 9 | if s[-1] == '.': 10 | return s[:-1] 11 | else: 12 | return s 13 | 14 | if __name__ == '__main__': 15 | print(decimal(4.4)) -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Main code copyright (c) 2016-2017 Alexander R. Pruss 2 | SVG path code copyrights: 3 | Lennart Regebro , Original Author 4 | Justin Gruenberg implemented the Quadradic Bezier calculations and provided suggestions and feedback about the d() function. 5 | Michiel Schallig suggested calculating length by recursive straight-line approximations, which enables you to choose between accuracy or speed. Steve Schwarz added an error argument to make that choice an argument. 6 | Thanks also to bug fixers Martin R, abcjjy, Daniel Stender and MTician. 7 | 8 | The MIT License 9 | 10 | Permission is hereby granted, free of charge, to any person obtaining a copy 11 | of this software and associated documentation files (the "Software"), to deal 12 | in the Software without restriction, including without limitation the rights 13 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | copies of the Software, and to permit persons to whom the Software is 15 | furnished to do so, subject to the following conditions: 16 | 17 | The above copyright notice and this permission notice shall be included in all 18 | copies or substantial portions of the Software. 19 | 20 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | SOFTWARE. -------------------------------------------------------------------------------- /inflatemesh-stl.inx: -------------------------------------------------------------------------------- 1 | 2 | 3 | <_name>STL Inflation Export 4 | mobi.omegacentauri.inflatemesh_stl 5 | org.inkscape.output.svg.inkscape 6 | inflatemesh.py 7 | 8 | .stl 9 | text/plain 10 | <_filetypename>STL inflated file (*.stl) 11 | <_filetypetooltip>Export an STL inflation of closed paths 12 | true 13 | 14 | 15 | 16 | 15 17 | 0 18 | 2 19 | 10 20 | 21 | hexagonal 22 | rectangular 23 | 24 | 0 25 | 1 26 | 27 | 28 | 31 | 32 | -------------------------------------------------------------------------------- /demo/heart.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 22 | 24 | image/svg+xml 25 | 27 | 28 | 29 | 30 | 31 | 33 | 53 | 58 | 59 | -------------------------------------------------------------------------------- /inflatemesh.inx: -------------------------------------------------------------------------------- 1 | 2 | 3 | <_name>OpenSCAD Inflation Export 4 | mobi.omegacentauri.inflatemesh 5 | org.inkscape.output.svg.inkscape 6 | inflatemesh.py 7 | 8 | .scad 9 | text/plain 10 | <_filetypename>OpenSCAD inflated file (*.scad) 11 | <_filetypetooltip>Export an OpenSCAD inflation of closed paths 12 | true 13 | 14 | 15 | 16 | 15 17 | 0 18 | 2 19 | 10 20 | 0 21 | 1.25 22 | 23 | hexagonal 24 | rectangular 25 | 26 | 0 27 | 1 28 | 1 29 | svg 30 | 31 | 32 | 35 | 36 | -------------------------------------------------------------------------------- /svg2scad.inx: -------------------------------------------------------------------------------- 1 | 2 | 3 | <_name>OpenSCAD Path Export 4 | mobi.omegacentauri.svg2scad 5 | org.inkscape.output.svg.inkscape 6 | svg2scad.py 7 | 8 | .scad 9 | text/plain 10 | <_filetypename>OpenSCAD path extraction (*.scad) 11 | <_filetypetooltip>Export paths to OpenSCAD points 12 | true 13 | 14 | 15 | 16 | 0.1 17 | 10 18 | 0 19 | 1 20 | 1 21 | 22 | none 23 | absolute 24 | offset 25 | polar 26 | 27 | 28 | object center 29 | object lower left 30 | absolute page coordinates 31 | 32 | 1 33 | 1 34 | svg 35 | 36 | 37 | 40 | 41 | -------------------------------------------------------------------------------- /demo/B.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 39 | 41 | 42 | 44 | image/svg+xml 45 | 47 | 48 | 49 | 50 | 51 | 55 | 59 | 64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /inflateutils/exportmesh.py: -------------------------------------------------------------------------------- 1 | from struct import pack 2 | from .vector import * 3 | from .formatdecimal import decimal 4 | from numbers import Number 5 | import os 6 | import sys 7 | 8 | try: 9 | basestring 10 | except: 11 | basestring = str 12 | 13 | def isColorTriangleList(polys): 14 | return isinstance(polys[0][1][0][0], Number) 15 | 16 | def toPolyhedra(polys): 17 | if isColorTriangleList(polys): 18 | return [ (polys[0][0], list(face for rgb,face in polys)) ] 19 | else: 20 | return polys 21 | 22 | def toMesh(polys): 23 | if isColorTriangleList(polys): 24 | return polys 25 | else: 26 | output = [] 27 | for rgb,polyhedron in polys: 28 | for face in polyhedron: 29 | output.append((rgb,face)) 30 | return output 31 | 32 | def describeColor(c): 33 | if c is None: 34 | return "undef"; 35 | elif isinstance(c, str): 36 | return c 37 | else: 38 | return "[%s,%s,%s]" % tuple(decimal(component) for component in c) 39 | 40 | def toSCADModule(polys, moduleName, digitsAfterDecimal=9, colorOverride=None): 41 | """ 42 | INPUT: 43 | polys: list of (color,polyhedra) pairs (counterclockwise triangles), or a list of (color,triangle) pairs (TODO: currently uses first color for all in latter case) 44 | moduleName: OpenSCAD module name 45 | 46 | OUTPUT: string with OpenSCAD code implementing the polys 47 | """ 48 | 49 | polys = toPolyhedra(polys) 50 | 51 | scad = [] 52 | scad.append("module " +moduleName+ "() {") 53 | for rgb,poly in polys: 54 | if colorOverride != "" and (colorOverride or rgb): 55 | line = " color(%s) " % describeColor(colorOverride if colorOverride else tuple(min(max(c,0.),1.0) for c in rgb)) 56 | else: 57 | line = " " 58 | pointsDict = {} 59 | i = 0 60 | line += "polyhedron(points=[" 61 | points = [] 62 | for face in poly: 63 | for v in reversed(face): 64 | if tuple(v) not in pointsDict: 65 | pointsDict[tuple(v)] = i 66 | points.append( ("[%s,%s,%s]") % tuple(decimal(x,digitsAfterDecimal) for x in v) ) 67 | i += 1 68 | line += ",".join(points) 69 | line += "], faces=[" 70 | line += ",".join( "[" + ",".join(str(pointsDict[tuple(v)]) for v in reversed(face)) + "]" for face in poly ) + "]" 71 | line += ");" 72 | scad.append(line) 73 | scad.append("}\n") 74 | return "\n".join(scad) 75 | 76 | def saveSCAD(filename, polys, moduleName="object1", quiet=False): 77 | """ 78 | filename: filename to write OpenSCAD file 79 | polys: list of (color,polyhedra) pairs (counterclockwise triangles) 80 | moduleName: OpenSCAD module name 81 | quiet: give no status message if set 82 | """ 83 | if not quiet: sys.stderr.write("Saving %s\n" % filename) 84 | if filename: 85 | with open(filename, "w") as f: 86 | f.write(toSCADModule(polys, moduleName)) 87 | f.write("\n" + moduleName + "();\n") 88 | else: 89 | sys.stdout.write(toSCADModule(polys, moduleName)) 90 | sys.stdout.write("\n" + moduleName + "();\n") 91 | 92 | def saveSTL(filename, mesh, swapYZ=False, quiet=False): 93 | """ 94 | filename: filename to save STL file 95 | mesh: list of (color,triangle) pairs (counterclockwise) 96 | swapYZ: should Y/Z axes be swapped? 97 | quiet: give no status message if set 98 | """ 99 | 100 | mesh = toMesh(mesh) 101 | 102 | if not quiet: sys.stderr.write("Saving %s\n" % filename) 103 | minY = float("inf") 104 | minVector = Vector(float("inf"),float("inf"),float("inf")) 105 | numTriangles = 0 106 | if swapYZ: 107 | matrix = Matrix( (1,0,0), (0,0,-1), (0,1,0) ) 108 | else: 109 | matrix = Matrix.identity(3) 110 | 111 | mono = True 112 | for rgb,triangle in mesh: 113 | if rgb is not None: 114 | mono = False 115 | numTriangles += 1 116 | for vertex in triangle: 117 | vertex = matrix*vertex 118 | minVector = Vector(min(minVector[i], vertex[i]) for i in range(3)) 119 | minVector -= Vector(0.001,0.001,0.001) # make sure all STL coordinates are strictly positive as per Wikipedia 120 | 121 | def writeSTL(write): 122 | write(pack("80s",b'')) 123 | write(pack("> 3) << 10 ) | ( (rgb[1] >> 3) << 5 ) | ( (rgb[2] >> 3) << 0 ) 133 | normal = (Vector(tri[1])-Vector(tri[0])).cross(Vector(tri[2])-Vector(tri[0])).normalize() 134 | write(pack("<3f", *(matrix*normal))) 135 | for vertex in tri: 136 | write(pack("<3f", *(matrix*(vertex-minVector)))) 137 | write(pack(" 0.000001 18 | 19 | def setDrawingDirectionAngle(self, drawingDirectionAngle): 20 | self.drawingDirectionAngle = drawingDirectionAngle 21 | 22 | if drawingDirectionAngle is None: 23 | return 24 | 25 | if 90 < (self.angle - drawingDirectionAngle) % 360 < 270: 26 | self.angle = (self.angle + 180) % 360 27 | if 90 < (self.secondaryAngle - drawingDirectionAngle) % 360 < 270: 28 | self.secondaryAngle = (self.secondaryAngle + 180) % 360 29 | 30 | def shade(self, polygon, grayscale, avoidOutline=True, mode=None): 31 | if mode is None: 32 | mode = Shader.MODE_EVEN_ODD 33 | if grayscale >= self.unshadedThreshold: 34 | return [] 35 | intensity = (self.unshadedThreshold-grayscale) / float(self.unshadedThreshold) 36 | spacing = self.lightestSpacing * (1-intensity) + self.darkestSpacing * intensity 37 | lines = Shader.shadePolygon(polygon, self.angle, spacing, avoidOutline=avoidOutline, mode=mode, alternate=(self.drawingDirectionAngle is None)) 38 | if self.crossHatch: 39 | lines += Shader.shadePolygon(polygon, self.angle+90, spacing, avoidOutline=avoidOutline, mode=mode, alternate=(self.drawingDirectionAngle is None)) 40 | return lines 41 | 42 | @staticmethod 43 | def shadePolygon(polygon, angleDegrees, spacing, avoidOutline=True, mode=None, alternate=True): 44 | if mode is None: 45 | mode = Shader.MODE_EVEN_ODD 46 | 47 | rotate = complex(math.cos(angleDegrees * math.pi / 180.), math.sin(angleDegrees * math.pi / 180.)) 48 | 49 | polygon = [(line[0] / rotate,line[1] / rotate) for line in polygon] 50 | 51 | spacing = float(spacing) 52 | 53 | toAvoid = list(set(line[0].imag for line in polygon)|set(line[1].imag for line in polygon)) 54 | 55 | if len(toAvoid) <= 1: 56 | deltaY = (toAvoid[0]-spacing/2.) % spacing 57 | else: 58 | # find largest interval 59 | toAvoid.sort() 60 | largestIndex = 0 61 | largestLen = 0 62 | for i in range(len(toAvoid)): 63 | l = ( toAvoid[i] - toAvoid[i-1] ) % spacing 64 | if l > largestLen: 65 | largestIndex = i 66 | largestLen = l 67 | deltaY = (toAvoid[largestIndex-1] + largestLen / 2.) % spacing 68 | 69 | minY = min(min(line[0].imag,line[1].imag) for line in polygon) 70 | maxY = max(max(line[0].imag,line[1].imag) for line in polygon) 71 | 72 | y = minY + ( - minY ) % spacing + deltaY 73 | 74 | if y > minY + spacing: 75 | y -= spacing 76 | 77 | y += 0.01 78 | 79 | odd = False 80 | 81 | all = [] 82 | 83 | while y < maxY: 84 | intersections = [] 85 | for line in polygon: 86 | z = line[0] 87 | z1 = line[1] 88 | if z1.imag == y or z.imag == y: # roundoff generated corner case -- ignore -- TODO 89 | break 90 | if z1.imag < y < z.imag or z.imag < y < z1.imag: 91 | if z1.real == z.real: 92 | intersections.append(( complex(z.real, y), z.imag vector with components a,b,c,... 8 | Vector(iterable) -> vector whose components are given by the iterable 9 | Vector(cx) -> 2D vector with components cx.real,cx.imag where cx is complex numbers 10 | """ 11 | def __new__(cls, *a): 12 | if len(a) == 1 and hasattr(a[0], '__iter__'): 13 | return tuple.__new__(cls, a[0]) 14 | elif len(a) == 1 and isinstance(a[0], complex): 15 | return tuple.__new__(cls, (a[0].real,a[0].imag)) 16 | else: 17 | return tuple.__new__(cls, a) 18 | 19 | def __add__(self,b): 20 | if isinstance(b, Number): 21 | if b==0.: 22 | return self 23 | else: 24 | raise NotImplementedError 25 | else: 26 | return type(self)(self[i]+b[i] for i in range(max(len(self),len(b)))) 27 | 28 | def __radd__(self,b): 29 | if isinstance(b, Number): 30 | if b==0.: 31 | return self 32 | else: 33 | raise NotImplementedError 34 | else: 35 | return type(self)(self[i]+b[i] for i in range(max(len(self),len(b)))) 36 | 37 | def __sub__(self,b): 38 | return type(self)(self[i]-b[i] for i in range(max(len(self),len(b)))) 39 | 40 | def __rsub__(self,b): 41 | return type(self)(b[i]-self[i] for i in range(max(len(self),len(b)))) 42 | 43 | def __neg__(self): 44 | return type(self)(-comp for comp in self) 45 | 46 | def __mul__(self,b): 47 | if isinstance(b, Number): 48 | return type(self)(comp*b for comp in self) 49 | elif hasattr(b, '__getitem__'): 50 | return sum(self[i] * b[i] for i in range(min(len(self),len(b)))) 51 | else: 52 | raise NotImplementedError 53 | 54 | def __rmul__(self,b): 55 | if isinstance(b, Number): 56 | return type(self)(comp*b for comp in self) 57 | else: 58 | raise NotImplementedError 59 | 60 | def __div__(self,b): 61 | return self*(1./b) 62 | 63 | def __getitem__(self,key): 64 | if isinstance(key, slice): 65 | return type(self)(tuple.__getitem__(self, key)) 66 | elif key >= len(self): 67 | if len(self)>0 and hasattr(self[0],'__getitem__'): 68 | return type(self[0])(0. for i in range(len(self[0]))) 69 | else: 70 | return 0. 71 | else: 72 | return tuple.__getitem__(self, key) 73 | 74 | def norm(self): 75 | return math.sqrt(sum(comp*comp for comp in self)) 76 | 77 | def normalize(self): 78 | n = self.norm() 79 | return type(self)(comp/n for comp in self) 80 | 81 | def cross(self,b): 82 | return Vector(self.y * b.z - self.z * b.y, self.z * b.x - self.x * b.z, self.x * b.y - self.y * b.x) 83 | 84 | def perpendicular(self): 85 | """ 86 | Return one normalized perpendicular vector. 87 | In 2D, that's enough for a basis. 88 | In 3D, you need another, but can generate via cross-product. 89 | In other dimensions, raise NotImplementedError 90 | """ 91 | if len(self) == 2: 92 | return Vector(-self.y, self.x) 93 | elif len(self) == 3: 94 | if abs(self.x) <= min(abs(self.y),abs(self.z)): 95 | return Vector(0.,-self.z,self.y).normalize() 96 | elif abs(self.y) <= min(abs(self.x),abs(self.z)): 97 | return Vector(-self.z,0.,self.x).normalize() 98 | else: 99 | return Vector(-self.y,self.x,0.).normalize() 100 | else: 101 | raise NotImplementedError 102 | 103 | def toComplex(self): 104 | return complex(self[0], self[1]) 105 | 106 | @property 107 | def x(self): 108 | if len(self) < 1: 109 | return 0 110 | return self[0] 111 | 112 | @property 113 | def y(self): 114 | if len(self) < 2: 115 | return 0 116 | return self[1] 117 | 118 | @property 119 | def z(self): 120 | if len(self) < 3: 121 | return 0 122 | return self[2] 123 | 124 | class Matrix(Vector): 125 | """ 126 | a Matrix is a Vector of Vectors. 127 | Two ways of initializing: 128 | Matrix(iterable1,iterable2,...) 129 | Matrix((iterable1,iterable2,...)) 130 | where each iterable is a row. This could be ambiguous if setting a one-row matrix. 131 | In that case, use the second notation: Matrix(((x,y,z))). 132 | """ 133 | def __new__(cls, *a): 134 | if len(a) == 1 and hasattr(a[0], '__iter__'): 135 | return Vector.__new__(cls, tuple(Vector(row) for row in a[0])) 136 | else: 137 | return Vector.__new__(cls, tuple(Vector(row) for row in a)) 138 | 139 | @property 140 | def rows(self): 141 | return len(self) 142 | 143 | @property 144 | def cols(self): 145 | return len(self[0]) 146 | 147 | def __mul__(self,b): 148 | if isinstance(b,Number): 149 | return Matrix(self[i] * b for i in range(self.rows)) 150 | elif isinstance(b,Matrix): 151 | return Matrix((sum(self[i][j]*b[j][k] for j in range(self.cols)) for k in range(b.cols)) for i in range(self.rows)) 152 | elif hasattr(b,'__getitem__'): 153 | return Vector(sum(self[i][j]*b[j] for j in range(self.cols)) for i in range(self.rows)) 154 | else: 155 | raise NotImplementedError 156 | 157 | def __rmul__(self,b): 158 | if isinstance(b,Number): 159 | return Matrix(self[i] * b for i in range(self.rows)) 160 | elif hasattr(b,'__getitem__'): 161 | return Vector(sum(b[i]*self[j][i] for i in range(self.rows)) for j in range(self.cols)) 162 | else: 163 | raise NotImplementedError 164 | 165 | @staticmethod 166 | def identity(n): 167 | return Matrix(Vector(1 if i==j else 0 for i in range(n)) for j in range(n)) 168 | 169 | @staticmethod 170 | def rotateVectorToVector(a,b): 171 | """ 172 | inputs must be normalized 173 | """ 174 | # http://math.stackexchange.com/questions/180418/calculate-rotation-matrix-to-align-vector-a-to-vector-b-in-3d 175 | v = Vector(a).cross(b) 176 | s = v.norm() 177 | c = a*b # dot product 178 | if c == -1: 179 | # todo: handle close to -1 cases 180 | return Matrix((-1,0,0),(0,-1,0),(0,0,-1)) 181 | vx = Matrix((0,-v.z,v.y),(v.z,0,-v.x),(-v.y,v.x,0)) 182 | return Matrix.identity(3) + vx + (1./(1+c)) * vx * vx 183 | 184 | @staticmethod 185 | def rotate2D(theta): 186 | return Matrix([math.cos(theta),-math.sin(theta)],[math.sin(theta),math.cos(theta)]) 187 | 188 | -------------------------------------------------------------------------------- /vector.py: -------------------------------------------------------------------------------- 1 | import math 2 | from numbers import Number 3 | 4 | class Vector(tuple): 5 | """ 6 | Three ways of initializing: 7 | Vector(a,b,c,...) -> vector with components a,b,c,... 8 | Vector(iterable) -> vector whose components are given by the iterable 9 | Vector(cx) -> 2D vector with components cx.real,cx.imag where cx is complex numbers 10 | """ 11 | def __new__(cls, *a): 12 | if len(a) == 1 and hasattr(a[0], '__iter__'): 13 | return tuple.__new__(cls, a[0]) 14 | elif len(a) == 1 and isinstance(a[0], complex): 15 | return tuple.__new__(cls, (a[0].real,a[0].imag)) 16 | else: 17 | return tuple.__new__(cls, a) 18 | 19 | def __add__(self,b): 20 | if isinstance(b, Number): 21 | if b==0.: 22 | return self 23 | else: 24 | raise NotImplementedError 25 | else: 26 | return type(self)(self[i]+b[i] for i in range(max(len(self),len(b)))) 27 | 28 | def __radd__(self,b): 29 | if isinstance(b, Number): 30 | if b==0.: 31 | return self 32 | else: 33 | raise NotImplementedError 34 | else: 35 | return type(self)(self[i]+b[i] for i in range(max(len(self),len(b)))) 36 | 37 | def __sub__(self,b): 38 | return type(self)(self[i]-b[i] for i in range(max(len(self),len(b)))) 39 | 40 | def __rsub__(self,b): 41 | return type(self)(b[i]-self[i] for i in range(max(len(self),len(b)))) 42 | 43 | def __neg__(self): 44 | return type(self)(-comp for comp in self) 45 | 46 | def __mul__(self,b): 47 | if isinstance(b, Number): 48 | return type(self)(comp*b for comp in self) 49 | elif hasattr(b, '__getitem__'): 50 | return sum(self[i] * b[i] for i in range(min(len(self),len(b)))) 51 | else: 52 | raise NotImplementedError 53 | 54 | def __rmul__(self,b): 55 | if isinstance(b, Number): 56 | return type(self)(comp*b for comp in self) 57 | else: 58 | raise NotImplementedError 59 | 60 | def __div__(self,b): 61 | return self*(1./b) 62 | 63 | def __getitem__(self,key): 64 | if isinstance(key, slice): 65 | return type(self)(tuple.__getitem__(self, key)) 66 | elif key >= len(self): 67 | if len(self)>0 and hasattr(self[0],'__getitem__'): 68 | return type(self[0])(0. for i in range(len(self[0]))) 69 | else: 70 | return 0. 71 | else: 72 | return tuple.__getitem__(self, key) 73 | 74 | def norm(self): 75 | return math.sqrt(sum(comp*comp for comp in self)) 76 | 77 | def normalize(self): 78 | n = self.norm() 79 | return type(self)(comp/n for comp in self) 80 | 81 | def cross(self,b): 82 | return Vector(self.y * b.z - self.z * b.y, self.z * b.x - self.x * b.z, self.x * b.y - self.y * b.x) 83 | 84 | def perpendicular(self): 85 | """ 86 | Return one normalized perpendicular vector. 87 | In 2D, that's enough for a basis. 88 | In 3D, you need another, but can generate via cross-product. 89 | In other dimensions, raise NotImplementedError 90 | """ 91 | if len(self) == 2: 92 | return Vector(-self.y, self.x) 93 | elif len(self) == 3: 94 | if abs(self.x) <= min(abs(self.y),abs(self.z)): 95 | return Vector(0.,-self.z,self.y).normalize() 96 | elif abs(self.y) <= min(abs(self.x),abs(self.z)): 97 | return Vector(-self.z,0.,self.x).normalize() 98 | else: 99 | return Vector(-self.y,self.x,0.).normalize() 100 | else: 101 | raise NotImplementedError 102 | 103 | def toComplex(self): 104 | return complex(self[0], self[1]) 105 | 106 | @property 107 | def x(self): 108 | if len(self) < 1: 109 | return 0 110 | return self[0] 111 | 112 | @property 113 | def y(self): 114 | if len(self) < 2: 115 | return 0 116 | return self[1] 117 | 118 | @property 119 | def z(self): 120 | if len(self) < 3: 121 | return 0 122 | return self[2] 123 | 124 | class Matrix(Vector): 125 | """ 126 | a Matrix is a Vector of Vectors. 127 | Two ways of initializing: 128 | Matrix(iterable1,iterable2,...) 129 | Matrix((iterable1,iterable2,...)) 130 | where each iterable is a row. This could be ambiguous if setting a one-row matrix. 131 | In that case, use the second notation: Matrix(((x,y,z))). 132 | """ 133 | def __new__(cls, *a): 134 | if len(a) == 1 and hasattr(a[0], '__iter__'): 135 | return Vector.__new__(cls, tuple(Vector(row) for row in a[0])) 136 | else: 137 | return Vector.__new__(cls, tuple(Vector(row) for row in a)) 138 | 139 | @property 140 | def rows(self): 141 | return len(self) 142 | 143 | @property 144 | def cols(self): 145 | return len(self[0]) 146 | 147 | def __mul__(self,b): 148 | if isinstance(b,Number): 149 | return Matrix(self[i] * b for i in range(self.rows)) 150 | elif isinstance(b,Matrix): 151 | return Matrix((sum(self[i][j]*b[j][k] for j in range(self.cols)) for k in range(b.cols)) for i in range(self.rows)) 152 | elif hasattr(b,'__getitem__'): 153 | return Vector(sum(self[i][j]*b[j] for j in range(self.cols)) for i in range(self.rows)) 154 | else: 155 | raise NotImplementedError 156 | 157 | def __rmul__(self,b): 158 | if isinstance(b,Number): 159 | return Matrix(self[i] * b for i in range(self.rows)) 160 | elif hasattr(b,'__getitem__'): 161 | return Vector(sum(b[i]*self[j][i] for i in range(self.rows)) for j in range(self.cols)) 162 | else: 163 | raise NotImplementedError 164 | 165 | @staticmethod 166 | def identity(n): 167 | return Matrix(Vector(1 if i==j else 0 for i in range(n)) for j in range(n)) 168 | 169 | @staticmethod 170 | def rotateVectorToVector(a,b): 171 | """ 172 | inputs must be normalized 173 | """ 174 | # http://math.stackexchange.com/questions/180418/calculate-rotation-matrix-to-align-vector-a-to-vector-b-in-3d 175 | v = Vector(a).cross(b) 176 | s = v.norm() 177 | c = a*b # dot product 178 | if c == -1: 179 | # todo: handle close to -1 cases 180 | return Matrix((-1,0,0),(0,-1,0),(0,0,-1)) 181 | vx = Matrix((0,-v.z,v.y),(v.z,0,-v.x),(-v.y,v.x,0)) 182 | return Matrix.identity(3) + vx + (1./(1+c)) * vx * vx 183 | 184 | @staticmethod 185 | def rotate2D(theta): 186 | return Matrix([math.cos(theta),-math.sin(theta)],[math.sin(theta),math.cos(theta)]) 187 | 188 | -------------------------------------------------------------------------------- /inflateutils/surface.py: -------------------------------------------------------------------------------- 1 | from __future__ import division 2 | from .vector import * 3 | from .exportmesh import * 4 | from random import uniform 5 | import itertools 6 | import os.path 7 | import math 8 | #from multiprocessing import Process, Array 9 | 10 | class InflationParams(object): 11 | def __init__(self, thickness=10., flatness=0., exponent=2., noise=0., iterations=None, hex=True, clamp=0.): 12 | self.thickness = thickness 13 | self.flatness = flatness 14 | self.exponent = exponent 15 | self.iterations = iterations 16 | self.hex = hex 17 | self.noise = noise 18 | self.noiseExponent = 1.25 19 | self.clamp = clamp 20 | 21 | class MeshData(object): 22 | def __init__(self, cols, rows): 23 | self.cols = cols 24 | self.rows = rows 25 | self.data = tuple([0 for row in range(rows)] for col in range(cols)) 26 | self.mask = tuple([False for row in range(rows)] for col in range(cols)) 27 | 28 | def clearData(self): 29 | for x,y in self.getPoints(useMask=False): 30 | self.data[x][y] = 0. 31 | 32 | def inside(self, col, row): 33 | return 0 <= col < self.cols and 0 <= row < self.rows and self.mask[col][row] 34 | 35 | def getData(self, col, row): 36 | if 0 <= col < self.cols and 0 <= row < self.rows: 37 | return self.data[col][row] 38 | else: 39 | return 0. 40 | 41 | def getNeighborData(self, col, row, i): 42 | x,y = self.getNeighbor(col, row, i) 43 | if 0 <= x < self.cols and 0 <= y < self.rows: 44 | return self.data[x][y] 45 | else: 46 | return 0. 47 | 48 | def getPoints(self, useMask=True): 49 | for i in range(self.cols): 50 | for j in range(self.rows): 51 | if not useMask or self.mask[i][j]: 52 | yield (i,j) 53 | 54 | def getCoordinateBounds(self): # dumb algorithm 55 | left = float("inf") 56 | right = float("-inf") 57 | bottom = float("inf") 58 | top = float("-inf") 59 | for (col,row) in self.getPoints(): 60 | x,y = self.getCoordinates(col,row) 61 | left = min(x,left) 62 | right = max(x,right) 63 | bottom = min(x,bottom) 64 | top = max(x,top) 65 | return left,bottom,right,top 66 | 67 | class RectMeshData(MeshData): 68 | def __init__(self, width, height, lowerLeft, d): 69 | MeshData.__init__(self, 1+int(width / d), 1+int(height / d)) 70 | self.lowerLeft = Vector(lowerLeft) 71 | self.d = d 72 | self.numNeighbors = 4 73 | self.deltas = (Vector(-1,0),Vector(1,0),Vector(0,-1),Vector(0,1)) 74 | self.normalizedDeltas = self.deltas 75 | 76 | def getNeighbor(self, col, row,i): 77 | return (col,row)+self.deltas[i] 78 | 79 | def getCoordinates(self, col,row): 80 | return self.d*Vector(col,row) + self.lowerLeft 81 | 82 | def insideCoordinates(self, v): 83 | v = (v-self.lowerLeft)* (1./self.d) 84 | return self.inside(int(math.floor(0.5+v.x)), int(math.floor(0.5+v.y))) 85 | 86 | def getDeltaLength(self, col, row, i): 87 | return self.d 88 | 89 | def getMesh(self, twoSided=False, color=None): 90 | mesh = [] 91 | 92 | def getValue(z): 93 | return self.getData(z[0],z[1]) 94 | 95 | for x in range(-1,self.cols): 96 | for y in range(-1,self.rows): 97 | v = Vector(x,y) 98 | numPoints = sum(1 for delta in ((0,0), (1,0), (0,1), (1,1)) if self.getData(v.x+delta[0],v.y+delta[1]) > 0.) 99 | 100 | def triangles(d1, d2, d3): 101 | v1,v2,v3 = v+d1,v+d2,v+d3 102 | z1,z2,z3 = map(getValue, (v1,v2,v3)) 103 | if (z1,z2,z3) == (0.,0.,0.): 104 | return [] 105 | v1 = self.getCoordinates(v1.x,v1.y) 106 | v2 = self.getCoordinates(v2.x,v2.y) 107 | v3 = self.getCoordinates(v3.x,v3.y) 108 | output = [(color,(Vector(v1.x,v1.y,z1), Vector(v2.x,v2.y,z2), Vector(v3.x,v3.y,z3)))] 109 | if not twoSided: 110 | z1,z2,z3 = 0.,0.,0. 111 | output.append ( (color,(Vector(v3.x,v3.y,-z3), Vector(v2.x,v2.y,-z2), Vector(v1.x,v1.y,-z1))) ) 112 | return output 113 | 114 | if numPoints > 0: 115 | if getValue(v+(0,0)) == 0. and getValue(v+(1,1)) == 0.: 116 | mesh += triangles((0,0), (1,0), (1,1)) 117 | mesh += triangles((1,1), (0,1), (0,0)) 118 | else: 119 | mesh += triangles((0,0), (1,0), (0,1)) 120 | mesh += triangles((1,0), (1,1), (0,1)) 121 | 122 | return mesh 123 | 124 | 125 | class HexMeshData(MeshData): 126 | def __init__(self, width, height, lowerLeft, d): 127 | self.hd = d 128 | self.vd = d * math.sqrt(3) / 2. 129 | self.lowerLeft = Vector(lowerLeft) + Vector(-self.hd*0.25, self.vd*0.5) 130 | # height += 10 131 | # width += 10 132 | MeshData.__init__(self, 2+int(width / self.hd), 2+int(height / self.vd)) 133 | self.numNeighbors = 6 134 | 135 | self.oddDeltas = (Vector(1,0), Vector(1,1), Vector(0,1), Vector(-1,0), Vector(0,-1), Vector(1,-1)) 136 | self.evenDeltas = (Vector(1,0), Vector(0,1), Vector(-1,1), Vector(-1,0), Vector(-1,-1), Vector(0,-1)) 137 | 138 | a = math.sqrt(3) / 2. 139 | self.normalizedDeltas = (Vector(1.,0.), Vector(0.5,a), Vector(-0.5,a), Vector(-1.,0.), Vector(-0.5,-a), Vector(0.5,-a)) 140 | 141 | def getNeighbor(self, col,row,i): 142 | if row % 2: 143 | return Vector(col,row)+self.oddDeltas[i] 144 | else: 145 | return Vector(col,row)+self.evenDeltas[i] 146 | 147 | def getCoordinates(self, col,row): 148 | return Vector( self.hd * (col + 0.5*(row%2)), self.vd * row ) + self.lowerLeft 149 | 150 | def insideCoordinates(self, v): 151 | row = int(math.floor(0.5+(v.y-self.lowerLeft.y) / self.vd)) 152 | col = int(math.floor(0.5+(v.x-self.lowerLeft.x) / self.hd -0.5*(row%2)) ) 153 | return self.inside(col, row) 154 | 155 | def getColRow(self, v): 156 | row = int(math.floor(0.5+(v.y-self.lowerLeft.y) / self.vd)) 157 | col = int(math.floor(0.5+(v.x-self.lowerLeft.x) / self.hd -0.5*(row%2)) ) 158 | return (col, row) 159 | 160 | def getDeltaLength(self, col, row, i): 161 | return self.hd 162 | 163 | def getMesh(self, twoSided=False, color=None): 164 | mesh = [] 165 | 166 | def getValue(z): 167 | return self.getData(z[0],z[1]) 168 | 169 | done = set() 170 | 171 | for x,y in self.getPoints(): 172 | neighbors = [self.getNeighbor(x,y,i) for i in range(self.numNeighbors)] 173 | for i in range(self.numNeighbors): 174 | triangle = ((x,y), tuple(neighbors[i-1]), tuple(neighbors[i])) 175 | sortedTriangle = tuple(sorted(triangle)) 176 | if sortedTriangle not in done: 177 | done.add(sortedTriangle) 178 | v1,v2,v3 = (self.getCoordinates(p[0],p[1]) for p in triangle) 179 | z1,z2,z3 = (self.getData(p[0],p[1]) for p in triangle) 180 | mesh.append( (color,(Vector(v1.x,v1.y,z1), Vector(v2.x,v2.y,z2), Vector(v3.x,v3.y,z3))) ) 181 | if not twoSided: 182 | z1,z2,z3 = 0.,0.,0. 183 | mesh.append( (color,(Vector(v3.x,v3.y,-z3), Vector(v2.x,v2.y,-z2), Vector(v1.x,v1.y,-z1))) ) 184 | return mesh 185 | 186 | def diamondSquare(n, noiseMagnitude=lambda n:1./(n+1)**2): 187 | def r(n): 188 | m = noiseMagnitude(n) 189 | return uniform(-m,m) 190 | size = int(2**n + 1) 191 | d = size - 1 192 | grid = tuple([0 for i in range(size)] for i in range(size)) 193 | grid[0][0] = r(0) 194 | grid[d][0] = r(0) 195 | grid[0][d] = r(0) 196 | grid[d][d] = r(0) 197 | 198 | iteration = 0 199 | 200 | d //= 2 201 | 202 | while d >= 1: 203 | # diamond 204 | for x in range(d, size-1, d*2): 205 | for y in range(d, size-1, d*2): 206 | grid[x][y] = 0.25 * (grid[x-d][y-d]+grid[x+d][y+d]+grid[x-d][y+d]+grid[x+d][y-d])+r(1+iteration) 207 | # square 208 | for x in range(0, size, d): 209 | for y in range(d*((x//d+1)%2), size, d*2): 210 | grid[x][y] = 0.25 * (grid[(x-d)%size][y]+grid[(x+d)%size][y]+grid[x][(y+d)%size]+grid[x][(y-d)%size])+r(1+iteration) 211 | 212 | d //= 2 213 | iteration += 1 214 | 215 | return grid 216 | 217 | def inflateRaster(meshData, inflationParams=InflationParams(), distanceToEdge=None): 218 | """ 219 | raster is a boolean matrix. 220 | 221 | flatness varies from 0 for a very gradual profile to something around 2-10 for a very flat top. 222 | 223 | Here's a rough way to visualize how inflateRaster() works. A random walk starts inside the region 224 | defined by the raster. If flatness is zero and exponent=1, it moves around randomly, and if T is the amount of time 225 | to exit the region, then (E[T^p])^(1/p) will then yield the inflation height. If flatness is non-zero, in each time 226 | step it also has a chance of death proportional to the flatness, and death is deemed to also count as an 227 | exit. So if the flatness parameter is big, then well within the region, the process will tend to exit via death, 228 | and so points well within the region will get similar height. 229 | 230 | The default exponent value of 1.0 looks pretty good, but you can get nice rounding effects at exponent=2 and larger, 231 | and some interesting edge-flattening effects for exponents close to zero. 232 | 233 | The flatness parameter is scaled to be approximately invariant across spatial resolution changes, 234 | and some weighting of the process is used to reduce edge effects via the use of the distanceToEdge 235 | function which measures how far a raster point is from the edge in a given direction in the case of 236 | a region not aligned perfectly with the raster. 237 | """ 238 | 239 | width = meshData.cols 240 | height = meshData.rows 241 | 242 | k = meshData.numNeighbors 243 | alpha = 1 - 500 * inflationParams.flatness / max(width,height)**2 244 | if alpha < 0: 245 | alpha = 1e-15 246 | exponent = inflationParams.exponent 247 | invExponent = 1. / exponent 248 | 249 | if distanceToEdge == None: 250 | adjustedDistances = tuple(tuple(tuple( 1. for i in range(k)) for y in range(height)) for x in range(width)) 251 | else: 252 | adjustedDistances = tuple(tuple(tuple( min(distanceToEdge(x,y,i) / meshData.getDeltaLength(x,y,i), 1.) for i in range(k)) for y in range(height)) for x in range(width)) 253 | 254 | meshData.clearData() 255 | 256 | if not inflationParams.iterations: 257 | iterations = 25 * max(width,height) 258 | else: 259 | iterations = inflationParams.iterations 260 | 261 | """ 262 | if exponent >= 1 and alpha == 1 and (isinstance(meshData,HexMeshData) or isinstance(meshData,RectMeshData)): 263 | # Use a lower bound based on Holder inequality and Lawler _Random Walk and the Heat Equation_ Sect. 1.4 264 | # to initialize meshData (the martingale stuff there works both for both rectangular and hexagonal grids). 265 | for col,row in meshData.getPoints(): 266 | r2 = float("inf") 267 | x,y = meshData.getCoordinates(col,row) 268 | for col2,row2 in meshData.getPoints(useMask=False): 269 | if not meshData.inside(col2,row2): 270 | x2,y2 = meshData.getCoordinates(col2,row2) 271 | r2 = min(r2,(x-x2)*(x-x2) + (y-y2)*(y-y2)) 272 | r2 /= meshData.getDeltaLength(0,0,0) ** 2 273 | meshData.data[col][row] = r2**exponent if r2 < float("inf") else 0. 274 | """ 275 | 276 | for iter in range(iterations): 277 | newData = tuple([0 for y in range(height)] for x in range(width)) 278 | 279 | for x,y in meshData.getPoints(): 280 | s = 0 281 | w = 0 282 | 283 | for i in range(k): 284 | d = adjustedDistances[x][y][i] 285 | w += 1. / d 286 | s += (meshData.getNeighborData(x,y,i)**invExponent+d)**exponent / d 287 | 288 | newData[x][y] = alpha * s / w 289 | 290 | meshData.data = newData 291 | 292 | maxZ = max(max(col) for col in meshData.data) ** invExponent 293 | 294 | meshData.data = tuple([datum ** invExponent / maxZ * inflationParams.thickness for datum in col] for col in meshData.data) 295 | 296 | if inflationParams.noise: 297 | n = int(math.log(max(width,height))/math.log(2)+2) 298 | size = int(2**n+1) 299 | left,bottom,right,top = meshData.getCoordinateBounds() 300 | noise = diamondSquare(n,noiseMagnitude = lambda n:1./(n+1)**inflationParams.noiseExponent if inflationParams.noiseExponent else 0.5**n) 301 | maxNoise = max(max(col) for col in noise) 302 | minNoise = min(min(col) for col in noise) 303 | if maxNoise == minNoise: 304 | return 305 | noise = tuple([(datum-minNoise) * inflationParams.noise / (maxNoise-minNoise) for datum in col] for col in noise) 306 | for col,row in meshData.getPoints(): 307 | x,y = meshData.getCoordinates(col,row) 308 | x = (x-left)/(right-left) * size 309 | y = (y-left)/(right-left) * size 310 | x = int(round(x)) 311 | y = int(round(y)) 312 | if x >= size: 313 | x = size-1 314 | if y >= size: 315 | y = size-1 316 | meshData.data[col][row] += noise[x][y] 317 | 318 | if inflationParams.clamp: 319 | for col,row in meshData.getPoints(): 320 | meshData.data[col][row] = min(meshData.data[col][row],inflationParams.clamp) 321 | 322 | #diamondSquare(2) -------------------------------------------------------------------------------- /inflatemesh.py: -------------------------------------------------------------------------------- 1 | from __future__ import division 2 | from inflateutils.surface import * 3 | import inflateutils.svgpath.shader as shader 4 | import inflateutils.svgpath.parser as parser 5 | import sys 6 | import getopt 7 | from inflateutils.exportmesh import * 8 | 9 | quiet = False 10 | 11 | def getBounds(lines): 12 | bottom = min(min(l[0].imag,l[1].imag) for l in lines) 13 | left = min(min(l[0].real,l[1].real) for l in lines) 14 | top = max(max(l[0].imag,l[1].imag) for l in lines) 15 | right = max(max(l[0].real,l[1].real) for l in lines) 16 | return left,bottom,right,top 17 | 18 | def rasterizePolygon(polygon, gridSize, shadeMode=shader.Shader.MODE_EVEN_ODD, hex=False): 19 | """ 20 | Returns boolean raster of strict interior as well as coordinates of lower-left corner. 21 | """ 22 | left,bottom,right,top = getBounds(polygon) 23 | 24 | width = right-left 25 | height = top-bottom 26 | 27 | spacing = max(width,height) / gridSize 28 | 29 | if hex: 30 | meshData = HexMeshData(right-left,top-bottom,Vector(left,bottom),spacing) 31 | else: 32 | meshData = RectMeshData(right-left,top-bottom,Vector(left,bottom),spacing) 33 | 34 | # TODO: not really optimal but simple -- the wraparound is the inoptimality 35 | 36 | phases = [0] + list(sorted( cmath.phase(l[1]-l[0]) % math.pi for l in polygon if l[1] != l[0] ) ) + [math.pi] 37 | bestSpacing = 0 38 | bestPhase = 0. 39 | for i in range(1,len(phases)): 40 | if phases[i]-phases[i-1] > bestSpacing: 41 | bestPhase = 0.5 * (phases[i] + phases[i-1]) 42 | bestSpacing = phases[i]-phases[i-1] 43 | 44 | rotate = cmath.exp(-1j * bestPhase) 45 | 46 | lines = tuple((l[0] * rotate, l[1] * rotate) for l in polygon) 47 | 48 | for x,y in meshData.getPoints(useMask=False): 49 | z = meshData.getCoordinates(x,y).toComplex() * rotate 50 | sum = 0 51 | for l in lines: 52 | a = l[0] - z 53 | b = l[1] - z 54 | if a.imag <= 0 <= b.imag or b.imag <= 0 <= a.imag: 55 | mInv = (b.real-a.real)/(b.imag-a.imag) 56 | if -a.imag * mInv + a.real >= 0: 57 | if shadeMode == shader.Shader.MODE_EVEN_ODD: 58 | sum += 1 59 | else: 60 | if a.imag < b.imag: 61 | sum += 1 62 | else: 63 | sum -= 1 64 | if (shadeMode == shader.Shader.MODE_EVEN_ODD and sum % 2) or (shadeMode != shader.Shader.MODE_EVEN_ODD and sum != 0): 65 | meshData.mask[x][y] = True 66 | 67 | return meshData 68 | 69 | def message(string): 70 | if not quiet: 71 | sys.stderr.write(string + "\n") 72 | 73 | def inflatePolygon(polygon, gridSize=15, shadeMode=shader.Shader.MODE_EVEN_ODD, inflationParams=None, 74 | center=False, twoSided=False, color=None): 75 | # polygon is described by list of (start,stop) pairs, where start and stop are complex numbers 76 | message("Rasterizing") 77 | meshData = rasterizePolygon(polygon, gridSize, shadeMode=shadeMode, hex=inflationParams.hex) 78 | 79 | def distanceToEdge(z0, direction): 80 | direction = direction / abs(direction) 81 | rotate = 1. / direction 82 | 83 | class State(object): pass 84 | state = State() 85 | state.changed = False 86 | state.bestLength = float("inf") 87 | 88 | for line in polygon: 89 | def update(x): 90 | if 0 <= x < state.bestLength: 91 | state.changed = True 92 | state.bestLength = x 93 | 94 | l0 = rotate * (line[0]-z0) 95 | l1 = rotate * (line[1]-z0) 96 | if l0.imag == l1.imag and l0.imag == 0.: 97 | if (l0.real <= 0 and l1.real >= 0) or (l1.real <= 0 and l0.real >= 0): 98 | return 0. 99 | update(l0.real) 100 | update(l1.real) 101 | elif l0.imag <= 0 <= l1.imag or l1.imag <= 0 <= l0.imag: 102 | # crosses real line 103 | mInv = (l1.real-l0.real)/(l1.imag-l0.imag) 104 | # (x - l0.real) / mInv = y - l0.imag 105 | # so for y = 0: 106 | x = -l0.imag * mInv + l0.real 107 | update(x) 108 | return state.bestLength 109 | 110 | message("Making edge distance map") 111 | deltasComplex = tuple( v.toComplex() for v in meshData.normalizedDeltas ) 112 | map = tuple(tuple([1. for i in range(len(deltasComplex))] for row in range(meshData.rows)) for col in range(meshData.cols)) 113 | 114 | for x,y in meshData.getPoints(): 115 | v = meshData.getCoordinates(x,y) 116 | 117 | for i in range(len(deltasComplex)): 118 | map[x][y][i] = distanceToEdge( v.toComplex(), deltasComplex[i] ) 119 | 120 | message("Inflating") 121 | 122 | def distanceFunction(col, row, i, map=map): 123 | return map[col][row][i] 124 | 125 | inflateRaster(meshData, inflationParams=inflationParams, distanceToEdge=distanceFunction) 126 | message("Meshing") 127 | 128 | mesh0 = meshData.getMesh(twoSided=twoSided, color=color) 129 | 130 | def fixFace(face, polygon): 131 | # TODO: optimize by using cached data from the distance map 132 | def trimLine(start, stop): 133 | delta = (stop - start).toComplex() # projects to 2D 134 | if delta == 0j: 135 | return stop 136 | length = abs(delta) 137 | z0 = start.toComplex() 138 | distance = distanceToEdge(z0, delta) 139 | if distance < length: 140 | z = z0 + distance * delta / length 141 | return Vector(z.real, z.imag, 0) 142 | else: 143 | return stop 144 | 145 | outsideCount = sum(1 for v in face if not meshData.insideCoordinates(v)) 146 | if outsideCount == 3: 147 | # should not ever happen 148 | return [] 149 | elif outsideCount == 0: 150 | return [face] 151 | elif outsideCount == 2: 152 | if meshData.insideCoordinates(face[1]): 153 | face = (face[1], face[2], face[0]) 154 | elif meshData.insideCoordinates(face[2]): 155 | face = (face[2], face[0], face[1]) 156 | # now, the first vertex is inside and the others are outside 157 | return [ (face[0], trimLine(face[0], face[1]), trimLine(face[0], face[2])) ] 158 | else: # outsideCount == 1 159 | if not meshData.insideCoordinates(face[0]): 160 | face = (face[1], face[2], face[0]) 161 | elif not meshData.insideCoordinates(face[1]): 162 | face = (face[2], face[0], face[1]) 163 | # now, the first two vertices are inside, and the third is outside 164 | closest0 = trimLine(face[0], face[2]) 165 | closest1 = trimLine(face[1], face[2]) 166 | if closest0 != closest1: 167 | return [ (face[0], face[1], closest0), (closest0, face[1], closest1) ] 168 | else: 169 | return [ (face[0], face[1], closest0) ] 170 | 171 | message("Fixing outer faces") 172 | mesh = [] 173 | for rgb,face in mesh0: 174 | for face2 in fixFace(face, polygon): 175 | mesh.append((rgb, face2)) 176 | 177 | return mesh 178 | 179 | def sortedApproximatePaths(paths,error=0.1): 180 | paths = [path.linearApproximation(error=error) for path in paths if len(path)] 181 | 182 | def key(path): 183 | top = min(min(line.start.imag,line.end.imag) for line in path) 184 | left = min(min(line.start.real,line.end.real) for line in path) 185 | return (top,left) 186 | 187 | return sorted(paths, key=key) 188 | 189 | def inflateLinearPath(path, gridSize=15, inflationParams=None, ignoreColor=False, offset=0j): 190 | lines = [] 191 | for line in path: 192 | lines.append((line.start+offset,line.end+offset)) 193 | mode = shader.Shader.MODE_NONZERO if path.svgState.fillRule == 'nonzero' else shader.Shader.MODE_EVEN_ODD 194 | return inflatePolygon(lines, gridSize=gridSize, inflationParams=inflationParams, twoSided=twoSided, 195 | color=None if ignoreColor else path.svgState.fill, shadeMode=mode) 196 | 197 | class InflatedData(object): 198 | pass 199 | 200 | def inflatePaths(paths, gridSize=15, inflationParams=None, twoSided=False, ignoreColor=False, baseName="path", offset=0j, colors=True): 201 | data = InflatedData() 202 | data.meshes = [] 203 | 204 | paths = sortedApproximatePaths( paths, error=0.1 ) 205 | 206 | for i,path in enumerate(paths): 207 | inflateThis = path.svgState.fill is not None 208 | if inflateThis: 209 | mesh = inflateLinearPath(path, gridSize=gridSize, inflationParams=inflationParams, ignoreColor=not colors, offset=offset) 210 | name = "inflated_" + baseName 211 | if len(paths)>1: 212 | name += "_" + str(i+1) 213 | data.meshes.append( (name, mesh) ) 214 | 215 | return data 216 | 217 | def recenterMesh(mesh): 218 | leftX = float("inf") 219 | rightX = float("-inf") 220 | topX = float("-inf") 221 | bottomX = float("inf") 222 | 223 | for rgb,triangle in mesh: 224 | for vertex in triangle: 225 | leftX = min(leftX, vertex[0]) 226 | rightX = max(rightX, vertex[0]) 227 | bottomX = min(bottomX, vertex[1]) 228 | topX = max(topX, vertex[1]) 229 | 230 | newMesh = [] 231 | 232 | center = Vector(0.5*(leftX+rightX),0.5*(bottomX+topX),0.) 233 | 234 | for rgb,triangle in mesh: 235 | newMesh.append((rgb, tuple(v-center for v in triangle))) 236 | 237 | return newMesh, center.x, center.y, rightX-leftX, topX-bottomX 238 | 239 | def getColorFromMesh(mesh): 240 | return mesh[0][0] 241 | 242 | if __name__ == '__main__': 243 | import cmath 244 | 245 | params = InflationParams() 246 | output = "stl" 247 | twoSided = False 248 | outfile = None 249 | gridSize = 15 250 | baseName = "svg" 251 | colors = True 252 | clamp = 0 253 | centerPage = False 254 | 255 | def help(exitCode=0): 256 | help = """python inflatemesh.py [options] filename.svg 257 | options: 258 | --help: this message 259 | --stl: output to STL (default: OpenSCAD) 260 | --rectangular: use mesh over rectangular grid (default: hexagonal) 261 | --flatness=x: make the top flatter; reasonable range: 0.0-10.0 (default: 0.0) 262 | --height=x: inflate to height (or thickness) x millimeters (default: 10) 263 | --clamp=x: clamp height down to x millimeters (default: 0, no clamping); making this be lower than 264 | the inflationheight is another way to ensure a flattened top 265 | --exponent=x: controls how rounded the inflated image is; must be bigger than 0.0 (default: 0.0) 266 | --resolution=n: approximate mesh resolution along the larger dimension (default: 15) 267 | --iterations=n: number of iterations in calculation (default depends on resolution) 268 | --two-sided: inflate both up and down 269 | --no-colors: omit colors from SVG file (default: include colors) 270 | --center-page: put the center of the SVG page at (0,0,0) in the OpenSCAD file 271 | --name=abc: make all the OpenSCAD variables/module names contain abc (e.g., center_abc) (default: svg) 272 | --output=file: write output to file (default: stdout) 273 | """ 274 | if exitCode: 275 | sys.stderr.write(help + "\n") 276 | else: 277 | print(help) 278 | sys.exit(exitCode) 279 | 280 | try: 281 | opts, args = getopt.getopt(sys.argv[1:], "h", 282 | ["tab=", "help", "stl", "rectangular", "mesh=", "flatness=", "name=", "height=", 283 | "exponent=", "resolution=", "format=", "iterations=", "width=", "xtwo-sided=", "two-sided", 284 | "output=", "center-page", "xcenter-page=", "no-colors", "xcolors=", "noise=", "noise-exponent=", 285 | "clamp=" 286 | ]) 287 | 288 | if len(args) == 0: 289 | raise getopt.GetoptError("invalid commandline") 290 | 291 | i = 0 292 | while i < len(opts): 293 | opt,arg = opts[i] 294 | if opt in ('-h', '--help'): 295 | help() 296 | sys.exit(0) 297 | elif opt == '--flatness': 298 | params.flatness = float(arg) 299 | elif opt == "--clamp": 300 | params.clamp = float(arg) 301 | elif opt == '--height': 302 | params.thickness = float(arg) 303 | elif opt == '--resolution': 304 | gridSize = int(arg) 305 | elif opt == '--rectangular': 306 | params.hex = False 307 | elif opt == '--mesh': 308 | params.hex = arg.lower()[0] == 'h' 309 | elif opt == '--format' or opt == "--tab": 310 | if opt == "--tab": 311 | quiet = True 312 | format = arg.replace('"','').replace("'","") 313 | elif opt == "--stl": 314 | format = "stl" 315 | elif opt == '--iterations': 316 | params.iterations = int(arg) 317 | elif opt == '--width': 318 | width = float(arg) 319 | elif opt == '--xtwo-sided': 320 | twoSided = (arg == "true" or arg == "1") 321 | elif opt == '--two-sided': 322 | twoSided = True 323 | elif opt == "--name": 324 | baseName = arg 325 | elif opt == "--exponent": 326 | params.exponent = float(arg) 327 | elif opt == "--output": 328 | outfile = arg 329 | elif opt == "--noise": 330 | params.noise = float(arg) 331 | elif opt == "--noise-exponent": 332 | params.noiseExponent = float(arg) 333 | elif opt == "--center-page": 334 | centerPage = True 335 | elif opt == "--xcenter-page": 336 | centerPage = (arg == "true" or arg == "1") 337 | elif opt == "--xcolors": 338 | colors = (arg == "true" or arg == "1") 339 | elif opt == "--no-colors": 340 | colors = False 341 | i += 1 342 | 343 | except getopt.GetoptError as e: 344 | sys.stderr.write(str(e)+"\n") 345 | help(exitCode=1) 346 | sys.exit(2) 347 | 348 | if twoSided: 349 | params.thickness *= 0.5 350 | 351 | paths, lowerLeft, upperRight = parser.getPathsFromSVGFile(args[0]) 352 | 353 | if centerPage: 354 | offset = -0.5*(lowerLeft+upperRight) 355 | else: 356 | offset = 0j 357 | 358 | data = inflatePaths(paths, inflationParams=params, gridSize=gridSize, twoSided=twoSided, baseName=baseName, offset=offset, colors=colors) 359 | 360 | if format == 'stl': 361 | mesh = [datum for name,mesh in data.meshes for datum in mesh] 362 | saveSTL(outfile, mesh, quiet=quiet) 363 | else: 364 | scad = "" 365 | for i,(name,mesh) in enumerate(data.meshes): 366 | mesh,centerX,centerY,width,height = recenterMesh(mesh) 367 | data.meshes[i] = (name,mesh) 368 | scad += "center_%s = [%s,%s];\n" % (name,decimal(centerX),decimal(centerY)) 369 | scad += "size_%s = [%s,%s];\n" % (name,decimal(width),decimal(height)) 370 | scad += "color_%s = %s;\n\n" % (name,describeColor(getColorFromMesh(mesh))) 371 | 372 | for name,mesh in data.meshes: 373 | scad += toSCADModule(mesh, moduleName=name, digitsAfterDecimal=5, colorOverride="") 374 | scad += "\n" 375 | 376 | for name,_ in data.meshes: 377 | scad += "translate(center_%s) color(color_%s) %s();\n" % (name,name,name) 378 | 379 | if outfile: 380 | with open(outfile, "w") as f: f.write(scad) 381 | else: 382 | print(scad) 383 | -------------------------------------------------------------------------------- /svg2scad.py: -------------------------------------------------------------------------------- 1 | from __future__ import division 2 | import inflateutils.svgpath.shader as shader 3 | import inflateutils.svgpath.parser as parser 4 | import inflateutils.svgpath.path as svgpath 5 | import sys 6 | import getopt 7 | import cmath 8 | import math 9 | from inflateutils.exportmesh import * 10 | from inflateutils.formatdecimal import decimal 11 | from random import sample 12 | 13 | quiet = False 14 | 15 | def closed(path): 16 | return path[-1] == path[0] 17 | 18 | def inside(z, path): 19 | for p in path: 20 | if p == z: 21 | return False 22 | try: 23 | phases = sorted((cmath.phase(p-z) for p in path)) 24 | # make a ray that is relatively far away from any points 25 | if len(phases) == 1: 26 | # should not happen 27 | bestPhase = phases[0] + math.pi 28 | else: 29 | bestIndex = max( (phases[i+1]-phases[i],i) for i in range(len(phases)-1))[1] 30 | bestPhase = (phases[bestIndex+1]+phases[bestIndex])/2. 31 | ray = cmath.rect(1., bestPhase) 32 | rotatedPath = tuple((p-z) / ray for p in path) 33 | # now we just need to check shiftedPath's intersection with the positive real line 34 | s = 0 35 | for i,p2 in enumerate(rotatedPath): 36 | p1 = rotatedPath[i-1] 37 | if p1.imag == p2.imag: 38 | # horizontal lines can't intersect positive real line once phase selection was done 39 | continue 40 | # (1/m)y + xIntercept = x 41 | reciprocalSlope = (p2.real-p1.real)/(p2.imag-p1.imag) 42 | xIntercept = p2.real - reciprocalSlope * p2.imag 43 | if xIntercept == 0: 44 | return False # on boundary 45 | if xIntercept > 0 and p1.imag * p2.imag < 0: 46 | if p1.imag < 0: 47 | s += 1 48 | else: 49 | s -= 1 50 | return s != 0 51 | 52 | except OverflowError: 53 | return False 54 | 55 | def nestedPaths(path1, path2, pointsToCheck=3): 56 | if not closed(path2): 57 | return False 58 | k = min(pointsToCheck, len(path1)) 59 | for point in sample(path1, k): 60 | if inside(point, path2): 61 | return True 62 | return False 63 | 64 | def comparePaths(path1,path2,pointsToCheck=3): 65 | """ 66 | open paths before closed paths 67 | outer paths come before inner ones 68 | otherwise, top to bottom bounds, left to right 69 | """ 70 | 71 | if closed(path1) and not closed(path2): 72 | return 1 73 | elif closed(path2) and not closed(path1): 74 | return -1 75 | if nestedPaths(path1, path2, pointsToCheck=pointsToCheck): 76 | return 1 77 | elif nestedPaths(path2, path1, pointsToCheck=pointsToCheck): 78 | return -1 79 | y1 = max(p.imag for p in path1) 80 | y2 = max(p.imag for p in path2) 81 | if y1 == y2: 82 | return comparison(min(p.real for p in path1),min(p.real for p in path2)) 83 | else: 84 | return comparison(y1,y2) 85 | 86 | def getLevels(paths): 87 | level = [] 88 | empty = True 89 | nextPaths = paths[:] 90 | for i in range(len(paths)): 91 | path = paths[i] 92 | if path is None: 93 | continue 94 | empty = False 95 | outer = True 96 | if closed(path): 97 | for j in range(len(paths)): 98 | if j != i and paths[j] is not None and nestedPaths(path, paths[j]): 99 | outer = False 100 | break 101 | if outer: 102 | level.append(path) 103 | nextPaths[i] = None 104 | 105 | if empty: 106 | return [] 107 | else: 108 | return [level] + getLevels(nextPaths) 109 | 110 | def message(string): 111 | if not quiet: 112 | sys.stderr.write(string + "\n") 113 | 114 | def sortedApproximatePaths(paths,error=0.1): 115 | def approximate(path): 116 | p = path.linearApproximation(error=error) 117 | p.originalPath = path 118 | return p 119 | 120 | paths = [approximate(path) for path in paths if len(path)] 121 | 122 | def key(path): 123 | top = min(min(line.start.imag,line.end.imag) for line in path) 124 | left = min(min(line.start.real,line.end.real) for line in path) 125 | return (top,left) 126 | 127 | return sorted(paths, key=key) 128 | 129 | class SubPath(list): 130 | pass 131 | 132 | class PolygonData(object): 133 | def __init__(self,color,fillColor): 134 | self.bounds = [float("inf"),float("inf"),float("-inf"),float("-inf")] 135 | self.color = color 136 | self.fillColor = fillColor 137 | self.pointLists = [] 138 | 139 | def updateBounds(self,z): 140 | self.bounds[0] = min(self.bounds[0], z.real) 141 | self.bounds[1] = min(self.bounds[1], z.imag) 142 | self.bounds[2] = max(self.bounds[2], z.real) 143 | self.bounds[3] = max(self.bounds[3], z.imag) 144 | 145 | def getAnchor(self, mode): 146 | if mode[0] == "a": 147 | return 0j 148 | elif mode[0] == 'l': 149 | return complex(self.bounds[0],self.bounds[1]) 150 | else: # mode[0] == 'c': 151 | return complex(0.5*(self.bounds[0]+self.bounds[2]),0.5*(self.bounds[1]+self.bounds[3])) 152 | 153 | def extractPaths(paths, offset, tolerance=0.1, baseName="svg", colors=True, levels=False, absolute=False, align="center"): 154 | polygons = [] 155 | 156 | paths = sortedApproximatePaths(paths, error=tolerance ) 157 | 158 | for i,path in enumerate(paths): 159 | color = None 160 | fillColor = None 161 | if colors: 162 | if path.svgState.fill is not None: 163 | fillColor = path.svgState.fill 164 | if path.svgState.stroke is not None: 165 | color = path.svgState.stroke 166 | polygon = PolygonData(color,fillColor) 167 | polygon.strokeWidth = path.svgState.strokeWidth; 168 | polygons.append(polygon) 169 | for j,subpath0 in enumerate(path.originalPath.breakup()): 170 | subpath = subpath0.linearApproximation(error=tolerance) 171 | points = [subpath[0].start+offset] 172 | polygon.updateBounds(points[-1]) 173 | for line in subpath: 174 | points.append(line.end+offset) 175 | polygon.updateBounds(points[-1]) 176 | if subpath.closed and points[0] != points[-1]: 177 | points.append(points[0]) 178 | sp = SubPath(points) 179 | sp.originalPath = subpath0 180 | polygon.pointLists.append(sp) 181 | if not absolute: 182 | for points in polygon.pointLists: 183 | for j in range(len(points)): 184 | points[j] -= polygon.getAnchor(align) 185 | 186 | if levels: 187 | polygon.levels = getLevels(polygon.pointLists) 188 | polygon.pointLists = flattenLevels(polygon.levels) 189 | 190 | return polygons 191 | 192 | def toNestedPolygons(levels, name, i=0, indent=4): 193 | def spaces(): 194 | return ' '*indent 195 | 196 | # def polygonPaths(paths, i): 197 | # return "polygonWithHoles([" + ",".join((name(i+j) for j in range(len(paths)))) + "])" 198 | # 199 | # if len(levels) == 2 and len(levels[0])==1: 200 | # return spaces() + polygonPaths(levels[0] + levels[1], i) + ";\n" 201 | 202 | out = "" 203 | if len(levels)>1: 204 | out += spaces() + "difference() {\n" 205 | indent += 2 206 | if len(levels[0])>1: 207 | out += spaces() + "union() {\n" 208 | indent += 2 209 | for poly in levels[0]: 210 | if closed(poly): 211 | out += spaces() + "polygon(%s);\n" % name(i) 212 | i += 1 213 | if len(levels[0])>1: 214 | indent -= 2 215 | out += spaces() + "}\n" 216 | if len(levels)>1: 217 | out += toNestedPolygons(levels[1:], name, i=i, indent=indent) 218 | indent -= 2 219 | out += spaces() + "}\n" 220 | return out 221 | 222 | def flattenLevels(levels): 223 | out = [] 224 | for level in levels: 225 | out += level 226 | return out 227 | 228 | def getBezier(path,offset,cpMode): 229 | didBezier = False 230 | b = [] 231 | 232 | def addCoords(t,z): 233 | z += offset 234 | b.append("/*%s*/[%s,%s]" % (t,decimal(z.real),decimal(z.imag))) 235 | 236 | def addCP(cp,node): 237 | if cpMode[0] == 'a': 238 | addCoords("CP",cp) 239 | else: 240 | delta=cp-node 241 | if cpMode[0] == 'p': 242 | b.append("/*CP*/POLAR(%s,%s)" 243 | % (decimal(abs(delta)),decimal(math.atan2(delta.imag,delta.real)*180/math.pi))) 244 | else: 245 | b.append("/*CP*/OFFSET([%s,%s])" 246 | % (decimal(delta.real),decimal(delta.imag))) 247 | 248 | def addLine(start,end): 249 | addCoords("N",start) 250 | #addCoords("CP",(2*start+end)/3) 251 | #addCoords("CP",end) 252 | b.append("LINE()") 253 | b.append("LINE()") 254 | 255 | last = None 256 | for p in path: 257 | if isinstance(p,svgpath.CubicBezier): 258 | addCoords("N",p.start) 259 | addCP(p.control1,p.start) 260 | addCP(p.control2,p.end) 261 | last = p.end 262 | didBezier = True 263 | elif isinstance(p,svgpath.Line): 264 | addLine(p.start,p.end) 265 | last = p.end 266 | elif isinstance(p,svgpath.QuadraticBezier): 267 | addCoords("N",p.start) 268 | addCP(p.start+(2./3)*(p.control-p.start),p.start) 269 | addCP(p.end+(2./3)*(p.control-p.end),p.end) 270 | last = p.end 271 | didBezier = True 272 | else: 273 | return None 274 | 275 | if last is None or not didBezier: 276 | return None 277 | 278 | if path.closed and last != path.point(0): 279 | addLine(last,path.point(0)) 280 | last = path.point(0) 281 | 282 | addCoords("N",last) 283 | 284 | return ",".join(b) 285 | 286 | if __name__ == '__main__': 287 | outfile = None 288 | doRibbons = False 289 | doPolygons = False 290 | baseName = "svg" 291 | tolerance = 0.1 292 | width = 0 293 | height = 10 294 | colors = True 295 | centerPage = False 296 | align = "center" 297 | cpMode = "none" 298 | 299 | def help(exitCode=0): 300 | help = """python svg2scad.py [options] filename.svg 301 | options: 302 | --help: this message 303 | --align=mode: object alignment mode: center [default], lowerleft, absolute 304 | --bezier=mode: control point style: none [no Bezier library needed], absolute, offset or polar 305 | --tolerance=x: when linearizing paths, keep them within x millimeters of correct position (default: 0.1) 306 | --ribbons: make ribbons out of outlined paths 307 | --polygons: make polygons out of shaded paths 308 | --width: ribbon width override 309 | --height: ribbon or polygon height in millimeters; if zero, they're two-dimensional (default: 10) 310 | --no-colors: omit colors from SVG file (default: include colors) 311 | --name=abc: make all the OpenSCAD variables/module names contain abc (e.g., center_abc) (default: svg) 312 | --center-page: put the center of the SVG page at (0,0,0) in the OpenSCAD file 313 | --output=file: write output to file (default: stdout) 314 | """ 315 | if exitCode: 316 | sys.stderr.write(help + "\n") 317 | else: 318 | print(help) 319 | sys.exit(exitCode) 320 | 321 | try: 322 | opts, args = getopt.getopt(sys.argv[1:], "h", 323 | ["help", "tolerance=", "ribbons", "polygons", "width=", "xpolygons=", "xribbons=", 324 | "height=", "tab=", "name=", "center-page", "xcenter-page=", "no-colors", "xcolors=", 325 | "bezier=", "align="]) 326 | 327 | if len(args) == 0: 328 | raise getopt.GetoptError("invalid commandline") 329 | 330 | i = 0 331 | while i < len(opts): 332 | opt,arg = opts[i] 333 | if opt in ('-h', '--help'): 334 | help() 335 | sys.exit(0) 336 | elif opt == '--tolerance': 337 | tolerance = float(arg) 338 | elif opt == '--width': 339 | width = float(arg) 340 | elif opt == '--height': 341 | height = float(arg) 342 | elif opt == '--name': 343 | baseName = arg 344 | elif opt == "--ribbons": 345 | doRibbons = True 346 | elif opt == "--bezier": 347 | cpMode = arg 348 | elif opt == "--align": 349 | align = arg 350 | elif opt == "--xabsolute": 351 | absolute = (arg == "true" or arg == "1") 352 | elif opt == "--xribbons": 353 | doRibbons = (arg == "true" or arg == "1") 354 | elif opt == "--polygons": 355 | doPolygons = True 356 | elif opt == "--xpolygons": 357 | doPolygons = (arg == "true" or arg == "1") 358 | elif opt == "--center-page": 359 | centerPage = True 360 | elif opt == "--xcenter-page": 361 | centerPage = (arg == "true" or arg == "1") 362 | elif opt == "--xcolors": 363 | colors = (arg == "true" or arg == "1") 364 | elif opt == "--no-colors": 365 | colors = False 366 | 367 | i += 1 368 | 369 | except getopt.GetoptError as e: 370 | sys.stderr.write(str(e)+"\n") 371 | help(exitCode=1) 372 | sys.exit(2) 373 | 374 | paths, lowerLeft, upperRight = parser.getPathsFromSVGFile(args[0]) 375 | 376 | if centerPage: 377 | offset = -0.5*(lowerLeft+upperRight) 378 | else: 379 | offset = 0 380 | 381 | polygons = extractPaths(paths, offset, tolerance=tolerance, baseName=baseName, colors=colors, 382 | levels=doPolygons, align=align) 383 | 384 | scad = "" 385 | 386 | if cpMode[0] != 'n': 387 | scad += "use ; // download from https://www.thingiverse.com/thing:2207518\n\n" 388 | scad += "bezier_precision = -%s;\n" % decimal(tolerance) 389 | 390 | if height > 0: 391 | if doPolygons: 392 | scad += "polygon_height_%s = %s;\n" % (baseName, decimal(height)) 393 | if doRibbons: 394 | scad += "ribbon_height_%s = %s;\n" % (baseName, decimal(height)) 395 | 396 | if width > 0: 397 | scad += "width_%s = %s;\n" % (baseName, decimal(width)) 398 | 399 | if len(scad): 400 | scad += "\n" 401 | 402 | def polyName(i): 403 | return baseName + "_" + str(i+1) 404 | 405 | def subpathName(i,j): 406 | return polyName(i) + "_" + str(j+1) 407 | 408 | for i,polygon in enumerate(polygons): 409 | if (align[0] != 'a'): 410 | scad += "position_%s = [%s,%s];\n" % (polyName(i), decimal(polygon.getAnchor(align).real), decimal(polygon.getAnchor(align).imag)) 411 | scad += "size_%s = [%s,%s];\n" % (polyName(i), decimal(polygon.bounds[2]-polygon.bounds[0]), decimal(polygon.bounds[3]-polygon.bounds[1])) 412 | scad += "stroke_width_%s = %s;\n" % (polyName(i), decimal(polygon.strokeWidth)) 413 | if colors: 414 | scad += "color_%s = %s;\n" % (polyName(i), describeColor(polygon.color)) 415 | scad += "fillcolor_%s = %s;\n" % (polyName(i), describeColor(polygon.fillColor)) 416 | 417 | # if doPolygons: 418 | # scad += """ 419 | #module polygonWithHoles(paths) { 420 | # points = [for(path=paths) for(p=path) p]; 421 | # function cumulativeLengths(paths,n=0,soFar=[0]) = 422 | # n >= len(paths)-1 ? soFar : 423 | # cumulativeLengths(paths,n=n+1,soFar=concat(soFar,[soFar[n]+len(paths[n])])); 424 | # offsets = cumulativeLengths(paths); 425 | # indices = [for(i=[0:1:len(paths)-1]) [for(j=[0:1:len(paths[i])-1]) offsets[i]+j]]; 426 | # polygon(points=points, paths=indices); 427 | #} 428 | # 429 | #""" 430 | # 431 | for i,polygon in enumerate(polygons): 432 | scad += "// paths for %s\n" % polyName(i) 433 | for j,points in enumerate(polygon.pointLists): 434 | b = cpMode[0] != 'n' and getBezier(points.originalPath,-polygon.getAnchor(align),cpMode) 435 | if b: 436 | scad += "bezier_" + subpathName(i,j) + " = ["+b+"];\n" 437 | scad += "points_" + subpathName(i,j) + " = Bezier(bezier_"+subpathName(i,j)+",precision=bezier_precision);" 438 | else: 439 | scad += "points_" + subpathName(i,j) + " = " 440 | scad += "[ " + ','.join('[%s,%s]' % (decimal(point.real),decimal(point.imag)) for point in points) + " ];\n" 441 | scad += "\n" 442 | 443 | objectNames = [] 444 | 445 | if doRibbons: 446 | scad += """module ribbon(points, thickness=1) { 447 | p = points; 448 | 449 | union() { 450 | for (i=[1:len(p)-1]) { 451 | hull() { 452 | translate(p[i-1]) circle(d=thickness, $fn=8); 453 | translate(p[i]) circle(d=thickness, $fn=8); 454 | } 455 | } 456 | } 457 | } 458 | 459 | """ 460 | objectNames.append("ribbon") 461 | 462 | for i,polygon in enumerate(polygons): 463 | scad += "module ribbon_%s(width=%s) {\n" % (polyName(i),("width_"+baseName) if width else ("stroke_width_"+polyName(i))) 464 | for j in range(len(polygon.pointLists)): 465 | scad += " ribbon(points_%s, thickness=width);\n" % subpathName(i,j) 466 | scad += "}\n\n" 467 | 468 | if doPolygons: 469 | objectNames.append("polygon") 470 | 471 | for i,polygon in enumerate(polygons): 472 | scad += "module polygon_%s() {\n " % polyName(i) 473 | scad += "render(convexity=4) " 474 | scad += "{\n" 475 | scad += toNestedPolygons(polygon.levels, lambda j : "points_" + subpathName(i,j)) 476 | scad += " }\n}\n\n" 477 | 478 | if height > 0: 479 | polygonExtrude = "linear_extrude(height=polygon_height_%s) " % baseName 480 | ribbonExtrude = "linear_extrude(height=ribbon_height_%s) " % baseName 481 | else: 482 | polygonExtrude = "" 483 | ribbonExtrude = "" 484 | 485 | for objectName in objectNames: 486 | for i in range(len(polygons)): 487 | c = "" if not colors else "//" 488 | if colors and objectName == 'polygon' and polygons[i].fillColor: 489 | c = "color(fillcolor_%s) " % polyName(i) 490 | elif colors and objectName == 'ribbon' and polygons[i].color: 491 | c = "color(color_%s) " % polyName(i) 492 | 493 | if align[0] == 'a': 494 | translate = "" 495 | else: 496 | translate = "translate(position_%s) " % polyName(i) 497 | 498 | extrude = polygonExtrude if objectName == 'polygon' else ribbonExtrude 499 | 500 | scad += c + extrude + translate + "%s_%s();\n" % (objectName, polyName(i)) 501 | 502 | if outfile: 503 | with open(outfile, "w") as f: f.write(scad) 504 | else: 505 | print(scad) 506 | -------------------------------------------------------------------------------- /inflateutils/svgpath/path.py: -------------------------------------------------------------------------------- 1 | from __future__ import division 2 | from math import sqrt, cos, sin, acos, degrees, radians, log 3 | from collections import MutableSequence 4 | 5 | # This file contains classes for the different types of SVG path segments as 6 | # well as a Path object that contains a sequence of path segments. 7 | 8 | MIN_DEPTH = 5 9 | ERROR = 1e-12 10 | 11 | def segment_length(curve, start, end, start_point, end_point, error, min_depth, depth): 12 | """Recursively approximates the length by straight lines""" 13 | mid = (start + end) / 2 14 | mid_point = curve.point(mid) 15 | length = abs(end_point - start_point) 16 | first_half = abs(mid_point - start_point) 17 | second_half = abs(end_point - mid_point) 18 | 19 | length2 = first_half + second_half 20 | if (length2 - length > error) or (depth < min_depth): 21 | # Calculate the length of each segment: 22 | depth += 1 23 | return (segment_length(curve, start, mid, start_point, mid_point, 24 | error, min_depth, depth) + 25 | segment_length(curve, mid, end, mid_point, end_point, 26 | error, min_depth, depth)) 27 | # This is accurate enough. 28 | return length2 29 | 30 | def approximate(path, start, end, start_point, end_point, max_error, depth, max_depth): 31 | if depth >= max_depth: 32 | return [start_point, end_point] 33 | actual_length = path.measure(start, end, error=max_error/4) 34 | linear_length = abs(end_point - start_point) 35 | # Worst case deviation given a fixed linear_length and actual_length would probably be 36 | # a symmetric tent shape (I haven't proved it -- TODO). 37 | deviationSquared = (actual_length/2)**2 - (linear_length/2)**2 38 | if deviationSquared <= max_error ** 2: 39 | return [start_point, end_point] 40 | else: 41 | mid = (start+end)/2. 42 | mid_point = path.point(mid) 43 | return ( approximate(path, start, mid, start_point, mid_point, max_error, depth+1, max_depth)[:-1] + 44 | approximate(path, mid, end, mid_point, end_point, max_error, depth+1, max_depth) ) 45 | 46 | def removeCollinear(points, error, pointsToKeep=set()): 47 | out = [] 48 | 49 | lengths = [0] 50 | 51 | for i in range(1,len(points)): 52 | lengths.append(lengths[-1] + abs(points[i]-points[i-1])) 53 | 54 | def length(a,b): 55 | return lengths[b] - lengths[a] 56 | 57 | i = 0 58 | 59 | while i < len(points): 60 | j = len(points) - 1 61 | while i < j: 62 | deviationSquared = (length(i, j)/2)**2 - (abs(points[j]-points[i])/2)**2 63 | if deviationSquared <= error ** 2 and set(range(i+1,j)).isdisjoint(pointsToKeep): 64 | out.append(points[i]) 65 | i = j 66 | break 67 | j -= 1 68 | out.append(points[j]) 69 | i += 1 70 | 71 | return out 72 | 73 | class Segment(object): 74 | def __init__(self, start, end): 75 | self.start = start 76 | self.end = end 77 | 78 | def measure(self, start, end, error=ERROR, min_depth=MIN_DEPTH): 79 | return Path(self).measure(start, end, error=error, min_depth=min_depth) 80 | 81 | def getApproximatePoints(self, error=0.001, max_depth=32): 82 | points = approximate(self, 0., 1., self.point(0.), self.point(1.), error, 0, max_depth) 83 | return points 84 | 85 | class Line(Segment): 86 | def __init__(self, start, end): 87 | super(Line, self).__init__(start,end) 88 | 89 | def __repr__(self): 90 | return 'Line(start=%s, end=%s)' % (self.start, self.end) 91 | 92 | def __eq__(self, other): 93 | if not isinstance(other, Line): 94 | return NotImplemented 95 | return self.start == other.start and self.end == other.end 96 | 97 | def __ne__(self, other): 98 | if not isinstance(other, Line): 99 | return NotImplemented 100 | return not self == other 101 | 102 | def getApproximatePoints(self, error=0.001, max_depth=32): 103 | return [self.start, self.end] 104 | 105 | def point(self, pos): 106 | if pos == 0.: 107 | return self.start 108 | elif pos == 1.: 109 | return self.end 110 | distance = self.end - self.start 111 | return self.start + distance * pos 112 | 113 | def length(self, error=None, min_depth=None): 114 | distance = (self.end - self.start) 115 | return sqrt(distance.real ** 2 + distance.imag ** 2) 116 | 117 | 118 | class CubicBezier(Segment): 119 | def __init__(self, start, control1, control2, end): 120 | super(CubicBezier, self).__init__(start,end) 121 | self.control1 = control1 122 | self.control2 = control2 123 | 124 | def __repr__(self): 125 | return 'CubicBezier(start=%s, control1=%s, control2=%s, end=%s)' % ( 126 | self.start, self.control1, self.control2, self.end) 127 | 128 | def __eq__(self, other): 129 | if not isinstance(other, CubicBezier): 130 | return NotImplemented 131 | return self.start == other.start and self.end == other.end and \ 132 | self.control1 == other.control1 and self.control2 == other.control2 133 | 134 | def __ne__(self, other): 135 | if not isinstance(other, CubicBezier): 136 | return NotImplemented 137 | return not self == other 138 | 139 | def is_smooth_from(self, previous): 140 | """Checks if this segment would be a smooth segment following the previous""" 141 | if isinstance(previous, CubicBezier): 142 | return (self.start == previous.end and 143 | (self.control1 - self.start) == (previous.end - previous.control2)) 144 | else: 145 | return self.control1 == self.start 146 | 147 | def point(self, pos): 148 | """Calculate the x,y position at a certain position of the path""" 149 | if pos == 0.: 150 | return self.start 151 | elif pos == 1.: 152 | return self.end 153 | return ((1 - pos) ** 3 * self.start) + \ 154 | (3 * (1 - pos) ** 2 * pos * self.control1) + \ 155 | (3 * (1 - pos) * pos ** 2 * self.control2) + \ 156 | (pos ** 3 * self.end) 157 | 158 | def length(self, error=ERROR, min_depth=MIN_DEPTH): 159 | """Calculate the length of the path up to a certain position""" 160 | start_point = self.point(0) 161 | end_point = self.point(1) 162 | return segment_length(self, 0, 1, start_point, end_point, error, min_depth, 0) 163 | 164 | 165 | class QuadraticBezier(Segment): 166 | def __init__(self, start, control, end): 167 | super(QuadraticBezier, self).__init__(start,end) 168 | self.control = control 169 | 170 | def __repr__(self): 171 | return 'QuadraticBezier(start=%s, control=%s, end=%s)' % ( 172 | self.start, self.control, self.end) 173 | 174 | def __eq__(self, other): 175 | if not isinstance(other, QuadraticBezier): 176 | return NotImplemented 177 | return self.start == other.start and self.end == other.end and \ 178 | self.control == other.control 179 | 180 | def __ne__(self, other): 181 | if not isinstance(other, QuadraticBezier): 182 | return NotImplemented 183 | return not self == other 184 | 185 | def is_smooth_from(self, previous): 186 | """Checks if this segment would be a smooth segment following the previous""" 187 | if isinstance(previous, QuadraticBezier): 188 | return (self.start == previous.end and 189 | (self.control - self.start) == (previous.end - previous.control)) 190 | else: 191 | return self.control == self.start 192 | 193 | def point(self, pos): 194 | if pos == 0.: 195 | return self.start 196 | elif pos == 1.: 197 | return self.end 198 | return (1 - pos) ** 2 * self.start + 2 * (1 - pos) * pos * self.control + \ 199 | pos ** 2 * self.end 200 | 201 | def length(self, error=None, min_depth=None): 202 | a = self.start - 2*self.control + self.end 203 | b = 2*(self.control - self.start) 204 | a_dot_b = a.real*b.real + a.imag*b.imag 205 | 206 | if abs(a) < 1e-12: 207 | s = abs(b) 208 | elif abs(a_dot_b + abs(a)*abs(b)) < 1e-12: 209 | k = abs(b)/abs(a) 210 | if k >= 2: 211 | s = abs(b) - abs(a) 212 | else: 213 | s = abs(a)*(k**2/2 - k + 1) 214 | else: 215 | # For an explanation of this case, see 216 | # http://www.malczak.info/blog/quadratic-bezier-curve-length/ 217 | A = 4 * (a.real ** 2 + a.imag ** 2) 218 | B = 4 * (a.real * b.real + a.imag * b.imag) 219 | C = b.real ** 2 + b.imag ** 2 220 | 221 | Sabc = 2 * sqrt(A + B + C) 222 | A2 = sqrt(A) 223 | A32 = 2 * A * A2 224 | C2 = 2 * sqrt(C) 225 | BA = B / A2 226 | 227 | s = (A32 * Sabc + A2 * B * (Sabc - C2) + (4 * C * A - B ** 2) * 228 | log((2 * A2 + BA + Sabc) / (BA + C2))) / (4 * A32) 229 | return s 230 | 231 | class Arc(Segment): 232 | def __init__(self, start, radius, rotation, arc, sweep, end, scaler=lambda z:z): 233 | """radius is complex, rotation is in degrees, 234 | large and sweep are 1 or 0 (True/False also work)""" 235 | 236 | super(Arc, self).__init__(scaler(start),scaler(end)) 237 | self.start0 = start 238 | self.end0 = end 239 | self.radius = radius 240 | self.rotation = rotation 241 | self.arc = bool(arc) 242 | self.sweep = bool(sweep) 243 | self.scaler = scaler 244 | 245 | self._parameterize() 246 | 247 | def __repr__(self): 248 | return 'Arc(start0=%s, radius=%s, rotation=%s, arc=%s, sweep=%s, end0=%s, scaler=%s)' % ( 249 | self.start0, self.radius, self.rotation, self.arc, self.sweep, self.end0, self.scaler) 250 | 251 | def __eq__(self, other): 252 | if not isinstance(other, Arc): 253 | return NotImplemented 254 | return self.start == other.start and self.end == other.end and \ 255 | self.radius == other.radius and self.rotation == other.rotation and \ 256 | self.arc == other.arc and self.sweep == other.sweep 257 | 258 | def __ne__(self, other): 259 | if not isinstance(other, Arc): 260 | return NotImplemented 261 | return not self == other 262 | 263 | def _parameterize(self): 264 | # Conversion from endpoint to center parameterization 265 | # http://www.w3.org/TR/SVG/implnote.html#ArcImplementationNotes 266 | 267 | cosr = cos(radians(self.rotation)) 268 | sinr = sin(radians(self.rotation)) 269 | dx = (self.start0.real - self.end0.real) / 2 270 | dy = (self.start0.imag - self.end0.imag) / 2 271 | x1prim = cosr * dx + sinr * dy 272 | x1prim_sq = x1prim * x1prim 273 | y1prim = -sinr * dx + cosr * dy 274 | y1prim_sq = y1prim * y1prim 275 | 276 | rx = self.radius.real 277 | rx_sq = rx * rx 278 | ry = self.radius.imag 279 | ry_sq = ry * ry 280 | 281 | # Correct out of range radii 282 | radius_check = (x1prim_sq / rx_sq) + (y1prim_sq / ry_sq) 283 | if radius_check > 1: 284 | rx *= sqrt(radius_check) 285 | ry *= sqrt(radius_check) 286 | rx_sq = rx * rx 287 | ry_sq = ry * ry 288 | 289 | t1 = rx_sq * y1prim_sq 290 | t2 = ry_sq * x1prim_sq 291 | c = sqrt(abs((rx_sq * ry_sq - t1 - t2) / (t1 + t2))) 292 | 293 | if self.arc == self.sweep: 294 | c = -c 295 | cxprim = c * rx * y1prim / ry 296 | cyprim = -c * ry * x1prim / rx 297 | 298 | self.center = complex((cosr * cxprim - sinr * cyprim) + 299 | ((self.start0.real + self.end0.real) / 2), 300 | (sinr * cxprim + cosr * cyprim) + 301 | ((self.start0.imag + self.end0.imag) / 2)) 302 | 303 | ux = (x1prim - cxprim) / rx 304 | uy = (y1prim - cyprim) / ry 305 | vx = (-x1prim - cxprim) / rx 306 | vy = (-y1prim - cyprim) / ry 307 | n = sqrt(ux * ux + uy * uy) 308 | p = ux 309 | theta = degrees(acos(p / n)) 310 | if uy < 0: 311 | theta = -theta 312 | self.theta = theta % 360 313 | 314 | n = sqrt((ux * ux + uy * uy) * (vx * vx + vy * vy)) 315 | p = ux * vx + uy * vy 316 | d = p/n 317 | # In certain cases the above calculation can through inaccuracies 318 | # become just slightly out of range, f ex -1.0000000000000002. 319 | if d > 1.0: 320 | d = 1.0 321 | elif d < -1.0: 322 | d = -1.0 323 | delta = degrees(acos(d)) 324 | if (ux * vy - uy * vx) < 0: 325 | delta = -delta 326 | self.delta = delta % 360 327 | if not self.sweep: 328 | self.delta -= 360 329 | 330 | def point(self, pos): 331 | if pos == 0.: 332 | return self.start 333 | elif pos == 1.: 334 | return self.end 335 | angle = radians(self.theta + (self.delta * pos)) 336 | cosr = cos(radians(self.rotation)) 337 | sinr = sin(radians(self.rotation)) 338 | 339 | x = (cosr * cos(angle) * self.radius.real - sinr * sin(angle) * 340 | self.radius.imag + self.center.real) 341 | y = (sinr * cos(angle) * self.radius.real + cosr * sin(angle) * 342 | self.radius.imag + self.center.imag) 343 | return self.scaler(complex(x, y)) 344 | 345 | def length(self, error=ERROR, min_depth=MIN_DEPTH): 346 | """The length of an elliptical arc segment requires numerical 347 | integration, and in that case it's simpler to just do a geometric 348 | approximation, as for cubic bezier curves. 349 | """ 350 | start_point = self.point(0) 351 | end_point = self.point(1) 352 | return segment_length(self, 0, 1, start_point, end_point, error, min_depth, 0) 353 | 354 | class SVGState(object): 355 | def __init__(self, fill=(0.,0.,0.), fillOpacity=None, fillRule='nonzero', stroke=None, strokeOpacity=None, strokeWidth=0.1, strokeWidthScaling=True): 356 | self.fill = fill 357 | self.fillOpacity = fillOpacity 358 | self.fillRule = fillRule 359 | self.stroke = stroke 360 | self.strokeOpacity = strokeOpacity 361 | self.strokeWidth = strokeWidth 362 | self.strokeWidthScaling = strokeWidthScaling 363 | 364 | def clone(self): 365 | return SVGState(fill=self.fill, fillOpacity=self.fillOpacity, fillRule=self.fillRule, stroke=self.stroke, strokeOpacity=self.strokeOpacity, 366 | strokeWidth=self.strokeWidth, strokeWidthScaling=self.strokeWidthScaling) 367 | 368 | class Path(MutableSequence): 369 | """A Path is a sequence of path segments""" 370 | 371 | # Put it here, so there is a default if unpickled. 372 | _closed = False 373 | 374 | def __init__(self, *segments, **kw): 375 | self._segments = list(segments) 376 | self._length = None 377 | self._lengths = None 378 | if 'closed' in kw: 379 | self.closed = kw['closed'] 380 | if 'svgState' in kw: 381 | self.svgState = kw['svgState'] 382 | else: 383 | self.svgState = SVGState() 384 | 385 | def __getitem__(self, index): 386 | return self._segments[index] 387 | 388 | def __setitem__(self, index, value): 389 | self._segments[index] = value 390 | self._length = None 391 | 392 | def __delitem__(self, index): 393 | del self._segments[index] 394 | self._length = None 395 | 396 | def insert(self, index, value): 397 | self._segments.insert(index, value) 398 | self._length = None 399 | 400 | def reverse(self): 401 | # Reversing the order of a path would require reversing each element 402 | # as well. That's not implemented. 403 | raise NotImplementedError 404 | 405 | def __len__(self): 406 | return len(self._segments) 407 | 408 | def __repr__(self): 409 | return 'Path(%s, closed=%s)' % ( 410 | ', '.join(repr(x) for x in self._segments), self.closed) 411 | 412 | def __eq__(self, other): 413 | if not isinstance(other, Path): 414 | return NotImplemented 415 | if len(self) != len(other): 416 | return False 417 | for s, o in zip(self._segments, other._segments): 418 | if not s == o: 419 | return False 420 | return True 421 | 422 | def __ne__(self, other): 423 | if not isinstance(other, Path): 424 | return NotImplemented 425 | return not self == other 426 | 427 | def _calc_lengths(self, error=ERROR, min_depth=MIN_DEPTH): 428 | ## TODO: check if error has decreased since last calculation 429 | if self._length is not None: 430 | return 431 | 432 | lengths = [each.length(error=error, min_depth=min_depth) for each in self._segments] 433 | self._length = sum(lengths) 434 | self._lengths = [each / (1 if self._length==0. else self._length) for each in lengths] 435 | 436 | def point(self, pos, error=ERROR): 437 | # Shortcuts 438 | if pos == 0.0: 439 | return self._segments[0].point(pos) 440 | if pos == 1.0: 441 | return self._segments[-1].point(pos) 442 | 443 | self._calc_lengths(error=error) 444 | # Find which segment the point we search for is located on: 445 | segment_start = 0 446 | for index, segment in enumerate(self._segments): 447 | segment_end = segment_start + self._lengths[index] 448 | if segment_end >= pos: 449 | # This is the segment! How far in on the segment is the point? 450 | segment_pos = (pos - segment_start) / (segment_end - segment_start) 451 | break 452 | segment_start = segment_end 453 | 454 | return segment.point(segment_pos) 455 | 456 | def length(self, error=ERROR, min_depth=MIN_DEPTH): 457 | self._calc_lengths(error, min_depth) 458 | return self._length 459 | 460 | def measure(self, start, end, error=ERROR, min_depth=MIN_DEPTH): 461 | self._calc_lengths(error=error) 462 | if start == 0.0 and end == 1.0: 463 | return self.length() 464 | length = 0 465 | segment_start = 0 466 | for index, segment in enumerate(self._segments): 467 | if end <= segment_start: 468 | break 469 | segment_end = segment_start + self._lengths[index] 470 | if start < segment_end: 471 | # this segment intersects the part of the path we want 472 | if start <= segment_start and segment_end <= end: 473 | # whole segment is contained in the part of the path 474 | length += self._lengths[index] * self._length 475 | else: 476 | if start <= segment_start: 477 | start_in_segment = 0. 478 | else: 479 | start_in_segment = (start-segment_start)/(segment_end-segment_start) 480 | if segment_end <= end: 481 | end_in_segment = 1. 482 | else: 483 | end_in_segment = (end-segment_start)/(segment_end-segment_start) 484 | segment = self._segments[index] 485 | length += segment_length(segment, start_in_segment, end_in_segment, segment.point(start_in_segment), 486 | segment.point(end_in_segment), error, MIN_DEPTH, 0) 487 | segment_start = segment_end 488 | return length 489 | 490 | def _is_closable(self): 491 | """Returns true if the end is on the start of a segment""" 492 | try: 493 | end = self[-1].end 494 | except: 495 | return False 496 | for segment in self: 497 | if segment.start == end: 498 | return True 499 | return False 500 | 501 | def breakup(self): 502 | paths = [] 503 | prevEnd = None 504 | segments = [] 505 | for segment in self._segments: 506 | if prevEnd is None or segment.point(0.) == prevEnd: 507 | segments.append(segment) 508 | else: 509 | paths.append(Path(*segments, svgState=self.svgState)) 510 | segments = [segment] 511 | prevEnd = segment.point(1.) 512 | 513 | if len(segments) > 0: 514 | paths.append(Path(*segments, svgState=self.svgState)) 515 | 516 | return paths 517 | 518 | def linearApproximation(self, error=0.001, max_depth=32): 519 | closed = False 520 | keepSegmentIndex = 0 521 | if self.closed: 522 | end = self[-1].end 523 | for i,segment in enumerate(self): 524 | if segment.start == end: 525 | keepSegmentIndex = i 526 | closed = True 527 | break 528 | 529 | keepSubpathIndex = 0 530 | keepPointIndex = 0 531 | 532 | subpaths = [] 533 | subpath = [] 534 | prevEnd = None 535 | for i,segment in enumerate(self._segments): 536 | if prevEnd is None or segment.start == prevEnd: 537 | if i == keepSegmentIndex: 538 | keepSubpathIndex = len(subpaths) 539 | keepPointIndex = len(subpath) 540 | else: 541 | subpaths.append(subpath) 542 | subpath = [] 543 | subpath += segment.getApproximatePoints(error=error/2., max_depth=max_depth) 544 | prevEnd = segment.end 545 | 546 | if len(subpath) > 0: 547 | subpaths.append(subpath) 548 | 549 | linearPath = Path(svgState=self.svgState) 550 | 551 | for i,subpath in enumerate(subpaths): 552 | keep = set((keepPointIndex,)) if i == keepSubpathIndex else set() 553 | special = None 554 | if i == keepSubpathIndex: 555 | special = subpath[keepPointIndex] 556 | points = removeCollinear(subpath, error=error/2., pointsToKeep=keep) 557 | # points = subpath 558 | 559 | for j in range(len(points)-1): 560 | linearPath.append(Line(points[j], points[j+1])) 561 | 562 | linearPath.closed = self.closed and linearPath._is_closable() 563 | linearPath.svgState = self.svgState 564 | 565 | return linearPath 566 | 567 | def getApproximateLines(self, error=0.001, max_depth=32): 568 | lines = [] 569 | for subpath in self.breakup(): 570 | points = subpath.getApproximatePoints(error=error, max_depth=max_depth) 571 | for i in range(len(points)-1): 572 | lines.append(points[i],points[i+1]) 573 | return lines 574 | 575 | @property 576 | def closed(self): 577 | """Checks that the path is closed""" 578 | return self._closed and self._is_closable() 579 | 580 | @closed.setter 581 | def closed(self, value): 582 | value = bool(value) 583 | if value and not self._is_closable(): 584 | raise ValueError("End does not coincide with a segment start.") 585 | self._closed = value 586 | 587 | def d(self): 588 | if self.closed: 589 | segments = self[:-1] 590 | else: 591 | segments = self[:] 592 | 593 | current_pos = None 594 | parts = [] 595 | previous_segment = None 596 | end = self[-1].end 597 | 598 | for segment in segments: 599 | start = segment.start 600 | # If the start of this segment does not coincide with the end of 601 | # the last segment or if this segment is actually the close point 602 | # of a closed path, then we should start a new subpath here. 603 | if current_pos != start or (self.closed and start == end): 604 | parts.append('M {0:G},{1:G}'.format(start.real, start.imag)) 605 | 606 | if isinstance(segment, Line): 607 | parts.append('L {0:G},{1:G}'.format( 608 | segment.end.real, segment.end.imag) 609 | ) 610 | elif isinstance(segment, CubicBezier): 611 | if segment.is_smooth_from(previous_segment): 612 | parts.append('S {0:G},{1:G} {2:G},{3:G}'.format( 613 | segment.control2.real, segment.control2.imag, 614 | segment.end.real, segment.end.imag) 615 | ) 616 | else: 617 | parts.append('C {0:G},{1:G} {2:G},{3:G} {4:G},{5:G}'.format( 618 | segment.control1.real, segment.control1.imag, 619 | segment.control2.real, segment.control2.imag, 620 | segment.end.real, segment.end.imag) 621 | ) 622 | elif isinstance(segment, QuadraticBezier): 623 | if segment.is_smooth_from(previous_segment): 624 | parts.append('T {0:G},{1:G}'.format( 625 | segment.end.real, segment.end.imag) 626 | ) 627 | else: 628 | parts.append('Q {0:G},{1:G} {2:G},{3:G}'.format( 629 | segment.control.real, segment.control.imag, 630 | segment.end.real, segment.end.imag) 631 | ) 632 | 633 | elif isinstance(segment, Arc): 634 | parts.append('A {0:G},{1:G} {2:G} {3:d},{4:d} {5:G},{6:G}'.format( 635 | segment.radius.real, segment.radius.imag, segment.rotation, 636 | int(segment.arc), int(segment.sweep), 637 | segment.end.real, segment.end.imag) 638 | ) 639 | current_pos = segment.end 640 | previous_segment = segment 641 | 642 | if self.closed: 643 | parts.append('Z') 644 | 645 | return ' '.join(parts) 646 | 647 | -------------------------------------------------------------------------------- /inflateutils/svgpath/parser.py: -------------------------------------------------------------------------------- 1 | # SVG Path specification parser 2 | 3 | import re 4 | from . import path 5 | import xml.etree.ElementTree as ET 6 | import re 7 | import math 8 | 9 | COMMANDS = set('MmZzLlHhVvCcSsQqTtAa') 10 | UPPERCASE = set('MZLHVCSQTA') 11 | 12 | COMMAND_RE = re.compile("([MmZzLlHhVvCcSsQqTtAa])") 13 | FLOAT_RE = re.compile("[-+]?[0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?") 14 | 15 | SVG_COLORS = { 16 | "aliceblue": (0.941176,0.972549,1), 17 | "antiquewhite": (0.980392,0.921569,0.843137), 18 | "aqua": (0,1,1), 19 | "aquamarine": (0.498039,1,0.831373), 20 | "azure": (0.941176,1,1), 21 | "beige": (0.960784,0.960784,0.862745), 22 | "bisque": (1,0.894118,0.768627), 23 | "black": (0,0,0), 24 | "blanchedalmond": (1,0.921569,0.803922), 25 | "blue": (0,0,1), 26 | "blueviolet": (0.541176,0.168627,0.886275), 27 | "brown": (0.647059,0.164706,0.164706), 28 | "burlywood": (0.870588,0.721569,0.529412), 29 | "cadetblue": (0.372549,0.619608,0.627451), 30 | "chartreuse": (0.498039,1,0), 31 | "chocolate": (0.823529,0.411765,0.117647), 32 | "coral": (1,0.498039,0.313725), 33 | "cornflowerblue": (0.392157,0.584314,0.929412), 34 | "cornsilk": (1,0.972549,0.862745), 35 | "crimson": (0.862745,0.0784314,0.235294), 36 | "cyan": (0,1,1), 37 | "darkblue": (0,0,0.545098), 38 | "darkcyan": (0,0.545098,0.545098), 39 | "darkgoldenrod": (0.721569,0.52549,0.0431373), 40 | "darkgray": (0.662745,0.662745,0.662745), 41 | "darkgreen": (0,0.392157,0), 42 | "darkgrey": (0.662745,0.662745,0.662745), 43 | "darkkhaki": (0.741176,0.717647,0.419608), 44 | "darkmagenta": (0.545098,0,0.545098), 45 | "darkolivegreen": (0.333333,0.419608,0.184314), 46 | "darkorange": (1,0.54902,0), 47 | "darkorchid": (0.6,0.196078,0.8), 48 | "darkred": (0.545098,0,0), 49 | "darksalmon": (0.913725,0.588235,0.478431), 50 | "darkseagreen": (0.560784,0.737255,0.560784), 51 | "darkslateblue": (0.282353,0.239216,0.545098), 52 | "darkslategray": (0.184314,0.309804,0.309804), 53 | "darkslategrey": (0.184314,0.309804,0.309804), 54 | "darkturquoise": (0,0.807843,0.819608), 55 | "darkviolet": (0.580392,0,0.827451), 56 | "deeppink": (1,0.0784314,0.576471), 57 | "deepskyblue": (0,0.74902,1), 58 | "dimgray": (0.411765,0.411765,0.411765), 59 | "dimgrey": (0.411765,0.411765,0.411765), 60 | "dodgerblue": (0.117647,0.564706,1), 61 | "firebrick": (0.698039,0.133333,0.133333), 62 | "floralwhite": (1,0.980392,0.941176), 63 | "forestgreen": (0.133333,0.545098,0.133333), 64 | "fuchsia": (1,0,1), 65 | "gainsboro": (0.862745,0.862745,0.862745), 66 | "ghostwhite": (0.972549,0.972549,1), 67 | "gold": (1,0.843137,0), 68 | "goldenrod": (0.854902,0.647059,0.12549), 69 | "gray": (0.501961,0.501961,0.501961), 70 | "grey": (0.501961,0.501961,0.501961), 71 | "green": (0,0.501961,0), 72 | "greenyellow": (0.678431,1,0.184314), 73 | "honeydew": (0.941176,1,0.941176), 74 | "hotpink": (1,0.411765,0.705882), 75 | "indianred": (0.803922,0.360784,0.360784), 76 | "indigo": (0.294118,0,0.509804), 77 | "ivory": (1,1,0.941176), 78 | "khaki": (0.941176,0.901961,0.54902), 79 | "lavender": (0.901961,0.901961,0.980392), 80 | "lavenderblush": (1,0.941176,0.960784), 81 | "lawngreen": (0.486275,0.988235,0), 82 | "lemonchiffon": (1,0.980392,0.803922), 83 | "lightblue": (0.678431,0.847059,0.901961), 84 | "lightcoral": (0.941176,0.501961,0.501961), 85 | "lightcyan": (0.878431,1,1), 86 | "lightgoldenrodyellow": (0.980392,0.980392,0.823529), 87 | "lightgray": (0.827451,0.827451,0.827451), 88 | "lightgreen": (0.564706,0.933333,0.564706), 89 | "lightgrey": (0.827451,0.827451,0.827451), 90 | "lightpink": (1,0.713725,0.756863), 91 | "lightsalmon": (1,0.627451,0.478431), 92 | "lightseagreen": (0.12549,0.698039,0.666667), 93 | "lightskyblue": (0.529412,0.807843,0.980392), 94 | "lightslategray": (0.466667,0.533333,0.6), 95 | "lightslategrey": (0.466667,0.533333,0.6), 96 | "lightsteelblue": (0.690196,0.768627,0.870588), 97 | "lightyellow": (1,1,0.878431), 98 | "lime": (0,1,0), 99 | "limegreen": (0.196078,0.803922,0.196078), 100 | "linen": (0.980392,0.941176,0.901961), 101 | "magenta": (1,0,1), 102 | "maroon": (0.501961,0,0), 103 | "mediumaquamarine": (0.4,0.803922,0.666667), 104 | "mediumblue": (0,0,0.803922), 105 | "mediumorchid": (0.729412,0.333333,0.827451), 106 | "mediumpurple": (0.576471,0.439216,0.858824), 107 | "mediumseagreen": (0.235294,0.701961,0.443137), 108 | "mediumslateblue": (0.482353,0.407843,0.933333), 109 | "mediumspringgreen": (0,0.980392,0.603922), 110 | "mediumturquoise": (0.282353,0.819608,0.8), 111 | "mediumvioletred": (0.780392,0.0823529,0.521569), 112 | "midnightblue": (0.0980392,0.0980392,0.439216), 113 | "mintcream": (0.960784,1,0.980392), 114 | "mistyrose": (1,0.894118,0.882353), 115 | "moccasin": (1,0.894118,0.709804), 116 | "navajowhite": (1,0.870588,0.678431), 117 | "navy": (0,0,0.501961), 118 | "oldlace": (0.992157,0.960784,0.901961), 119 | "olive": (0.501961,0.501961,0), 120 | "olivedrab": (0.419608,0.556863,0.137255), 121 | "orange": (1,0.647059,0), 122 | "orangered": (1,0.270588,0), 123 | "orchid": (0.854902,0.439216,0.839216), 124 | "palegoldenrod": (0.933333,0.909804,0.666667), 125 | "palegreen": (0.596078,0.984314,0.596078), 126 | "paleturquoise": (0.686275,0.933333,0.933333), 127 | "palevioletred": (0.858824,0.439216,0.576471), 128 | "papayawhip": (1,0.937255,0.835294), 129 | "peachpuff": (1,0.854902,0.72549), 130 | "peru": (0.803922,0.521569,0.247059), 131 | "pink": (1,0.752941,0.796078), 132 | "plum": (0.866667,0.627451,0.866667), 133 | "powderblue": (0.690196,0.878431,0.901961), 134 | "purple": (0.501961,0,0.501961), 135 | "red": (1,0,0), 136 | "rosybrown": (0.737255,0.560784,0.560784), 137 | "royalblue": (0.254902,0.411765,0.882353), 138 | "saddlebrown": (0.545098,0.270588,0.0745098), 139 | "salmon": (0.980392,0.501961,0.447059), 140 | "sandybrown": (0.956863,0.643137,0.376471), 141 | "seagreen": (0.180392,0.545098,0.341176), 142 | "seashell": (1,0.960784,0.933333), 143 | "sienna": (0.627451,0.321569,0.176471), 144 | "silver": (0.752941,0.752941,0.752941), 145 | "skyblue": (0.529412,0.807843,0.921569), 146 | "slateblue": (0.415686,0.352941,0.803922), 147 | "slategray": (0.439216,0.501961,0.564706), 148 | "slategrey": (0.439216,0.501961,0.564706), 149 | "snow": (1,0.980392,0.980392), 150 | "springgreen": (0,1,0.498039), 151 | "steelblue": (0.27451,0.509804,0.705882), 152 | "tan": (0.823529,0.705882,0.54902), 153 | "teal": (0,0.501961,0.501961), 154 | "thistle": (0.847059,0.74902,0.847059), 155 | "tomato": (1,0.388235,0.278431), 156 | "turquoise": (0.25098,0.878431,0.815686), 157 | "violet": (0.933333,0.509804,0.933333), 158 | "wheat": (0.960784,0.870588,0.701961), 159 | "white": (1,1,1), 160 | "whitesmoke": (0.960784,0.960784,0.960784), 161 | "yellow": (1,1,0), 162 | "yellowgreen": (0.603922,0.803922,0.196078), 163 | } 164 | 165 | def _tokenize_path(pathdef): 166 | for x in COMMAND_RE.split(pathdef): 167 | if x in COMMANDS: 168 | yield x 169 | for token in FLOAT_RE.findall(x): 170 | yield token 171 | 172 | def applyMatrix(matrix, z): 173 | return complex(z.real * matrix[0] + z.imag * matrix[1] + matrix[2], 174 | z.real * matrix[3] + z.imag * matrix[4] + matrix[5] ) 175 | 176 | def matrixMultiply(matrix1, matrix2): 177 | if matrix1 is None: 178 | return matrix2 179 | elif matrix2 is None: 180 | return matrix1 181 | 182 | m1 = [matrix1[0:3], matrix1[3:6] ] # don't need last row 183 | m2 = [matrix2[0:3], matrix2[3:6], [0,0,1]] 184 | 185 | out = [] 186 | 187 | for i in range(2): 188 | for j in range(3): 189 | out.append( sum(m1[i][k]*m2[k][j] for k in range(3)) ) 190 | 191 | return out 192 | 193 | def parse_path(pathdef, current_pos=0j, matrix = None, svgState=None): 194 | if matrix is None: 195 | scaler=lambda z : z 196 | else: 197 | scaler=lambda z : applyMatrix(matrix, z) 198 | if svgState is None: 199 | svgState = path.SVGState() 200 | 201 | # In the SVG specs, initial movetos are absolute, even if 202 | # specified as 'm'. This is the default behavior here as well. 203 | # But if you pass in a current_pos variable, the initial moveto 204 | # will be relative to that current_pos. This is useful. 205 | elements = list(_tokenize_path(pathdef)) 206 | # Reverse for easy use of .pop() 207 | elements.reverse() 208 | 209 | segments = path.Path(svgState = svgState) 210 | start_pos = None 211 | command = None 212 | 213 | while elements: 214 | 215 | if elements[-1] in COMMANDS: 216 | # New command. 217 | last_command = command # Used by S and T 218 | command = elements.pop() 219 | absolute = command in UPPERCASE 220 | command = command.upper() 221 | else: 222 | # If this element starts with numbers, it is an implicit command 223 | # and we don't change the command. Check that it's allowed: 224 | if command is None: 225 | raise ValueError("Unallowed implicit command in %s, position %s" % ( 226 | pathdef, len(pathdef.split()) - len(elements))) 227 | last_command = command # Used by S and T 228 | 229 | if command == 'M': 230 | # Moveto command. 231 | x = elements.pop() 232 | y = elements.pop() 233 | pos = float(x) + float(y) * 1j 234 | if absolute: 235 | current_pos = pos 236 | else: 237 | current_pos += pos 238 | 239 | # when M is called, reset start_pos 240 | # This behavior of Z is defined in svg spec: 241 | # http://www.w3.org/TR/SVG/paths.html#PathDataClosePathCommand 242 | start_pos = current_pos 243 | 244 | # Implicit moveto commands are treated as lineto commands. 245 | # So we set command to lineto here, in case there are 246 | # further implicit commands after this moveto. 247 | command = 'L' 248 | 249 | elif command == 'Z': 250 | # Close path 251 | if current_pos != start_pos: 252 | segments.append(path.Line(scaler(current_pos), scaler(start_pos))) 253 | if len(segments): 254 | segments.closed = True 255 | current_pos = start_pos 256 | start_pos = None 257 | command = None # You can't have implicit commands after closing. 258 | 259 | elif command == 'L': 260 | x = elements.pop() 261 | y = elements.pop() 262 | pos = float(x) + float(y) * 1j 263 | if not absolute: 264 | pos += current_pos 265 | segments.append(path.Line(scaler(current_pos), scaler(pos))) 266 | current_pos = pos 267 | 268 | elif command == 'H': 269 | x = elements.pop() 270 | pos = float(x) + current_pos.imag * 1j 271 | if not absolute: 272 | pos += current_pos.real 273 | segments.append(path.Line(scaler(current_pos), scaler(pos))) 274 | current_pos = pos 275 | 276 | elif command == 'V': 277 | y = elements.pop() 278 | pos = current_pos.real + float(y) * 1j 279 | if not absolute: 280 | pos += current_pos.imag * 1j 281 | segments.append(path.Line(scaler(current_pos), scaler(pos))) 282 | current_pos = pos 283 | 284 | elif command == 'C': 285 | control1 = float(elements.pop()) + float(elements.pop()) * 1j 286 | control2 = float(elements.pop()) + float(elements.pop()) * 1j 287 | end = float(elements.pop()) + float(elements.pop()) * 1j 288 | 289 | if not absolute: 290 | control1 += current_pos 291 | control2 += current_pos 292 | end += current_pos 293 | 294 | segments.append(path.CubicBezier(scaler(current_pos), scaler(control1), scaler(control2), scaler(end))) 295 | current_pos = end 296 | 297 | elif command == 'S': 298 | # Smooth curve. First control point is the "reflection" of 299 | # the second control point in the previous path. 300 | 301 | if last_command not in 'CS': 302 | # If there is no previous command or if the previous command 303 | # was not an C, c, S or s, assume the first control point is 304 | # coincident with the current point. 305 | control1 = scaler(current_pos) 306 | else: 307 | # The first control point is assumed to be the reflection of 308 | # the second control point on the previous command relative 309 | # to the current point. 310 | control1 = 2 * scaler(current_pos) - segments[-1].control2 311 | 312 | control2 = float(elements.pop()) + float(elements.pop()) * 1j 313 | end = float(elements.pop()) + float(elements.pop()) * 1j 314 | 315 | if not absolute: 316 | control2 += current_pos 317 | end += current_pos 318 | 319 | segments.append(path.CubicBezier(scaler(current_pos), control1, scaler(control2), scaler(end))) 320 | current_pos = end 321 | 322 | elif command == 'Q': 323 | control = float(elements.pop()) + float(elements.pop()) * 1j 324 | end = float(elements.pop()) + float(elements.pop()) * 1j 325 | 326 | if not absolute: 327 | control += current_pos 328 | end += current_pos 329 | 330 | segments.append(path.QuadraticBezier(scaler(current_pos), scaler(control), scaler(end))) 331 | current_pos = end 332 | 333 | elif command == 'T': 334 | # Smooth curve. Control point is the "reflection" of 335 | # the second control point in the previous path. 336 | 337 | if last_command not in 'QT': 338 | # If there is no previous command or if the previous command 339 | # was not an Q, q, T or t, assume the first control point is 340 | # coincident with the current point. 341 | control = scaler(current_pos) 342 | else: 343 | # The control point is assumed to be the reflection of 344 | # the control point on the previous command relative 345 | # to the current point. 346 | control = 2 * scaler(current_pos) - segments[-1].control 347 | 348 | end = float(elements.pop()) + float(elements.pop()) * 1j 349 | 350 | if not absolute: 351 | end += current_pos 352 | 353 | segments.append(path.QuadraticBezier(scaler(current_pos), control, scaler(end))) 354 | current_pos = end 355 | 356 | elif command == 'A': 357 | radius = float(elements.pop()) + float(elements.pop()) * 1j 358 | rotation = float(elements.pop()) 359 | arc = float(elements.pop()) 360 | sweep = float(elements.pop()) 361 | end = float(elements.pop()) + float(elements.pop()) * 1j 362 | 363 | if not absolute: 364 | end += current_pos 365 | 366 | segments.append(path.Arc(current_pos, radius, rotation, arc, sweep, end, scaler)) 367 | current_pos = end 368 | 369 | return segments 370 | 371 | def path_from_ellipse(x, y, rx, ry, matrix, state): 372 | arc = "M %.9f %.9f " % (x-rx,y) 373 | arc += "A %.9f %.9f 0 0 1 %.9f %.9f " % (rx, ry, x+rx,y) 374 | arc += "A %.9f %.9f 0 0 1 %.9f %.9f" % (rx, ry, x-rx,y) 375 | return parse_path(arc, matrix=matrix, svgState=state) 376 | 377 | def path_from_rect(x,y,w,h,rx,ry, matrix,state): 378 | if not rx and not ry: 379 | rect = "M %.9f %.9f h %.9f v %.9f h %.9f Z" % (x,y,w,h,-w) 380 | else: 381 | if rx is None: 382 | rx = ry 383 | elif ry is None: 384 | ry = rx 385 | rect = "M %.9f %.9f h %.9f " % (x+rx,y,w-2*rx) 386 | rect += "a %.9f %.9f 0 0 1 %.9f %.9f " % (rx, ry, rx, ry) 387 | rect += "v %.9f " % (h-2*ry) 388 | rect += "a %.9f %.9f 0 0 1 %.9f %.9f " % (rx, ry, -rx, ry) 389 | rect += "h %.9f " % -(w-2*rx) 390 | rect += "a %.9f %.9f 0 0 1 %.9f %.9f " % (rx, ry, -rx, -ry) 391 | rect += "v %.9f " % -(h-2*ry) 392 | rect += "a %.9f %.9f 0 0 1 %.9f %.9f Z" % (rx, ry, rx, -ry) 393 | return parse_path(rect, matrix=matrix, svgState=state) 394 | 395 | def sizeFromString(text): 396 | """ 397 | Returns size in mm, if possible. 398 | """ 399 | text = re.sub(r'\s',r'', text) 400 | try: 401 | return float(text)*25.4/96 # px 402 | except: 403 | if text[-1] == '%': 404 | return float(text[:-1]) # NOT mm 405 | units = text[-2:].lower() 406 | x = float(text[:-2]) 407 | convert = { 'mm':1, 'cm':10, 'in':25.4, 'px':25.4/96, 'pt':25.4/72, 'pc':12*25.4/72 } 408 | try: 409 | return x * convert[units] 410 | except: 411 | return x # NOT mm 412 | 413 | def rgbFromColor(colorName): 414 | colorName = colorName.strip().lower() 415 | if colorName == 'none': 416 | return None 417 | cmd = re.split(r'[\s(),]+', colorName) 418 | if cmd[0] == 'rgb': 419 | colors = cmd[1:4] 420 | outColor = [] 421 | for c in colors: 422 | if c.endswith('%'): 423 | outColor.append(float(c[:-1]) / 100.) 424 | else: 425 | outColor.append(float(c) / 255.) 426 | return tuple(outColor) 427 | elif colorName.startswith('#'): 428 | if len(colorName) == 4: 429 | return (int(colorName[1],16)/15., int(colorName[2],16)/15., int(colorName[3],16)/15.) 430 | else: 431 | return (int(colorName[1:3],16)/255., int(colorName[3:5],16)/255., int(colorName[5:7],16)/255.) 432 | else: 433 | return SVG_COLORS[colorName] 434 | 435 | 436 | def getPathsFromSVG(svg): 437 | def updateStateCommand(state,cmd,arg): 438 | if cmd == 'fill': 439 | state.fill = rgbFromColor(arg) 440 | elif cmd == 'fill-opacity': 441 | state.fillOpacity = float(arg) 442 | elif cmd == 'fill-rule': 443 | state.fillRule = arg 444 | # if state.fill is None: 445 | # state.fill = (0.,0.,0.) 446 | elif cmd == 'stroke': 447 | state.stroke = rgbFromColor(arg) 448 | elif cmd == 'stroke-opacity': 449 | state.strokeOpacity = rgbFromColor(arg) 450 | elif cmd == 'stroke-width': 451 | state.strokeWidth = float(arg) 452 | elif cmd == 'vector-effect': 453 | state.strokeWidthScaling = 'non-scaling-stroke' not in cmd 454 | # todo better scaling for non-uniform cases? 455 | 456 | def updateState(tree,state,matrix): 457 | state = state.clone() 458 | try: 459 | style = re.sub(r'\s',r'', tree.attrib['style']).lower() 460 | for item in style.split(';'): 461 | cmd,arg = item.split(':')[:2] 462 | updateStateCommand(state,cmd,arg) 463 | except: 464 | pass 465 | 466 | for item in tree.attrib: 467 | try: 468 | updateStateCommand(state,item,tree.attrib[item]) 469 | except: 470 | pass 471 | 472 | if state.strokeWidth and state.strokeWidthScaling: 473 | # this won't work great for non-uniform scaling 474 | h = abs(applyMatrix(matrix, complex(0,state.strokeWidth)) - applyMatrix(matrix, 0j)) 475 | w = abs(applyMatrix(matrix, complex(state.strokeWidth,0)) - applyMatrix(matrix, 0j)) 476 | state.strokeWidth = (h+w)/2 477 | return state 478 | 479 | def reorder(a,b,c,d,e,f): 480 | return [a,c,e, b,d,f] 481 | 482 | def updateMatrix(tree, matrix): 483 | try: 484 | transformList = re.split(r'\)[\s,]+', tree.attrib['transform'].strip().lower()) 485 | except KeyError: 486 | return matrix 487 | 488 | for transform in transformList: 489 | cmd = re.split(r'[,()\s]+', transform) 490 | 491 | updateMatrix = None 492 | 493 | if cmd[0] == 'matrix': 494 | updateMatrix = reorder(*list(map(float, cmd[1:7]))) 495 | elif cmd[0] == 'translate': 496 | x = float(cmd[1]) 497 | if len(cmd) >= 3 and cmd[2] != '': 498 | y = float(cmd[2]) 499 | else: 500 | y = 0 501 | updateMatrix = reorder(1,0,0,1,x,y) 502 | elif cmd[0] == 'scale': 503 | x = float(cmd[1]) 504 | if len(cmd) >= 3 and cmd[2] != '': 505 | y = float(cmd[2]) 506 | else: 507 | y = x 508 | updateMatrix = reorder(x,0,0, y,0,0) 509 | elif cmd[0] == 'rotate': 510 | theta = float(cmd[1]) * math.pi / 180. 511 | c = math.cos(theta) 512 | s = math.sin(theta) 513 | updateMatrix = [c, -s, 0, s, c, 0] 514 | if len(cmd) >= 4 and cmd[2] != '': 515 | x = float(cmd[2]) 516 | y = float(cmd[3]) 517 | updateMatrix = matrixMultiply(updateMatrix, [1,0,-x, 0,1,-y]) 518 | updateMatrix = matrixMultiply([1,0,x, 0,1,y], updateMatrix) 519 | elif cmd[0] == 'skewX': 520 | theta = float(cmd[1]) * math.pi / 180. 521 | updateMatrix = [1, math.tan(theta), 0, 0,1,0] 522 | elif cmd[0] == 'skewY': 523 | theta = float(cmd[1]) * math.pi / 180. 524 | updateMatrix = [1,0,0, math.tan(theta),1,0] 525 | 526 | matrix = matrixMultiply(matrix, updateMatrix) 527 | 528 | return matrix 529 | 530 | def updateStateAndMatrix(tree,state,matrix): 531 | matrix = updateMatrix(tree,matrix) 532 | return updateState(tree,state,matrix),matrix 533 | 534 | def getPaths(paths, matrix, tree, state, savedElements): 535 | def getFloat(attribute,default=0.): 536 | try: 537 | return float(tree.attrib[attribute].strip()) 538 | except KeyError: 539 | return default 540 | 541 | tag = re.sub(r'.*}', '', tree.tag).lower() 542 | try: 543 | savedElements[tree.attrib['id']] = tree 544 | except KeyError: 545 | pass 546 | 547 | state, matrix = updateStateAndMatrix(tree, state, matrix) 548 | if tag == 'path': 549 | path = parse_path(tree.attrib['d'], matrix=matrix, svgState=state) 550 | if len(path): 551 | paths.append(path) 552 | elif tag == 'circle': 553 | path = path_from_ellipse(getFloat('cx'), getFloat('cy'), getFloat('r'), getFloat('r'), matrix, state) 554 | paths.append(path) 555 | elif tag == 'ellipse': 556 | path = path_from_ellipse(getFloat('cx'), getFloat('cy'), getFloat('rx'), getFloat('ry'), matrix, state) 557 | paths.append(path) 558 | elif tag == 'line': 559 | x1 = getFloat('x1') 560 | y1 = getFloat('y1') 561 | x2 = getFloat('x2') 562 | y2 = getFloat('y2') 563 | p = 'M %.9f %.9f L %.9f %.9f' % (x1,y1,x2,y2) 564 | path = parse_path(p, matrix=matrix, svgState=state) 565 | paths.append(path) 566 | elif tag == 'polygon': 567 | points = re.split(r'[\s,]+', tree.attrib['points'].strip()) 568 | p = ' '.join(['M', points[0], points[1], 'L'] + points[2:] + ['Z']) 569 | path = parse_path(p, matrix=matrix, svgState=state) 570 | paths.append(path) 571 | elif tag == 'polyline': 572 | points = re.split(r'[\s,]+', tree.attrib['points'].strip()) 573 | p = ' '.join(['M', points[0], points[1], 'L'] + points[2:]) 574 | path = parse_path(p, matrix=matrix, svgState=state) 575 | paths.append(path) 576 | elif tag == 'rect': 577 | x = getFloat('x') 578 | y = getFloat('y') 579 | w = getFloat('width') 580 | h = getFloat('height') 581 | rx = getFloat('rx',default=None) 582 | ry = getFloat('ry',default=None) 583 | path = path_from_rect(x,y,w,h,rx,ry, matrix,state) 584 | paths.append(path) 585 | elif tag == 'g' or tag == 'svg': 586 | for child in tree: 587 | getPaths(paths, matrix, child, state, savedElements) 588 | elif tag == 'use': 589 | try: 590 | link = None 591 | for tag in tree.attrib: 592 | if tag.strip().lower().endswith("}href"): 593 | link = tree.attrib[tag] 594 | break 595 | if link is None or link[0] != '#': 596 | raise KeyError 597 | source = savedElements[link[1:]] 598 | x = 0 599 | y = 0 600 | try: 601 | x = float(tree.attrib['x']) 602 | except: 603 | pass 604 | try: 605 | y = float(tree.attrib['y']) 606 | except: 607 | pass 608 | # TODO: handle width and height? (Inkscape does not) 609 | matrix = matrixMultiply(matrix, reorder(1,0,0,1,x,y)) 610 | getPaths(paths, matrix, source, state, dict(savedElements)) 611 | except KeyError: 612 | pass 613 | 614 | def scale(width, height, viewBox, z): 615 | x = (z.real - viewBox[0]) / (viewBox[2] - viewBox[0]) * width 616 | y = (viewBox[3]-z.imag) / (viewBox[3] - viewBox[1]) * height 617 | return complex(x,y) 618 | 619 | paths = [] 620 | 621 | try: 622 | width = sizeFromString(svg.attrib['width'].strip()) 623 | except KeyError: 624 | width = None 625 | try: 626 | height = sizeFromString(svg.attrib['height'].strip()) 627 | except KeyError: 628 | height = None 629 | 630 | try: 631 | viewBox = list(map(float, re.split(r'[\s,]+', svg.attrib['viewBox'].strip()))) 632 | except KeyError: 633 | if width is None or height is None: 634 | raise KeyError 635 | viewBox = [0, 0, width*96/25.4, height*96/25.4] 636 | 637 | if width is None: 638 | width = viewBox[2] * 25.4/96 639 | 640 | if height is None: 641 | height = viewBox[3] * 25.4/96 642 | 643 | viewBoxWidth = viewBox[2] 644 | viewBoxHeight = viewBox[3] 645 | 646 | viewBox[2] += viewBox[0] 647 | viewBox[3] += viewBox[1] 648 | 649 | try: 650 | preserve = svg.attrib['preserveAspectRatio'].strip().lower().split() 651 | if len(preserve[0]) != 8: 652 | raise KeyError 653 | if len(preserve)>=2 and preserve[1] == 'slice': 654 | if viewBoxWidth/viewBoxHeight > width/height: 655 | # viewbox is wider than viewport, so scale by height to ensure 656 | # viewbox covers the viewport 657 | rescale = height / viewBoxHeight 658 | else: 659 | rescale = width / viewBoxWidth 660 | else: 661 | if viewBoxWidth/viewBoxHeight > width/height: 662 | # viewbox is wider than viewport, so scale by width to ensure 663 | # viewport covers the viewbox 664 | rescale = width / viewBoxWidth 665 | else: 666 | rescale = height / viewBoxHeight 667 | matrix = [rescale, 0, 0, 668 | 0, rescale, 0]; 669 | 670 | if preserve[0][0:4] == 'xmin': 671 | # viewBox[0] to 0 672 | matrix[2] = -viewBox[0] * rescale 673 | elif preserve[0][0:4] == 'xmid': 674 | # viewBox[0] to width/2 675 | matrix[2] = -viewBox[0] * rescale + width/2 676 | else: # preserve[0][0:4] == 'xmax': 677 | # viewBox[0] to width 678 | matrix[2] = -viewBox[0] * rescale + width 679 | 680 | if preserve[0][4:8] == 'ymin': 681 | # viewBox[1] to 0 682 | matrix[5] = -viewBox[1] * rescale 683 | elif preserve[0][4:8] == 'ymid': 684 | # viewBox[0] to width/2 685 | matrix[5] = -viewBox[1] * rescale + height/2 686 | else: # preserve[0][4:8] == 'xmax': 687 | # viewBox[0] to width 688 | matrix[5] = -viewBox[1] * rescale + height 689 | except: 690 | matrix = [ width/viewBoxWidth, 0, -viewBox[0]* width/viewBoxWidth, 691 | 0, -height/viewBoxHeight, viewBox[3]*height/viewBoxHeight ] 692 | 693 | getPaths(paths, matrix, svg, path.SVGState(), {}) 694 | 695 | return ( paths, applyMatrix(matrix, complex(viewBox[0], viewBox[1])), 696 | applyMatrix(matrix, complex(viewBox[2], viewBox[3])) ) 697 | 698 | def getPathsFromSVGFile(filename): 699 | return getPathsFromSVG(ET.parse(filename).getroot()) 700 | --------------------------------------------------------------------------------