This repository has been archived on 2023-03-25. You can view files and clone it, but cannot push or open issues or pull requests.
mightyscape-0.92-deprecated/fablabchemnitz_gcode_input.py
2019-11-14 20:05:10 +01:00

683 lines
29 KiB
Python

"""
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 = ('<svg xmlns="http://www.w3.org/2000/svg"'
' width="{}mm" height="{}mm" viewBox="{} {} {} {}"/>'
).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)