flatcam/camlib.py

781 lines
26 KiB
Python

import cairo
#from string import *
#from math import *
#from random import *
#from struct import *
#import os
#import sys
from numpy import arctan2, Inf, array
from matplotlib.figure import Figure
# See: http://toblerity.org/shapely/manual.html
from shapely.geometry import Polygon, LineString, Point
from shapely.geometry import MultiPoint, MultiPolygon
from shapely.geometry import box as shply_box
from shapely.ops import cascaded_union
class Geometry:
def __init__(self):
# Units (in or mm)
self.units = 'in'
# Final geometry: MultiPolygon
self.solid_geometry = None
def isolation_geometry(self, offset):
'''
Creates contours around geometry at a given
offset distance.
'''
return self.solid_geometry.buffer(offset)
def bounds(self):
'''
Returns coordinates of rectangular bounds
of geometry: (xmin, ymin, xmax, ymax).
'''
if self.solid_geometry == None:
print "Warning: solid_geometry not computed yet."
return (0,0,0,0)
return self.solid_geometry.bounds
def size(self):
'''
Returns (width, height) of rectangular
bounds of geometry.
'''
if self.solid_geometry == None:
print "Warning: solid_geometry not computed yet."
return 0
bounds = self.bounds()
return (bounds[2]-bounds[0], bounds[3]-bounds[1])
def get_empty_area(self, boundary=None):
'''
Returns the complement of self.solid_geometry within
the given boundary polygon. If not specified, it defaults to
the rectangular bounding box of self.solid_geometry.
'''
if boundary == None:
boundary = self.solid_geometry.envelope
return boundary.difference(g.solid_geometry)
def clear_polygon(self, polygon, tooldia, overlap = 0.15):
'''
Creates geometry inside a polygon for a tool to cover
the whole area.
'''
poly_cuts = [polygon.buffer(-tooldia/2.0)]
while(1):
polygon = poly_cuts[-1].buffer(-tooldia*(1-overlap))
if polygon.area > 0:
poly_cuts.append(polygon)
else:
break
return poly_cuts
class Gerber (Geometry):
def __init__(self):
# Initialize parent
Geometry.__init__(self)
# Number format
self.digits = 3
self.fraction = 4
## Gerber elements ##
# Apertures {'id':{'type':chr,
# ['size':float], ['width':float],
# ['height':float]}, ...}
self.apertures = {}
# Paths [{'linestring':LineString, 'aperture':dict}]
self.paths = []
# Buffered Paths [Polygon]
# Paths transformed into Polygons by
# offsetting the aperture size/2
self.buffered_paths = []
# Polygon regions [{'polygon':Polygon, 'aperture':dict}]
self.regions = []
# Flashes [{'loc':[float,float], 'aperture':dict}]
self.flashes = []
# Geometry from flashes
self.flash_geometry = []
def fix_regions(self):
'''
Overwrites the region polygons with fixed
versions if found to be invalid (according to Shapely).
'''
for region in self.regions:
if region['polygon'].is_valid == False:
#polylist = fix_poly(region['polygon'])
#region['polygon'] = fix_poly3(polylist)
region['polygon'] = region['polygon'].buffer(0)
def buffer_paths(self):
self.buffered_paths = []
for path in self.paths:
width = self.apertures[path["aperture"]]["size"]
self.buffered_paths.append(path["linestring"].buffer(width/2))
def aperture_parse(self, gline):
'''
Parse gerber aperture definition
into dictionary of apertures.
'''
indexstar = gline.find("*")
indexC = gline.find("C,")
if indexC != -1: # Circle, example: %ADD11C,0.1*%
apid = gline[4:indexC]
self.apertures[apid] = {"type":"C",
"size":float(gline[indexC+2:indexstar])}
return apid
indexR = gline.find("R,")
if indexR != -1: # Rectangle, example: %ADD15R,0.05X0.12*%
apid = gline[4:indexR]
indexX = gline.find("X")
self.apertures[apid] = {"type":"R",
"width":float(gline[indexR+2:indexX]),
"height":float(gline[indexX+1:indexstar])}
return apid
indexO = gline.find("O,")
if indexO != -1: # Obround
apid = gline[4:indexO]
indexX = gline.find("X")
self.apertures[apid] = {"type":"O",
"width":float(gline[indexO+2:indexX]),
"height":float(gline[indexX+1:indexstar])}
return apid
print "WARNING: Aperture not implemented:", gline
return None
def parse_file(self, filename):
'''
Calls Gerber.parse_lines() with array of lines
read from the given file.
'''
gfile = open(filename, 'r')
gstr = gfile.readlines()
gfile.close()
self.parse_lines(gstr)
def parse_lines(self, glines):
'''
Main Gerber parser.
'''
path = [] # Coordinates of the current path
last_path_aperture = None
current_aperture = None
for gline in glines:
if gline.find("D01*") != -1: # pen down
path.append(coord(gline, self.digits, self.fraction))
last_path_aperture = current_aperture
continue
if gline.find("D02*") != -1: # pen up
if len(path) > 1:
# Path completed, create shapely LineString
self.paths.append({"linestring":LineString(path),
"aperture":last_path_aperture})
path = [coord(gline, self.digits, self.fraction)]
continue
indexD3 = gline.find("D03*")
if indexD3 > 0: # Flash
self.flashes.append({"loc":coord(gline, self.digits, self.fraction),
"aperture":current_aperture})
continue
if indexD3 == 0: # Flash?
print "WARNING: Uninplemented flash style:", gline
continue
if gline.find("G37*") != -1: # end region
# Only one path defines region?
self.regions.append({"polygon":Polygon(path),
"aperture":last_path_aperture})
path = []
continue
if gline.find("%ADD") != -1: # aperture definition
self.aperture_parse(gline) # adds element to apertures
continue
indexstar = gline.find("*")
if gline.find("D") == 0: # Aperture change
current_aperture = gline[1:indexstar]
continue
if gline.find("G54D") == 0: # Aperture change (deprecated)
current_aperture = gline[4:indexstar]
continue
if gline.find("%FS") != -1: # Format statement
indexX = gline.find("X")
self.digits = int(gline[indexX + 1])
self.fraction = int(gline[indexX + 2])
continue
print "WARNING: Line ignored:", gline
if len(path) > 1:
# EOF, create shapely LineString if something in path
self.paths.append({"linestring":LineString(path),
"aperture":last_path_aperture})
def do_flashes(self):
self.flash_geometry = []
for flash in self.flashes:
aperture = self.apertures[flash['aperture']]
if aperture['type'] == 'C': # Circles
circle = Point(flash['loc']).buffer(aperture['size']/2)
self.flash_geometry.append(circle)
continue
if aperture['type'] == 'R': # Rectangles
loc = flash['loc']
width = aperture['width']
height = aperture['height']
minx = loc[0] - width/2
maxx = loc[0] + width/2
miny = loc[1] - height/2
maxy = loc[1] + height/2
rectangle = shply_box(minx, miny, maxx, maxy)
self.flash_geometry.append(rectangle)
continue
#TODO: Add support for type='O'
print "WARNING: Aperture type %s not implemented"%(aperture['type'])
def create_geometry(self):
if len(self.buffered_paths) == 0:
self.buffer_paths()
self.fix_regions()
self.do_flashes()
self.solid_geometry = cascaded_union(
self.buffered_paths +
[poly['polygon'] for poly in self.regions] +
self.flash_geometry)
class CNCjob:
def __init__(self, units="in", kind="generic", z_move = 0.1,
feedrate = 3.0, z_cut = -0.002):
# Options
self.kind = kind
self.units = units
self.z_cut = z_cut
self.z_move = z_move
self.feedrate = feedrate
# Constants
self.unitcode = {"in": "G20", "mm": "G21"}
self.pausecode = "G04 P1"
self.feedminutecode = "G94"
self.absolutecode = "G90"
# Output G-Code
self.gcode = ""
# Bounds of geometry given to CNCjob.generate_from_geometry()
self.input_geometry_bounds = None
# Tool diameter given to CNCjob.generate_from_geometry()
self.tooldia = 0
# Output generated by CNCjob.create_gcode_geometry()
self.G_geometry = None
def generate_from_excellon(self, exobj):
'''
Generates G-code for drilling from excellon text.
self.gcode becomes a list, each element is a
different job for each tool in the excellon code.
'''
self.kind = "drill"
self.gcode = []
t = "G00 X%.4fY%.4f\n"
down = "G01 Z%.4f\n"%self.z_cut
up = "G01 Z%.4f\n"%self.z_move
for tool in exobj.tools:
points = []
gcode = ""
for drill in exobj.drill:
if drill['tool'] == tool:
points.append(drill['point'])
gcode = self.unitcode[self.units] + "\n"
gcode += self.absolutecode + "\n"
gcode += self.feedminutecode + "\n"
gcode += "F%.2f\n"%self.feedrate
gcode += "G00 Z%.4f\n"%self.z_move # Move to travel height
gcode += "M03\n" # Spindle start
gcode += self.pausecode + "\n"
for point in points:
gcode += t%point
gcode += down + up
gcode += t%(0,0)
gcode += "M05\n" # Spindle stop
self.gcode.append(gcode)
def generate_from_geometry(self, geometry, append=True, tooldia=None):
'''
Generates G-Code for geometry (Shapely collection).
'''
if tooldia == None:
tooldia = self.tooldia
else:
self.tooldia = tooldia
self.input_geometry_bounds = geometry.bounds
if append == False:
self.gcode = ""
t = "G0%d X%.4fY%.4f\n"
self.gcode = self.unitcode[self.units] + "\n"
self.gcode += self.absolutecode + "\n"
self.gcode += self.feedminutecode + "\n"
self.gcode += "F%.2f\n"%self.feedrate
self.gcode += "G00 Z%.4f\n"%self.z_move # Move to travel height
self.gcode += "M03\n" # Spindle start
self.gcode += self.pausecode + "\n"
for geo in geometry:
if type(geo) == Polygon:
path = list(geo.exterior.coords) # Polygon exterior
self.gcode += t%(0, path[0][0], path[0][1]) # Move to first point
self.gcode += "G01 Z%.4f\n"%self.z_cut # Start cutting
for pt in path[1:]:
self.gcode += t%(1, pt[0], pt[1]) # Linear motion to point
self.gcode += "G00 Z%.4f\n"%self.z_move # Stop cutting
for ints in geo.interiors: # Polygon interiors
path = list(ints.coords)
self.gcode += t%(0, path[0][0], path[0][1]) # Move to first point
self.gcode += "G01 Z%.4f\n"%self.z_cut # Start cutting
for pt in path[1:]:
self.gcode += t%(1, pt[0], pt[1]) # Linear motion to point
self.gcode += "G00 Z%.4f\n"%self.z_move # Stop cutting
continue
if type(geo) == LineString or type(geo) == LineRing:
path = list(geo.coords)
self.gcode += t%(0, path[0][0], path[0][1]) # Move to first point
self.gcode += "G01 Z%.4f\n"%self.z_cut # Start cutting
for pt in path[1:]:
self.gcode += t%(1, pt[0], pt[1]) # Linear motion to point
self.gcode += "G00 Z%.4f\n"%self.z_move # Stop cutting
continue
if type(geo) == Point:
path = list(geo.coords)
self.gcode += t%(0, path[0][0], path[0][1]) # Move to first point
self.gcode += "G01 Z%.4f\n"%self.z_cut # Start cutting
self.gcode += "G00 Z%.4f\n"%self.z_move # Stop cutting
continue
print "WARNING: G-code generation not implemented for %s"%(str(type(geo)))
self.gcode += "G00 Z%.4f\n"%self.z_move # Stop cutting
self.gcode += "G00 X0Y0\n"
self.gcode += "M05\n" # Spindle stop
def create_gcode_geometry(self):
'''
G-Code parser (from self.gcode). Generates dictionary with
single-segment LineString's and "kind" indicating cut or travel,
fast or feedrate speed.
'''
geometry = []
# TODO: ???? bring this into the class??
gobjs = gparse1b(self.gcode)
# Last known instruction
current = {'X': 0.0, 'Y': 0.0, 'Z': 0.0, 'G': 0}
# Process every instruction
for gobj in gobjs:
if 'Z' in gobj:
if ('X' in gobj or 'Y' in gobj) and gobj['Z'] != current['Z']:
print "WARNING: Non-orthogonal motion: From", current
print " To:", gobj
current['Z'] = gobj['Z']
if 'G' in gobj:
current['G'] = gobj['G']
if 'X' in gobj or 'Y' in gobj:
x = 0
y = 0
kind = ["C","F"] # T=travel, C=cut, F=fast, S=slow
if 'X' in gobj:
x = gobj['X']
else:
x = current['X']
if 'Y' in gobj:
y = gobj['Y']
else:
y = current['Y']
if current['Z'] > 0:
kind[0] = 'T'
if current['G'] == 1:
kind[1] = 'S'
geometry.append({'geom':LineString([(current['X'],current['Y']),
(x,y)]), 'kind':kind})
# Update current instruction
for code in gobj:
current[code] = gobj[code]
self.G_geometry = geometry
return geometry
def plot(self, tooldia=None, dpi=75, margin=0.1,
color={"T":["#F0E24D", "#B5AB3A"], "C":["#5E6CFF", "#4650BD"]},
alpha={"T":0.3, "C":1.0}):
'''
Creates a Matplotlib figure with a plot of the
G-code job.
'''
if tooldia == None:
tooldia = self.tooldia
fig = Figure(dpi=dpi)
ax = fig.add_subplot(111)
ax.set_aspect(1)
xmin, ymin, xmax, ymax = self.input_geometry_bounds
ax.set_xlim(xmin-margin, xmax+margin)
ax.set_ylim(ymin-margin, ymax+margin)
if tooldia == 0:
for geo in self.G_geometry:
linespec = '--'
linecolor = color[geo['kind'][0]][1]
if geo['kind'][0] == 'C':
linespec = 'k-'
x, y = geo['geom'].coords.xy
ax.plot(x, y, linespec, color=linecolor)
else:
for geo in self.G_geometry:
poly = geo['geom'].buffer(tooldia/2.0)
patch = PolygonPatch(poly, facecolor=color[geo['kind'][0]][0],
edgecolor=color[geo['kind'][0]][1],
alpha=alpha[geo['kind'][0]], zorder=2)
ax.add_patch(patch)
return fig
class Excellon(Geometry):
def __init__(self):
Geometry.__init__(self)
self.tools = {}
self.drills = []
def parse_file(self, filename):
efile = open(filename, 'r')
estr = efile.readlines()
efile.close()
self.parse_lines(estr)
def parse_lines(self, elines):
'''
Main Excellon parser.
'''
current_tool = ""
for eline in elines:
## Tool definitions ##
# TODO: Verify all this
indexT = eline.find("T")
indexC = eline.find("C")
indexF = eline.find("F")
# Type 1
if indexT != -1 and indexC > indexT and indexF > indexF:
tool = eline[1:indexC]
spec = eline[indexC+1:indexF]
self.tools[tool] = spec
continue
# Type 2
# TODO: Is this inches?
#indexsp = eline.find(" ")
#indexin = eline.find("in")
#if indexT != -1 and indexsp > indexT and indexin > indexsp:
# tool = eline[1:indexsp]
# spec = eline[indexsp+1:indexin]
# self.tools[tool] = spec
# continue
# Type 3
if indexT != -1 and indexC > indexT:
tool = eline[1:indexC]
spec = eline[indexC+1:-1]
self.tools[tool] = spec
continue
## Tool change
if indexT == 0:
current_tool = eline[1:-1]
continue
## Drill
indexX = eline.find("X")
indexY = eline.find("Y")
if indexX != -1 and indexY != -1:
x = float(int(eline[indexX+1:indexY])/10000.0)
y = float(int(eline[indexY+1:-1])/10000.0)
self.drills.append({'point':Point((x,y)), 'tool':current_tool})
continue
print "WARNING: Line ignored:", eline
def create_geometry(self):
self.solid_geometry = []
sizes = {}
for tool in self.tools:
sizes[tool] = float(self.tools[tool])
for drill in self.drills:
poly = Point(drill['point']).buffer(sizes[drill['tool']]/2.0)
self.solid_geometry.append(poly)
self.solid_geometry = cascaded_union(self.solid_geometry)
class motion:
'''
Represents a machine motion, which can be cutting or just travelling.
'''
def __init__(self, start, end, depth, typ='line', offset=None, center=None,
radius=None, tooldia=0.5):
self.typ = typ
self.start = start
self.end = end
self.depth = depth
self.center = center
self.radius = radius
self.tooldia = tooldia
self.offset = offset # (I, J)
def gparse1(filename):
'''
Parses G-code file into list of dictionaries like
Examples: {'G': 1.0, 'X': 0.085, 'Y': -0.125},
{'G': 3.0, 'I': -0.01, 'J': 0.0, 'X': 0.0821, 'Y': -0.1179}
'''
f = open(filename)
gcmds = []
for line in f:
line = line.strip()
# Remove comments
# NOTE: Limited to 1 bracket pair
op = line.find("(")
cl = line.find(")")
if op > -1 and cl > op:
#comment = line[op+1:cl]
line = line[:op] + line[(cl+1):]
# Parse GCode
# 0 4 12
# G01 X-0.007 Y-0.057
# --> codes_idx = [0, 4, 12]
codes = "NMGXYZIJFP"
codes_idx = []
i = 0
for ch in line:
if ch in codes:
codes_idx.append(i)
i += 1
n_codes = len(codes_idx)
if n_codes == 0:
continue
# Separate codes in line
parts = []
for p in range(n_codes-1):
parts.append( line[ codes_idx[p]:codes_idx[p+1] ].strip() )
parts.append( line[codes_idx[-1]:].strip() )
# Separate codes from values
cmds = {}
for part in parts:
cmds[part[0]] = float(part[1:])
gcmds.append(cmds)
f.close()
return gcmds
def gparse1b(gtext):
gcmds = []
lines = gtext.split("\n")
for line in lines:
line = line.strip()
# Remove comments
# NOTE: Limited to 1 bracket pair
op = line.find("(")
cl = line.find(")")
if op > -1 and cl > op:
#comment = line[op+1:cl]
line = line[:op] + line[(cl+1):]
# Parse GCode
# 0 4 12
# G01 X-0.007 Y-0.057
# --> codes_idx = [0, 4, 12]
codes = "NMGXYZIJFP"
codes_idx = []
i = 0
for ch in line:
if ch in codes:
codes_idx.append(i)
i += 1
n_codes = len(codes_idx)
if n_codes == 0:
continue
# Separate codes in line
parts = []
for p in range(n_codes-1):
parts.append( line[ codes_idx[p]:codes_idx[p+1] ].strip() )
parts.append( line[codes_idx[-1]:].strip() )
# Separate codes from values
cmds = {}
for part in parts:
cmds[part[0]] = float(part[1:])
gcmds.append(cmds)
return gcmds
def gparse2(gcmds):
x = []
y = []
z = []
xypoints = []
motions = []
current_g = None
for cmds in gcmds:
# Destination point
x_ = None
y_ = None
z_ = None
if 'X' in cmds:
x_ = cmds['X']
x.append(x_)
if 'Y' in cmds:
y_ = cmds['Y']
y.append(y_)
if 'Z' in cmds:
z_ = cmds['Z']
z.append(z_)
# Ingnore anything but XY movements from here on
if x_ is None and y_ is None:
#print "-> no x,y"
continue
if x_ is None:
x_ = xypoints[-1][0]
if y_ is None:
y_ = xypoints[-1][1]
if z_ is None:
z_ = z[-1]
mot = None
if 'G' in cmds:
current_g = cmds['G']
if current_g == 0: # Fast linear
if len(xypoints) > 0:
#print "motion(", xypoints[-1], ", (", x_, ",", y_, "),", z_, ")"
mot = motion(xypoints[-1], (x_, y_), z_)
if current_g == 1: # Feed-rate linear
if len(xypoints) > 0:
#print "motion(", xypoints[-1], ", (", x_, ",", y_, "),", z_, ")"
mot = motion(xypoints[-1], (x_, y_), z_)
if current_g == 2: # Clockwise arc
if len(xypoints) > 0:
if 'I' in cmds and 'J' in cmds:
mot = motion(xypoints[-1], (x_, y_), z_, offset=(cmds['I'],
cmds['J']), typ='arccw')
if current_g == 3: # Counter-clockwise arc
if len(xypoints) > 0:
if 'I' in cmds and 'J' in cmds:
mot = motion(xypoints[-1], (x_, y_), z_, offset=(cmds['I'],
cmds['J']), typ='arcacw')
if mot is not None:
motions.append(mot)
xypoints.append((x_, y_))
x = array(x)
y = array(y)
z = array(z)
xmin = min(x)
xmax = max(x)
ymin = min(y)
ymax = max(y)
print "x:", min(x), max(x)
print "y:", min(y), max(y)
print "z:", min(z), max(z)
print xypoints[-1]
return xmin, xmax, ymin, ymax, motions
############### cam.py ####################
def coord(gstr,digits,fraction):
'''
Parse Gerber coordinates
'''
global gerbx, gerby
xindex = gstr.find("X")
yindex = gstr.find("Y")
index = gstr.find("D")
if (xindex == -1):
x = gerbx
y = int(gstr[(yindex+1):index])*(10**(-fraction))
elif (yindex == -1):
y = gerby
x = int(gstr[(xindex+1):index])*(10**(-fraction))
else:
x = int(gstr[(xindex+1):yindex])*(10**(-fraction))
y = int(gstr[(yindex+1):index])*(10**(-fraction))
gerbx = x
gerby = y
return [x,y]
################ end of cam.py #############