""" ImportGCode, and Inkscape extension by Nathaniel Klumb This extension adds support for some GCode files to the File/Import... dialog in Inkscape. It loads the GCode file passed to it by Inkscape as a command-line parameter and writes the resulting SVG to stdout (which is how Inkscape input plugins work). This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. """ import re import inkex from StringIO import StringIO from math import sqrt,pi,sin,cos,tan,acos,atan2,fabs class ImportGCode: """ Import a GCode file and process it into an SVG. """ current_id = 0 geometry_error = False def __init__(self,gcode_filename,v_carve=False,laser_mode=False, ignore_z=True,label_z=True, tool_diameter=1.0,v_angle=90.0,v_top=0.0,v_step=1.0): """ Load a GCode file and process it into an SVG. """ self.unit = 1.0 self.ignore_z = ignore_z or v_carve self.label_z = label_z and not v_carve self.tool_diameter = tool_diameter self.v_carve = v_carve self.v_angle = v_angle * pi / 180.0 self.v_top = v_top self.v_step = v_step self.laser_mode = laser_mode self.spindle = False self.speed = 0 with open(gcode_filename) as file: self.loadGCode(file) self.createSVG() def getIJ(self,x1,y1,x2,y2,r): """ Calculate I and J from two arc endpoints and a radius. """ theta = atan2(y2 - y1, x2 - x1) alpha = acos(sqrt((x2 - x1)**2 + (y2 - y1)**2)/(2 * abs(r))) return (r * cos(theta + alpha), r * sin(theta + alpha)) def getTangentPoints(self,x1,y1,r1,x2,y2,r2): """ Compute the four outer tangent endpoints of two circles. """ theta = atan2(y2 - y1, x2 - x1) try: alpha = acos((r1 - r2)/sqrt((x2 - x1)**2 + (y2 - y1)**2)) except ValueError: # It's broken, but we'll just cap it off. # The SVG will be messed up, but that's better feedback # than just blankly saying, "Sorry, please try again." if not self.geometry_error: #Only show the error once. inkex.errormsg('Math error importing V-carve: ' 'V-bit angle too large?') inkex.errormsg(' Check your included angle ' 'setting and try again.') self.geometry_error = True if ((r1 - r2)/sqrt((x2 - x1)**2 + (y2 - y1)**2) < -1): alpha = pi else: alpha = 0 return ((x1 + r1 * cos(theta - alpha), y1 + r1 * sin(theta - alpha)), (x1 + r1 * cos(theta + alpha), y1 + r1 * sin(theta + alpha)), (x2 + r2 * cos(theta + alpha), y2 + r2 * sin(theta + alpha)), (x2 + r2 * cos(theta - alpha), y2 + r2 * sin(theta - alpha))) def intersectLines(self, p1, p2, p3, p4): """ Calculate the intersection of Line(p1,p2) and Line(p3,p4) returns a tuple: (x, y, valid, included) (x, y): the intersection valid: a unique solution exists included: the solution is within both the line segments Segment(p1,p2) and Segment(p3,p4) """ DET_TOLERANCE = 0.00000001 T = 0.00000001 # the first line is pt1 + r*(p2-p1) x1,y1 = p1 x2,y2 = p2 dx1 = x2 - x1 dy1 = y2 - y1 # the second line is p4 + s*(p4-p3) x3,y3 = p3 x4,y4 = p4; dx2 = x4 - x3 dy2 = y4 - y3 # In matrix form: # [ dx1 -dx2 ][ r ] = [ x3-x1 ] # [ dy1 -dy2 ][ s ] = [ y3-y1 ] # # Which can be solved: # [ r ] = _1_ [ -dy2 dx2 ] [ x3-x1 ] # [ s ] = DET [ -dy1 dx1 ] [ y3-y1 ] # # With the deteminant: DET = (-dx1 * dy2 + dy1 * dx2) DET = (-dx1 * dy2 + dy1 * dx2) # If DET is zero, they're parallel if fabs(DET) < DET_TOLERANCE: # If they overlap, either p3 or p4 must be # an included point, so check one, then check the # other. If either falls inside the segment from # p1 to p2, return it as *a* valid intersection. # Otherwise, return the bad news -- no intersection. # # Also, when checking the limits, allow a tolerance, T, # since we're working in floating point. if ((((x3 >= x1 - T) and (x3 <= x2 + T)) or ((x3 <= x1 + T) and (x3 >= x2 - T))) and (((y3 >= y1 - T) and (y3 <= y2 + T)) or ((y3 <= y1 + T) and (y3 >= y2 - T)))): return (x3,y3,False,True) elif ((((x4 >= x1 - T) and (x4 <= x2 + T)) or ((x4 <= x1 + T) and (x4 >= x2 - T))) and (((y4 >= y1 - T) and (y4 <= y2 + T)) or ((y4 <= y1 + T) and (y4 >= y2 - T)))): return (x4,y4,False,True) # NO CONNECTION... *dialtone* else: return (None,None,False,False) # Since the determinant is non-zero, now take the reciprocal. invDET = 1.0/DET # We want to calculate the intersection for each line so we can # average the results together. They should be identical, but # floating-point and rounding error, etc... # Calculate the scalar distances along Line(p1,p2) and Line(p3,p4) r = invDET * (-dy2 * (x3-x1) + dx2 * (y3-y1)) s = invDET * (-dy1 * (x3-x1) + dx1 * (y3-y1)) # Average the intersection's coordinates from the two lines. x = (x1 + r*dx1 + x3 + s*dx2)/2.0 y = (y1 + r*dy1 + y3 + s*dy2)/2.0 # Now one last check to see if the intersection's coordinates are # included within both line segments. included = ((((x >= x1 - T) and (x <= x2 + T)) or ((x <= x1 + T) and (x >= x2 - T))) and (((y >= y1 - T) and (y <= y2 + T)) or ((y <= y1 + T) and (y >= y2 - T))) and (((x >= x3 - T) and (x <= x4 + T)) or ((x <= x3 + T) and (x >= x4 - T))) and (((y >= y3 - T) and (y <= y4 + T)) or ((y <= y3 + T) and (y >= y4 - T)))) return (x,y,True,included) def getRadius(self,Z): """ Compute the radius of a V-bit given a Z coordinate. If the V-bit is above stock top, we just mirror it. Technically, the file's broken, but hey, may as well do something. """ if (self.v_top <= Z): return (Z - self.v_top) * tan(self.v_angle / 2) else: return (self.v_top - Z) * tan(self.v_angle / 2) def getAngle(self,center,point): """ Calculate the angle from a center to a point. """ a = atan2(point[1] - center[1], point[0] - center[0]) return a + ((2*pi) if (a<0.0) else 0) def isLargeAngle(self,center,p1,p2): """ Determine whether the SVG large angle flag should be set. """ a1 = self.getAngle(center,p1) a2 = self.getAngle(center,p2) angle = a1 - a2 if angle < 0: angle += 2 * pi return 1 if (abs(angle) > pi) else 0 def interpolatePoints(self,center,p1,p2): """ Interpolate a set of points along an arc. """ a1 = self.getAngle(center,p1) a2 = self.getAngle(center,p2) angle = a2 - a1 dz = 1.0 * (p2[2]-p1[2]) r = sqrt((center[0] - p1[0])**2 + (center[1] - p1[1])**2) length = r * abs(angle) / pi steps = int(round(length/self.v_step)) points = [] for i in range(1,steps): point = (center[0] + r * cos(a1 + angle*i/steps), center[1] + r * sin(a1 + angle*i/steps), p1[2] + dz*i/steps) points += [point] points += [p2] return points def makeVcarve(self,v_segments): """ Connect multiple V-carve segments into one path. Start on one V-carve segment and chain all the way to the opposite end, then add a switchback and chain all the way back to the beginning. """ vs = v_segments # Move to the starting point. path = 'M {} {} '.format(vs[0][1][0][0],vs[0][1][0][1]) # Initial arc, if it's not a point. if vs[0][0][0][2] > 0: path += ('A {} {} 0 {} {} {} {} ' ).format(vs[0][0][0][2],vs[0][0][0][2], 1 if (vs[0][0][0][2] > vs[0][0][1][2]) else 0, 0,vs[0][1][1][0],vs[0][1][1][1]) # Step through all the segments on the way to the other end. for v in range(len(vs)-1): # Check whether an intersection exists between the two # line segments. If so, use it, otherwise, connect with an arc. x,y,valid,included = self.intersectLines(vs[v][1][1], vs[v][1][2], vs[v+1][1][1], vs[v+1][1][2]) if included: #line segments path += 'L {} {} '.format(x,y) else: path += ('L {} {} A {} {} 0 {} {} {} {} ' ).format(vs[v][1][2][0],vs[v][1][2][1], vs[v][0][1][2],vs[v][0][1][2], self.isLargeAngle(vs[v][0][1], vs[v][1][2], vs[v+1][1][1]), 0,vs[v+1][1][1][0],vs[v+1][1][1][1]) # Connecting line. path += 'L {} {} '.format(vs[len(vs)-1][1][2][0], vs[len(vs)-1][1][2][1]) # Switchback arc, if it's not a point. if vs[len(vs)-1][0][1][2] > 0: path += ('A {} {} 0 {} {} {} {} ' ).format(vs[len(vs)-1][0][1][2],vs[len(vs)-1][0][1][2], 1 if (vs[len(vs)-1][0][1][2] > vs[len(vs)-2][0][0][2]) else 0, 0,vs[len(vs)-1][1][3][0],vs[len(vs)-1][1][3][1]) # Step through all the segments on the way back home. for v in range(len(vs)-1,0,-1): # Check whether an intersection exists between the two # line segments. If so, use it, otherwise, connect with an arc. x,y,valid,included = self.intersectLines(vs[v][1][3], vs[v][1][0], vs[v-1][1][3], vs[v-1][1][0]) if included: #line segments path += 'L {} {} '.format(x,y) else: path += ('L {} {} A {} {} 0 {} {} {} {} ' ).format(vs[v][1][0][0],vs[v][1][0][1], vs[v-1][0][1][2],vs[v-1][0][1][2], self.isLargeAngle(vs[v-1][0][1], vs[v][1][0], vs[v-1][1][3]), 0,vs[v-1][1][3][0],vs[v-1][1][3][1]) # And finally, close the curve. path += 'Z' return path def getVsegment(self,x1,y1,z1,x2,y2,z2): """ Compute the required data to define a V-carve segment. """ r1 = self.getRadius(z1) r2 = self.getRadius(z2) p = self.getTangentPoints(x1,y1,r1,x2,y2,r2) return (((x1,y1,r1),(x2,y2,r2)),p) def parseLine(self,command,X,Y,Z,line,no_path=False): """ Parse a line of G-code. This takes the current coordinates and modal command, then processes the new line of G-code to yield a new ending set of coordinates plus values necessary for curve computations. It also returns the resulting path data, unless otherwise indicated, e.g. for V-carves. """ comments = re.compile('\([^\)]*\)') commands = re.compile('([MSGXYZIJKR])([-.0-9]+)') lastX = X lastY = Y lastZ = Z I = 0.0 J = 0.0 K = 0.0 R = None results = commands.findall(comments.sub('',line)) for (code,val) in results: v = float(val) i = int(v) if code == 'M': if i == 3: self.spindle = True elif i == 5: self.spindle = False elif code == 'S': self.speed = v elif code == 'G': if i == 0: command = 'G0' elif i == 1: command = 'G1' elif i == 2: command = 'G2' elif i == 3: command = 'G3' elif i == 20: self.unit = 25.4 elif i == 21: self.unit = 1.0 elif val == "90": self.absolute = True elif val == "91": self.absolute = False elif val == "90.1": self.absoluteIJK = True elif val == "91.1": self.absoluteIJK = False elif code == 'X': if self.absolute: X = v * self.unit else: X += v * self.unit elif code == 'Y': if self.absolute: Y = v * self.unit else: Y += v * self.unit elif code == 'Z': if self.absolute: Z = v * self.unit else: Z += v * self.unit elif code == 'I': I = v * self.unit if self.absoluteIJK: I -= X elif code == 'J': J = v * self.unit if self.absoluteIJK: J -= Y elif code == 'K': # Sure, process it, but we don't *do* anything with K. K = v * self.unit if self.absoluteIJK: K -= Z elif code == 'R': R = v * self.unit if no_path: # V-carving doesn't need any path data. return ((command, X, Y, Z, I, J, K, R, '')) # The line's been parsed. Now let's generate path data from it. path = '' if command == 'G1': # If there's any XY motion, make a line segment. if ((X != lastX) or (Y != lastY)): path = 'L {} {} '.format(round(X,5),round(Y,5)) elif (command == 'G2') or (command == 'G3'): # Arcs! Oh, what glorious fun we'll have! First, sweep direction. sweep = 0 if (command == 'G2') else 1 # R overrules I and J if both are present, so we compute # new I and J values based on R. We need those to determine # whether the Large Angle Flag needs to be set. if R is not None: I,J = self.getIJ(lastX,lastY,X,Y,R) if (I != 0.0) or (J != 0.0): if sweep == 0: large_arc = self.isLargeAngle((lastX+I,lastY+J), (lastX,lastY),(X,Y)) else: large_arc = self.isLargeAngle((lastX+I,lastY+J), (X,Y),(lastX,lastY)) radius = sqrt(I**2 + J**2) path = 'A {} {} 0 {} {} {} {} '.format(round(radius,5), round(radius,5), large_arc,sweep, round(X,5),round(Y,5)) # No R, and no I or J either? Let's just call it a line segment. # (It may have had a K, but we don't believe in K for SVG imports.) else: path = 'L {} {} '.format(round(X,5),round(Y,5)) # In laser mode, if the spindle isn't active or the speed is zero, # there's no lasing to be had. Drop the path data. (The Inkscape # extension from J Tech Photonics uses G1/G2/G3 moves throughout, # with nary a G0, so if we don't do this, we'll show unlasered paths.) if (self.laser_mode and ((not self.spindle) or (self.speed == 0))): path = '' return ((command, X, Y, Z, I, J, K, R, path)) def savePath(self,path,Z): """ Save a set of path data, filing it by Z if appropriate. """ if (path.find('A') == -1) and (path.find('L') == -1): return #empty path if self.ignore_z: if path not in self.paths: self.paths.add(path) else: try: if path not in self.paths_by_z[Z]: self.paths_by_z[Z].add(path) except KeyError: self.paths_by_z[Z] = set([path]) def loadGCode(self,gcode_file): """ Load a G-code file, handling the contents. """ if self.ignore_z: self.paths = set([]) else: self.paths_by_z = {} self.absolute = True self.absoluteIJK = False self.unit=1.0 command = '' X = 0.0 Y = 0.0 Z = 0.0 lastX = X lastY = Y lastZ = Z self.minX = 0.0 self.minY = 0.0 self.minZ = 0.0 self.maxX = 0.0 self.maxY = 0.0 self.maxZ = 0.0 path = '' line = gcode_file.readline() v_segments = [] while line: command,X,Y,Z,I,J,K,R,path_data = self.parseLine(command, X, Y, Z, line, self.v_carve) self.minX = X if X < self.minX else self.minX self.maxX = X if X > self.maxX else self.maxX self.minY = Y if Y < self.minY else self.minY self.maxY = Y if Y > self.maxY else self.maxY self.minZ = Z if Z < self.minZ else self.minZ self.maxZ = Z if Z > self.maxZ else self.maxZ # V-carve mode. if self.v_carve: if (lastX != X) or (lastY != Y): if command == 'G1': v_segments += [self.getVsegment(lastX, lastY, lastZ, X, Y, Z)] elif (command == 'G2') or (command == 'G3'): # We don't attempt to handle the plethora of curves # that can result from V-carving arcs. Instead, we # just interpolate them and process the subparts. points = self.interpolatePoints((lastX+I,lastY+J), (lastX,lastY,lastZ), (X,Y,Z)) iX = lastX iY = lastY iZ = lastZ for p in points: v_segments += [self.getVsegment(iX, iY, iZ, p[0], p[1], p[2])] iX = p[0] iY = p[1] iZ = p[2] else: if len(v_segments): self.savePath(self.makeVcarve(v_segments),'VCarve') v_segments = [] # Standard mode (non-V-carve). else: if ((command == 'G0') or (not self.ignore_z and (Z != lastZ)) or (self.laser_mode and ((not self.spindle) or (self.speed == 0)))): if (path != ''): self.savePath(path,lastZ) path = '' if (((command == 'G1') or (command == 'G2') or (command == 'G3')) and (path == '')): path = 'M {} {} {}'.format(lastX,lastY,path_data) else: path += path_data lastX = X lastY = Y lastZ = Z line = gcode_file.readline() # Always remember to save the tail end of your work. if self.v_carve: if len(v_segments): self.savePath(self.makeVcarve(v_segments),'VCarve') else: if (path != ''): self.savePath(path,lastZ) def filterPaths(self): """ Filter out duplicate paths, leaving only the deepest instance. """ if self.ignore_z: return z_depths = sorted(self.paths_by_z,None,None,True) for i in range(0,len(z_depths)): for j in range(i+1, len(z_depths)): self.paths_by_z[z_depths[i]] -= self.paths_by_z[z_depths[j]] def next_id(self): """ Return an incrementing value. """ self.current_id += 1 return self.current_id def getStyle(self,color='#000000',width=None): """ Create a CSS-type style string. """ if width is None: width = self.tool_diameter return ('opacity:1;vector-effect:none;fill:none;fill-opacity:1;' 'stroke:{};stroke-width:{};stroke-opacity:1;' 'stroke-linecap:round;stroke-linejoin:round;' 'stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0' ).format(color,width) def createSVG(self): """ Create the output SVG. """ base = ('' ).format(self.maxX-self.minX, self.maxY-self.minY, self.minX, self.minY, self.maxX-self.minX, self.maxY-self.minY) self.doc = inkex.etree.parse(StringIO((base))) svg = self.doc.getroot() # Since G-code and SVG interpret Y in opposite directions, # we just group everything under a transform that mirrors Y. svg = inkex.etree.SubElement(svg,'g',{'id':'gcode', 'transform':'scale(1,-1)'}) # Add illustrative axes to the SVG to facilitate positioning. inkex.etree.SubElement(svg,'path', {'d':'M 0 {} V {}'.format(self.minY, self.maxY), 'style':self.getStyle('#00ff00',0.5), 'id':'vertical'}) inkex.etree.SubElement(svg,'path', {'d':'M {} 0 H {}'.format(self.minX, self.maxX), 'style':self.getStyle('#ff0000',0.5), 'id':'horizontal'}) # For V-carves, include the paths and use a narrow stroke width. if self.v_carve: for path in self.paths: inkex.etree.SubElement(svg,'path', {'d':path, 'style':self.getStyle(width=0.1), 'id':'path{}'.format(self.next_id())}) # For standard mode with Z ignored, include the paths. elif self.ignore_z: for path in self.paths: inkex.etree.SubElement(svg,'path', {'d':path, 'style':self.getStyle(), 'id':'path{}'.format(self.next_id())}) # For standard mode with Z grouping, filter the paths, # then add each group of paths (and optionally, labels). else: self.filterPaths() z_depths = sorted(self.paths_by_z) depth_num = 0 for i in range(0,len(z_depths)): if len(self.paths_by_z[z_depths[i]]): params = {'id':('group{}-{}' ).format(self.next_id(),z_depths[i]), 'style':self.getStyle()} group = inkex.etree.SubElement(svg,'g',params) # If labels are enabled, add the label to the group. if self.label_z: params = {'x':'{}'.format(self.maxX), 'y':'{}'.format(depth_num*-5), 'transform':'scale(1,-1)', 'id':'text{}'.format(i), 'style':('opacity:1;fill:#0000ff;' 'fill-opacity:1;stroke:none;' 'font-size:4.5')} if self.unit == 1.0: label = '{} mm'.format(z_depths[i]) else: label = '{} in'.format(z_depths[i]/self.unit) inkex.etree.SubElement(group,'text',params ).text = label depth_num += 1 # Now add the paths to the group. for path in self.paths_by_z[z_depths[i]]: id = 'path{}'.format(self.next_id()) inkex.etree.SubElement(group,'path', {'d':path, 'style':self.getStyle(), 'id':id}) # If labels are enabled, label the labels. if self.label_z: inkex.etree.SubElement(svg,'text', {'x':'{}'.format(self.maxX), 'y':'{}'.format(depth_num*-5), 'transform':'scale(1,-1)', 'id':'text{}'.format(i), 'style':('opacity:1;fill:#0000ff;' 'fill-opacity:1;stroke:none;' 'font-size:4.5')} ).text = 'Z Groups:' # And now for the code to allow Inkscape to run our lovely extension. if __name__ == '__main__': parser = inkex.optparse.OptionParser(usage=('usage: %prog [options]' ' GCodeFile'), option_class=inkex.InkOption) # Mode select: parser.add_option('-m', '--mode', action='store', help='Mode: vcarve, standard, laser', default='standard') # V-carve options: parser.add_option('-a', '--v_angle', action='store', help='Included (full) angle for V-bit, in degrees.', default=None) parser.add_option('-t', '--v_top', action='store', help='Stock top (usually zero)', default=None) parser.add_option('-s', '--v_step', action='store', help='Step size for curve interpolation.', default=None) # Standard options: parser.add_option('-d', '--tool_diameter', action='store', help='Tool diameter / path width.', default=None) # General options: parser.add_option('-u', '--units', action='store', help='Dialog units.', default='mm') parser.add_option('-z', '--z_axis', action='store', help='Z-axis: ignore,group,label', default=False) # Non-options to handle .inx "parameters": parser.add_option('--tab', action='store') parser.add_option('--inputhelp', action='store') # Now, process, my lovelies! (options, args) = parser.parse_args(inkex.sys.argv[1:]) # First steps first, what mode? v_carve = False ignore_z = False laser_mode = False if (options.mode == 'vcarve'): v_carve = True elif (options.mode == 'laser'): laser_mode = True # V-carve parameters. try: v_angle = round(float(options.v_angle),3) except ValueError: v_angle = 1.0 try: v_top = round(float(options.v_top) * (25.4 if (options.units == 'in') else 1.0),5) except ValueError: v_top = 0.0 try: v_step = round(float(options.v_step) * (25.4 if (options.units == 'in') else 1.0),5) except ValueError: v_step = 1.0 # Standard parameters. try: diameter = round(float(options.tool_diameter) * (25.4 if (options.units == 'in') else 1.0),3) except ValueError: diameter = 1.0 # General options. ignore_z = (options.z_axis == 'ignore') label_z = (options.z_axis == 'label') gc = ImportGCode(args[0], v_carve, laser_mode, ignore_z, label_z, diameter, v_angle, v_top, v_step) gc.doc.write(inkex.sys.stdout)