at-tami/gerber/gerbmerge/bin/jobs.py

1289 lines
51 KiB
Python
Raw Normal View History

2015-07-23 02:43:01 +03:00
#!/usr/bin/env python
"""
This module reads all Gerber and Excellon files and stores the
data for each job.
--------------------------------------------------------------------
This program is licensed under the GNU General Public License (GPL)
Version 3. See http://www.fsf.org for details of the license.
Rugged Circuits LLC
http://ruggedcircuits.com/gerbmerge
"""
import sys
import re
import string
import __builtin__
import copy
import types
import aptable
import config
import makestroke
import amacro
import geometry
import util
# Parsing Gerber/Excellon files is currently very brittle. A more robust
# RS274X/Excellon parser would be a good idea and allow this program to work
# robustly with more than just Eagle CAM files.
# Reminder to self:
#
# D01 -- move and draw line with exposure on
# D02 -- move with exposure off
# D03 -- flash aperture
# Patterns for Gerber RS274X file interpretation
apdef_pat = re.compile(r'^%AD(D\d+)([^*$]+)\*%$') # Aperture definition
apmdef_pat = re.compile(r'^%AM([^*$]+)\*$') # Aperture macro definition
comment_pat = re.compile(r'G0?4[^*]*\*') # Comment (GerbTool comment omits the 0)
tool_pat = re.compile(r'(D\d+)\*') # Aperture selection
gcode_pat = re.compile(r'G(\d{1,2})\*?') # G-codes
drawXY_pat = re.compile(r'X([+-]?\d+)Y([+-]?\d+)D0?([123])\*') # Drawing command
drawX_pat = re.compile(r'X([+-]?\d+)D0?([123])\*') # Drawing command, Y is implied
drawY_pat = re.compile(r'Y([+-]?\d+)D0?([123])\*') # Drawing command, X is implied
format_pat = re.compile(r'%FS(L|T)?(A|I)(N\d+)?(X\d\d)(Y\d\d)\*%') # Format statement
layerpol_pat = re.compile(r'^%LP[CD]\*%') # Layer polarity (D=dark, C=clear)
# Circular interpolation drawing commands (from Protel)
cdrawXY_pat = re.compile(r'X([+-]?\d+)Y([+-]?\d+)I([+-]?\d+)J([+-]?\d+)D0?([123])\*')
cdrawX_pat = re.compile(r'X([+-]?\d+)I([+-]?\d+)J([+-]?\d+)D0?([123])\*') # Y is implied
cdrawY_pat = re.compile(r'Y([+-]?\d+)I([+-]?\d+)J([+-]?\d+)D0?([123])\*') # X is implied
IgnoreList = ( \
# These are for Eagle, and RS274X files in general
re.compile(r'^%OFA0B0\*%$'),
re.compile(r'^%IPPOS\*%'),
re.compile(r'^%AMOC8\*$'), # Eagle's octagon defined by macro with a $1 parameter
re.compile(r'^5,1,8,0,0,1\.08239X\$1,22\.5\*$'), # Eagle's octagon, 22.5 degree rotation
re.compile(r'^5,1,8,0,0,1\.08239X\$1,0\.0\*$'), # Eagle's octagon, 0.0 degree rotation
re.compile(r'^\*?%$'),
re.compile(r'^M0?2\*$'),
# These additional ones are for Orcad Layout, PCB, Protel, etc.
re.compile(r'\*'), # Empty statement
re.compile(r'^%IN.*\*%'),
re.compile(r'^%ICAS\*%'), # Not in RS274X spec.
re.compile(r'^%MOIN\*%'),
re.compile(r'^%ASAXBY\*%'),
re.compile(r'^%AD\*%'), # GerbTool empty aperture definition
re.compile(r'^%LN.*\*%') # Layer name
)
# Patterns for Excellon interpretation
xtool_pat = re.compile(r'^(T\d+)$') # Tool selection
xydraw_pat = re.compile(r'^X([+-]?\d+)Y([+-]?\d+)$') # Plunge command
xdraw_pat = re.compile(r'^X([+-]?\d+)$') # Plunge command, repeat last Y value
ydraw_pat = re.compile(r'^Y([+-]?\d+)$') # Plunge command, repeat last X value
xtdef_pat = re.compile(r'^(T\d+)(?:F\d+)?(?:S\d+)?C([0-9.]+)$') # Tool+diameter definition with optional
# feed/speed (for Protel)
xtdef2_pat = re.compile(r'^(T\d+)C([0-9.]+)(?:F\d+)?(?:S\d+)?$') # Tool+diameter definition with optional
# feed/speed at the end (for OrCAD)
xzsup_pat = re.compile(r'^INCH,([LT])Z$') # Leading/trailing zeros INCLUDED
XIgnoreList = ( \
re.compile(r'^%$'),
re.compile(r'^M30$'), # End of job
re.compile(r'^M48$'), # Program header to first %
re.compile(r'^M72$') # Inches
)
# A Job is a single input board. It is expected to have:
# - a board outline file in RS274X format
# - several (at least one) Gerber files in RS274X format
# - a drill file in Excellon format
#
# The board outline and Excellon filenames must be given separately.
# The board outline file determines the extents of the job.
class Job:
def __init__(self, name):
self.name = name
# Minimum and maximum (X,Y) absolute co-ordinates encountered
# in GERBER data only (not Excellon). Note that coordinates
# are stored in hundred-thousandsths of an inch so 9999999 is 99.99999
# inches.
self.maxx = self.maxy = -9999999 # in the case all coordinates are < 0, this will prevent maxx and maxy from defaulting to 0
self.minx = self.miny = 9999999
# Aperture translation table relative to GAT. This dictionary
# has as each key a layer name for the job. Each key's value
# is itself a dictionary where each key is an aperture in the file.
# The value is the key in the GAT. Example:
# apxlat['TopCopper']['D10'] = 'D12'
# apxlat['TopCopper']['D11'] = 'D15'
# apxlat['BottomCopper']['D10'] = 'D15'
self.apxlat = {}
# Aperture macro translation table relative to GAMT. This dictionary
# has as each key a layer name for the job. Each key's value
# is itself a dictionary where each key is an aperture macro name in the file.
# The value is the key in the GAMT. Example:
# apxlat['TopCopper']['THD10X'] = 'M1'
# apxlat['BottomCopper']['AND10'] = 'M5'
self.apmxlat = {}
# Commands are one of:
# A. strings for:
# - aperture changes like "D12"
# - G-code commands like "G36"
# - RS-274X commands like "%LPD*%" that begin with '%'
# B. (X,Y,D) triples comprising X,Y integers in the range 0 through 999999
# and draw commands that are either D01, D02, or D03. The character
# D in the triple above is the integer 1, 2, or 3.
# C. (X,Y,I,J,D,s) 6-tuples comprising X,Y,I,J integers in the range 0 through 999999
# and D as with (X,Y,D) triples. The 's' integer is non-zero to indicate that
# the (I,J) tuple is a SIGNED offset (for multi-quadrant circular interpolation)
# else the tuple is unsigned.
#
# This variable is, as for apxlat, a dictionary keyed by layer name.
self.commands = {}
# This dictionary stores all GLOBAL apertures actually needed by this
# layer, i.e., apertures specified prior to draw commands. The dictionary
# is indexed by layer name, and each dictionary entry is a list of aperture
# code strings, like 'D12'. This dictionary helps us to figure out the
# minimum number of apertures that need to be written out in the Gerber
# header of the merged file. Once again, the list of apertures refers to
# GLOBAL aperture codes in the GAT, not ones local to this layer.
self.apertures = {}
# Excellon commands are grouped by tool number in a dictionary.
# This is to help sorting all jobs and writing out all plunge
# commands for a single tool.
#
# The key to this dictionary is the full tool name, e.g., T03
# as a string. Each command is an (X,Y) integer tuple.
self.xcommands = {}
# This is a dictionary mapping LOCAL tool names (e.g., T03) to diameters
# in inches for THIS JOB. This dictionary will be initially empty
# for old-style Excellon files with no embedded tool sizes. The
# main program will construct this dictionary from the global tool
# table in this case, once all jobs have been read in.
self.xdiam = {}
# This is a mapping from tool name to diameter for THIS JOB
self.ToolList = None
# How many times to replicate this job if using auto-placement
self.Repeat = 1
# How many decimal digits of precision there are in the Excellon file.
# A value greater than 0 overrides the global ExcellonDecimals setting
# for this file, allowing jobs with different Excellon decimal settings
# to be combined.
self.ExcellonDecimals = 0 # 0 means global value prevails
def width_in(self):
"Return width in INCHES"
return float(self.maxx-self.minx)*0.00001
def height_in(self):
"Return height in INCHES"
return float(self.maxy-self.miny)*0.00001
def jobarea(self):
return self.width_in()*self.height_in()
def maxdimension(self):
return max(self.width_in(),self.height_in())
def mincoordinates(self):
"Return minimum X and Y coordinate"
return self.minx, self.miny
def fixcoordinates(self, x_shift, y_shift):
"Add x_shift and y_shift to all coordinates in the job"
# Shift maximum and minimum coordinates
self.minx += x_shift
self.maxx += x_shift
self.miny += y_shift
self.maxy += y_shift
# Shift all commands
for layer, command in self.commands.iteritems():
# Loop through each command in each layer
for index in range( len(command) ):
c = command[index]
# Shift X and Y coordinate of command
if type(c) == types.TupleType: ## ensure that command is of type tuple
command_list = list(c) ## convert tuple to list
if (type( command_list[0] ) == types.IntType) \
and (type( command_list[1] ) == types.IntType): ## ensure that first two elemenst are integers
command_list[0] += x_shift
command_list[1] += y_shift
command[index] = tuple(command_list) ## convert list back to tuple
self.commands[layer] = command ## set modified command
# Shift all excellon commands
for tool, command in self.xcommands.iteritems():
# Loop through each command in each layer
for index in range( len(command) ):
c = command[index]
# Shift X and Y coordinate of command
command_list = list(c) ## convert tuple to list
if ( type( command_list[0] ) == types.IntType ) \
and ( type( command_list[1] ) == types.IntType ): ## ensure that first two elemenst are integers
command_list[0] += x_shift / 10
command_list[1] += y_shift / 10
command[index] = tuple(command_list) ## convert list back to tuple
self.xcommands[tool] = command ## set modified command
def parseGerber(self, fullname, layername, updateExtents = 0):
"""Do the dirty work. Read the Gerber file given the
global aperture table GAT and global aperture macro table GAMT"""
GAT = config.GAT
GAMT = config.GAMT
# First construct reverse GAT/GAMT, mapping definition to code
RevGAT = config.buildRevDict(GAT) # RevGAT[hash] = aperturename
RevGAMT = config.buildRevDict(GAMT) # RevGAMT[hash] = aperturemacroname
#print 'Reading data from %s ...' % fullname
fid = file(fullname, 'rt')
currtool = None
self.apxlat[layername] = {}
self.apmxlat[layername] = {}
self.commands[layername] = []
self.apertures[layername] = []
# These divisors are used to scale (X,Y) co-ordinates. We store
# everything as integers in hundred-thousandths of an inch (i.e., M.5
# format). If we get something in M.4 format, we must multiply by
# 10. If we get something in M.6 format we must divide by 10, etc.
x_div = 1.0
y_div = 1.0
# Drawing commands can be repeated with X or Y omitted if they are
# the same as before. These variables store the last X/Y value as
# integers in hundred-thousandths of an inch.
last_x = last_y = 0
# Last modal G-code. Some G-codes introduce "modes", such as circular interpolation
# mode, and we want to remember what mode we're in. We're interested in:
# G01 -- linear interpolation, cancels all circular interpolation modes
# G36 -- Turn on polygon area fill
# G37 -- Turn off polygon area fill
last_gmode = 1 # G01 by default, linear interpolation
# We want to know whether to do signed (G75) or unsigned (G74) I/J offsets. These
# modes are independent of G01/G02/G03, e.g., Protel will issue multiple G03/G01
# codes all in G75 mode.
# G74 -- Single-quadrant circular interpolation (disables multi-quadrant interpolation)
# G02/G03 codes set clockwise/counterclockwise arcs in a single quadrant only
# using X/Y/I/J commands with UNSIGNED (I,J).
# G75 -- Multi-quadrant circular interpolation --> X/Y/I/J with signed (I,J)
# G02/G03 codes set clockwise/counterclockwise arcs in all 4 quadrants
# using X/Y/I/J commands with SIGNED (I,J).
circ_signed = True # Assume G75...make sure this matches canned header we write out
# If the very first flash/draw is a shorthand command (i.e., without an Xxxxx or Yxxxx)
# component then we don't really "see" the first point X00000Y00000. To account for this
# we use the following Boolean flag as well as the isLastShorthand flag during parsing
# to manually insert the point X000000Y00000 into the command stream.
firstFlash = True
for line in fid:
# Get rid of CR characters (0x0D) and leading/trailing blanks
line = string.replace(line, '\x0D', '').strip()
# Old location of format_pat search. Now moved down into the sub-line parse loop below.
# RS-274X statement? If so, echo it. Currently, only the "LP" statement is expected
# (from Protel, of course). These will be distinguished from D-code and G-code
# commands by the fact that the first character of the string is '%'.
match = layerpol_pat.match(line)
if match:
self.commands[layername].append(line)
continue
# See if this is an aperture definition, and if so, map it.
match = apdef_pat.match(line)
if match:
if currtool:
raise RuntimeError, "File %s has an aperture definition that comes after drawing commands." % fullname
A = aptable.parseAperture(line, self.apmxlat[layername])
if not A:
raise RuntimeError, "Unknown aperture definition in file %s" % fullname
hash = A.hash()
if not RevGAT.has_key(hash):
#print line
#print self.apmxlat
#print RevGAT
raise RuntimeError, 'File %s has aperture definition "%s" not in global aperture table.' % (fullname, hash)
# This says that all draw commands with this aperture code will
# be replaced by aperture self.apxlat[layername][code].
self.apxlat[layername][A.code] = RevGAT[hash]
continue
# Ignore %AMOC8* from Eagle for now as it uses a macro parameter, which
# is not yet supported in GerbMerge.
if line[:7]=='%AMOC8*':
continue
# See if this is an aperture macro definition, and if so, map it.
M = amacro.parseApertureMacro(line,fid)
if M:
if currtool:
raise RuntimeError, "File %s has an aperture macro definition that comes after drawing commands." % fullname
hash = M.hash()
if not RevGAMT.has_key(hash):
raise RuntimeError, 'File %s has aperture macro definition not in global aperture macro table:\n%s' % (fullname, hash)
# This says that all aperture definition commands that reference this macro name
# will be replaced by aperture macro name self.apmxlat[layername][macroname].
self.apmxlat[layername][M.name] = RevGAMT[hash]
continue
# From this point on we may have more than one match on this line, e.g.:
# G54D11*X22400Y22300D02*X22500Y22200D01*
sub_line = line
while sub_line:
# Handle "comment" G-codes first
match = comment_pat.match(sub_line)
if match:
sub_line = sub_line[match.end():]
continue
# See if this is a format statement, and if so, map it. In version 1.3 this was moved down
# from the line-only parse checks above (see comment) to handle OrCAD lines like
# G74*%FSLAN2X34Y34*%
match = format_pat.match(sub_line) # Used to be format_pat.search
if match:
sub_line = sub_line[match.end():]
for item in match.groups():
if item is None: continue # Optional group didn't match
if item[0] in "LA": # omit leading zeroes and absolute co-ordinates
continue
if item[0]=='T': # omit trailing zeroes
raise RuntimeError, "Trailing zeroes not supported in RS274X files"
if item[0]=='I': # incremental co-ordinates
raise RuntimeError, "Incremental co-ordinates not supported in RS274X files"
if item[0]=='N': # Maximum digits for N* commands...ignore it
continue
if item[0]=='X': # M.N specification for X-axis.
fracpart = int(item[2])
x_div = 10.0**(5-fracpart)
if item[0]=='Y': # M.N specification for Y-axis.
fracpart = int(item[2])
y_div = 10.0**(5-fracpart)
continue
# Parse and interpret G-codes
match = gcode_pat.match(sub_line)
if match:
sub_line = sub_line[match.end():]
gcode = int(match.group(1))
# Determine if this is a G-Code that should be ignored because it has no effect
# (e.g., G70 specifies "inches" which is already in effect).
if gcode in [54, 70, 90]:
continue
# Determine if this is a G-Code that we have to emit because it matters.
if gcode in [1, 2, 3, 36, 37, 74, 75]:
self.commands[layername].append("G%02d" % gcode)
# Determine if this is a G-code that sets a new mode
if gcode in [1, 36, 37]:
last_gmode = gcode
# Remember last G74/G75 code so we know whether to do signed or unsigned I/J
# offsets.
if gcode==74:
circ_signed = False
elif gcode==75:
circ_signed = True
continue
raise RuntimeError, "G-Code 'G%02d' is not supported" % gcode
# See if this is a tool change (aperture change) command
match = tool_pat.match(sub_line)
if match:
currtool = match.group(1)
# Protel likes to issue random D01, D02, and D03 commands instead of aperture
# codes. We can ignore D01 because it simply means to move to the current location
# while drawing. Well, that's drawing a point. We can ignore D02 because it means
# to move to the current location without drawing. Truly pointless. We do NOT want
# to ignore D03 because it implies a flash. Protel very inefficiently issues a D02
# move to a location without drawing, then a single-line D03 to flash. However, a D02
# terminates a polygon in G36 mode, so keep D02's in this case.
if currtool=='D01' or (currtool=='D02' and (last_gmode != 36)):
sub_line = sub_line[match.end():]
continue
if (currtool == 'D03') or (currtool=='D02' and (last_gmode == 36)):
self.commands[layername].append(currtool)
sub_line = sub_line[match.end():]
continue
# Map it using our translation table
if not self.apxlat[layername].has_key(currtool):
raise RuntimeError, 'File %s has tool change command "%s" with no corresponding translation' % (fullname, currtool)
currtool = self.apxlat[layername][currtool]
# Add it to the list of things to write out
self.commands[layername].append(currtool)
# Add it to the list of all apertures needed by this layer
self.apertures[layername].append(currtool)
# Move on to next match, if any
sub_line = sub_line[match.end():]
continue
# Is it a simple draw command?
I = J = None # For circular interpolation drawing commands
match = drawXY_pat.match(sub_line)
isLastShorthand = False # By default assume we don't make use of last_x and last_y
if match:
x, y, d = map(__builtin__.int, match.groups())
else:
match = drawX_pat.match(sub_line)
if match:
x, d = map(__builtin__.int, match.groups())
y = last_y
isLastShorthand = True # Indicate we're making use of last_x/last_y
else:
match = drawY_pat.match(sub_line)
if match:
y, d = map(__builtin__.int, match.groups())
x = last_x
isLastShorthand = True # Indicate we're making use of last_x/last_y
# Maybe it's a circular interpolation draw command with IJ components
if match is None:
match = cdrawXY_pat.match(sub_line)
if match:
x, y, I, J, d = map(__builtin__.int, match.groups())
else:
match = cdrawX_pat.match(sub_line)
if match:
x, I, J, d = map(__builtin__.int, match.groups())
y = last_y
isLastShorthand = True # Indicate we're making use of last_x/last_y
else:
match = cdrawY_pat.match(sub_line)
if match:
y, I, J, d = map(__builtin__.int, match.groups())
x = last_x
isLastShorthand = True # Indicate we're making use of last_x/last_y
if match:
if currtool is None:
# It's OK if this is an exposure-off movement command (specified with D02).
# It's also OK if we're in the middle of a G36 polygon fill as we're only defining
# the polygon extents.
if (d != 2) and (last_gmode != 36):
raise RuntimeError, 'File %s has draw command %s with no aperture chosen' % (fullname, sub_line)
# Save last_x/y BEFORE scaling to 2.5 format else subsequent single-ordinate
# flashes (e.g., Y with no X) will be scaled twice!
last_x = x
last_y = y
# Corner case: if this is the first flash/draw and we are using shorthand (i.e., missing Xxxx
# or Yxxxxx) then prepend the point X0000Y0000 into the commands as it is actually the starting
# point of our layer. We prepend the command X0000Y0000D02, i.e., a move to (0,0) without drawing.
if (isLastShorthand and firstFlash):
self.commands[layername].append((0,0,2))
if updateExtents:
self.minx = min(self.minx,0)
self.maxx = max(self.maxx,0)
self.miny = min(self.miny,0)
self.maxy = max(self.maxy,0)
x = int(round(x*x_div))
y = int(round(y*y_div))
if I is not None:
I = int(round(I*x_div))
J = int(round(J*y_div))
self.commands[layername].append((x,y,I,J,d,circ_signed))
else:
self.commands[layername].append((x,y,d))
firstFlash = False
# Update dimensions...this is complicated for circular interpolation commands
# that span more than one quadrant. For now, we ignore this problem since users
# should be using a border layer to indicate extents.
if updateExtents:
if x < self.minx: self.minx = x
if x > self.maxx: self.maxx = x
if y < self.miny: self.miny = y
if y > self.maxy: self.maxy = y
# Move on to next match, if any
sub_line = sub_line[match.end():]
continue
# If it's none of the above, it had better be on our ignore list.
for pat in IgnoreList:
match = pat.match(sub_line)
if match:
break
else:
raise RuntimeError, 'File %s has uninterpretable line:\n %s' % (fullname, line)
sub_line = sub_line[match.end():]
# end while still things to match on this line
# end of for each line in file
fid.close()
if 0:
print layername
print self.commands[layername]
def parseExcellon(self, fullname):
#print 'Reading data from %s ...' % fullname
fid = file(fullname, 'rt')
currtool = None
suppress_leading = True # Suppress leading zeros by default, equivalent to 'INCH,TZ'
# We store Excellon X/Y data in ten-thousandths of an inch. If the Config
# option ExcellonDecimals is not 4, we must adjust the values read from the
# file by a divisor to convert to ten-thousandths. This is only used in
# leading-zero suppression mode. In trailing-zero suppression mode, we must
# trailing-zero-pad all input integers to M+N digits (e.g., 6 digits for 2.4 mode)
# specified by the 'zeropadto' variable.
if self.ExcellonDecimals > 0:
divisor = 10.0**(4 - self.ExcellonDecimals)
zeropadto = 2+self.ExcellonDecimals
else:
divisor = 10.0**(4 - config.Config['excellondecimals'])
zeropadto = 2+config.Config['excellondecimals']
# Protel takes advantage of optional X/Y components when the previous one is the same,
# so we have to remember them.
last_x = last_y = 0
# Helper function to convert X/Y strings into integers in units of ten-thousandth of an inch.
def xln2tenthou(L, divisor=divisor, zeropadto=zeropadto):
V = []
for s in L:
if not suppress_leading:
s = s + '0'*(zeropadto-len(s))
V.append(int(round(int(s)*divisor)))
return tuple(V)
for line in fid.xreadlines():
# Get rid of CR characters
line = string.replace(line, '\x0D', '')
# Protel likes to embed comment lines beginning with ';'
if line[0]==';':
continue
# Check for leading/trailing zeros included ("INCH,LZ" or "INCH,TZ")
match = xzsup_pat.match(line)
if match:
if match.group(1)=='L':
# LZ --> Leading zeros INCLUDED
suppress_leading = False
else:
# TZ --> Trailing zeros INCLUDED
suppress_leading = True
continue
# See if a tool is being defined. First try to match with tool name+size
match = xtdef_pat.match(line) # xtdef_pat and xtdef2_pat expect tool name and diameter
if match is None: # but xtdef_pat expects optional feed/speed between T and C
match = xtdef2_pat.match(line) # and xtdef_2pat expects feed/speed at the end
if match:
currtool, diam = match.groups()
try:
diam = float(diam)
except:
raise RuntimeError, "File %s has illegal tool diameter '%s'" % (fullname, diam)
# Canonicalize tool number because Protel (of course) sometimes specifies it
# as T01 and sometimes as T1. We canonicalize to T01.
currtool = 'T%02d' % int(currtool[1:])
if self.xdiam.has_key(currtool):
raise RuntimeError, "File %s defines tool %s more than once" % (fullname, currtool)
self.xdiam[currtool] = diam
continue
# Didn't match TxxxCyyy. It could be a tool change command 'Tdd'.
match = xtool_pat.match(line)
if match:
currtool = match.group(1)
# Canonicalize tool number because Protel (of course) sometimes specifies it
# as T01 and sometimes as T1. We canonicalize to T01.
currtool = 'T%02d' % int(currtool[1:])
# Diameter will be obtained from embedded tool definition, local tool list or if not found, the global tool list
try:
diam = self.xdiam[currtool]
except:
if self.ToolList:
try:
diam = self.ToolList[currtool]
except:
raise RuntimeError, "File %s uses tool code %s that is not defined in the job's tool list" % (fullname, currtool)
else:
try:
diam = config.DefaultToolList[currtool]
except:
#print config.DefaultToolList
raise RuntimeError, "File %s uses tool code %s that is not defined in default tool list" % (fullname, currtool)
self.xdiam[currtool] = diam
continue
# Plunge command?
match = xydraw_pat.match(line)
if match:
x, y = xln2tenthou(match.groups())
else:
match = xdraw_pat.match(line)
if match:
x = xln2tenthou(match.groups())[0]
y = last_y
else:
match = ydraw_pat.match(line)
if match:
y = xln2tenthou(match.groups())[0]
x = last_x
if match:
if currtool is None:
raise RuntimeError, 'File %s has plunge command without previous tool selection' % fullname
try:
self.xcommands[currtool].append((x,y))
except KeyError:
self.xcommands[currtool] = [(x,y)]
last_x = x
last_y = y
continue
# It had better be an ignorable
for pat in XIgnoreList:
if pat.match(line):
break
else:
raise RuntimeError, 'File %s has uninterpretable line:\n %s' % (fullname, line)
def hasLayer(self, layername):
return self.commands.has_key(layername)
def writeGerber(self, fid, layername, Xoff, Yoff):
"Write out the data such that the lower-left corner of this job is at the given (X,Y) position, in inches"
# Maybe we don't have this layer
if not self.hasLayer(layername): return
# First convert given inches to 2.5 co-ordinates
X = int(round(Xoff/0.00001))
Y = int(round(Yoff/0.00001))
# Now calculate displacement for each position so that we end up at specified origin
DX = X - self.minx
DY = Y - self.miny
# Rock and roll. First, write out a dummy flash using code D02
# (exposure off). This prevents an unintentional draw from the end
# of one job to the beginning of the next when a layer is repeated
# due to panelizing.
fid.write('X%07dY%07dD02*\n' % (X, Y))
for cmd in self.commands[layername]:
if type(cmd) is types.TupleType:
if len(cmd)==3:
x, y, d = cmd
fid.write('X%07dY%07dD%02d*\n' % (x+DX, y+DY, d))
else:
x, y, I, J, d, s = cmd
fid.write('X%07dY%07dI%07dJ%07dD%02d*\n' % (x+DX, y+DY, I, J, d)) # I,J are relative
else:
# It's an aperture change, G-code, or RS274-X command that begins with '%'. If
# it's an aperture code, the aperture has already been translated
# to the global aperture table during the parse phase.
if cmd[0]=='%':
fid.write('%s\n' % cmd) # The command already has a * in it (e.g., "%LPD*%")
else:
fid.write('%s*\n' % cmd)
def findTools(self, diameter):
"Find the tools, if any, with the given diameter in inches. There may be more than one!"
L = []
for tool, diam in self.xdiam.items():
if diam==diameter:
L.append(tool)
return L
def writeExcellon(self, fid, diameter, Xoff, Yoff):
"Write out the data such that the lower-left corner of this job is at the given (X,Y) position, in inches"
# First convert given inches to 2.4 co-ordinates. Note that Gerber is 2.5 (as of GerbMerge 1.2)
# and our internal Excellon representation is 2.4 as of GerbMerge
# version 0.91. We use X,Y to calculate DX,DY in 2.4 units (i.e., with a
# resolution of 0.0001".
X = int(round(Xoff/0.00001)) # First work in 2.5 format to match Gerber
Y = int(round(Yoff/0.00001))
# Now calculate displacement for each position so that we end up at specified origin
DX = X - self.minx
DY = Y - self.miny
# Now round down to 2.4 format
DX = int(round(DX/10.0))
DY = int(round(DY/10.0))
ltools = self.findTools(diameter)
if config.Config['excellonleadingzeros']:
fmtstr = 'X%06dY%06d\n'
else:
fmtstr = 'X%dY%d\n'
# Boogie
for ltool in ltools:
if self.xcommands.has_key(ltool):
for cmd in self.xcommands[ltool]:
x, y = cmd
fid.write(fmtstr % (x+DX, y+DY))
def writeDrillHits(self, fid, diameter, toolNum, Xoff, Yoff):
"""Write a drill hit pattern. diameter is tool diameter in inches, while toolNum is
an integer index into strokes.DrillStrokeList"""
# First convert given inches to 2.5 co-ordinates
X = int(round(Xoff/0.00001))
Y = int(round(Yoff/0.00001))
# Now calculate displacement for each position so that we end up at specified origin
DX = X - self.minx
DY = Y - self.miny
# Do NOT round down to 2.4 format. These drill hits are in Gerber 2.5 format, not
# Excellon plunge commands.
ltools = self.findTools(diameter)
for ltool in ltools:
if self.xcommands.has_key(ltool):
for cmd in self.xcommands[ltool]:
x, y = cmd
makestroke.drawDrillHit(fid, 10*x+DX, 10*y+DY, toolNum)
def aperturesAndMacros(self, layername):
"Return dictionaries whose keys are all necessary aperture names and macro names for this layer"
GAT=config.GAT
if self.apertures.has_key(layername):
apdict = {}.fromkeys(self.apertures[layername])
apmlist = [GAT[ap].dimx for ap in self.apertures[layername] if GAT[ap].apname=='Macro']
apmdict = {}.fromkeys(apmlist)
return apdict, apmdict
else:
return {}, {}
def makeLocalApertureCode(self, layername, AP):
"Find or create a layer-specific aperture code to represent the global aperture given"
if AP.code not in self.apxlat[layername].values():
lastCode = aptable.findHighestApertureCode(self.apxlat[layername].keys())
localCode = 'D%d' % (lastCode+1)
self.apxlat[layername][localCode] = AP.code
def inBorders(self, x, y):
return (x >= self.minx) and (x <= self.maxx) and (y >= self.miny) and (y <= self.maxy)
def trimGerberLayer(self, layername):
"Modify drawing commands that are outside job dimensions"
newcmds = []
lastInBorders = True
lastx, lasty, lastd = self.minx, self.miny, 2 # (minx,miny,exposure off)
bordersRect = (self.minx, self.miny, self.maxx, self.maxy)
lastAperture = None
for cmd in self.commands[layername]:
if type(cmd) == types.TupleType:
# It is a data command: tuple (X, Y, D), all integers, or (X, Y, I, J, D), all integers.
if len(cmd)==3:
x, y, d = cmd
# I=J=None # In case we support circular interpolation in the future
else:
# We don't do anything with circular interpolation for now, so just issue
# the command and be done with it.
# x, y, I, J, d, s = cmd
newcmds.append(cmd)
continue
newInBorders = self.inBorders(x,y)
# Flash commands are easy (for now). If they're outside borders,
# ignore them. There's no need to consider the previous command.
# What should we do if the flash is partially inside and partially
# outside the border? Ideally, define a macro that constructs the
# part of the flash that is inside the border. Practically, you've
# got to be kidding.
#
# Actually, it's not that tough for rectangle apertures. We identify
# the intersection rectangle of the aperture and the bounding box,
# determine the new rectangular aperture required along with the
# new flash point, add the aperture to the GAT if necessary, and
# make the change. Spiffy.
#
# For circular interpolation commands, it's definitely harder since
# we have to construct arcs that are a subset of the original arc.
#
# For polygon fills, we similarly have to break up the polygon into
# sub-polygons that are contained within the allowable extents.
#
# Both circular interpolation and polygon fills are a) uncommon,
# and b) hard to handle. The current version of GerbMerge does not
# handle these cases.
if d==3:
if lastAperture.isRectangle():
apertureRect = lastAperture.rectangleAsRect(x,y)
if geometry.isRect1InRect2(apertureRect, bordersRect):
newcmds.append(cmd)
else:
newRect = geometry.intersectExtents(apertureRect, bordersRect)
if newRect:
newRectWidth = geometry.rectWidth(newRect)
newRectHeight = geometry.rectHeight(newRect)
newX, newY = geometry.rectCenter(newRect)
# We arbitrarily remove all flashes that lead to rectangles
# with a width or length less than 1 mil (10 Gerber units).
# Should we make this configurable?
if min(newRectWidth, newRectHeight) >= 10:
# Construct an Aperture that is a Rectangle of dimensions (newRectWidth,newRectHeight)
newAP = aptable.Aperture(aptable.Rectangle, 'D??', \
util.gerb2in(newRectWidth), util.gerb2in(newRectHeight))
global_code = aptable.findOrAddAperture(newAP)
# We need an unused local aperture code to correspond to this newly-created global one.
self.makeLocalApertureCode(layername, newAP)
# Make sure to indicate that the new aperture is one that is used by this layer
if global_code not in self.apertures[layername]:
self.apertures[layername].append(global_code)
# Switch to new aperture code, flash new aperture, switch back to previous aperture code
newcmds.append(global_code)
newcmds.append((newX, newY, 3))
newcmds.append(lastAperture.code)
else:
pass # Ignore this flash...area in common is too thin
else:
pass # Ignore this flash...no area in common
elif self.inBorders(x, y):
# Aperture is not a rectangle and its center is somewhere within our
# borders. Flash it and ignore part outside borders (for now).
newcmds.append(cmd)
else:
pass # Ignore this flash
# If this is a exposure off command, then it doesn't matter what the
# previous command is. This command just updates the (X,Y) position
# and sets the start point for a line draw to a new location.
elif d==2:
if self.inBorders(x, y):
newcmds.append(cmd)
else:
# This is an exposure on (draw line) command. Now things get interesting.
# Regardless of what the last command was (draw, exposure off, flash), we
# are planning on drawing a visible line using the current aperture from
# the (lastx,lasty) position to the new (x,y) position. The cases are:
# A: (lastx,lasty) is outside borders, (x,y) is outside borders.
# (lastx,lasty) have already been eliminated. Just update (lastx,lasty)
# with new (x,y) and remove the new command too. There is one case which
# may be of concern, and that is when the line defined by (lastx,lasty)-(x,y)
# actually crosses through the job. In this case, we have to draw the
# partial line (x1,y1)-(x2,y2) where (x1,y1) and (x2,y2) lie on the
# borders. We will add 3 commands:
# X(x1)Y(y1)D02 # exposure off
# X(x2)Y(y2)D01 # exposure on
# X(x)Y(y)D02 # exposure off
#
# B: (lastx,lasty) is outside borders, (x,y) is inside borders.
# We have to find the intersection of the line (lastx,lasty)-(x,y)
# with the borders and draw only the line segment (x1,y1)-(x,y):
# X(x1)Y(y1)D02 # exposure off
# X(x)Y(y)D01 # exposure on
#
# C: (lastx,lasty) is inside borders, (x,y) is outside borders.
# We have to find the intersection of the line (lastx,lasty)-(x,y)
# with the borders and draw only the line segment (lastx,lasty)-(x1,y1):
# then update to the new position:
# X(x1)Y(y1)D01 # exposure on
# X(x)Y(y)D02 # exposure off
#
# D: (lastx,lasty) is inside borders, (x,y) is inside borders. This is
# the most common and simplest case...just copy the command over:
# X(x)Y(y)D01 # exposure on
#
# All of the above are for linear interpolation. Circular interpolation
# is ignored for now.
if lastInBorders and newInBorders: # Case D
newcmds.append(cmd)
else:
# segmentXbox() returns a list of 0, 1, or 2 points describing the intersection
# points of the segment (lastx,lasty)-(x,y) with the box defined
# by lower-left corner (minx,miny) and upper-right corner (maxx,maxy).
pointsL = geometry.segmentXbox((lastx,lasty), (x,y), (self.minx,self.miny), (self.maxx,self.maxy))
if len(pointsL)==0: # Case A, no intersection
# Both points are outside the box and there is no overlap with box.
d = 2 # Command is effectively removed since newcmds wasn't extended.
# Ensure "last command" is exposure off to reflect this.
elif len(pointsL)==1: # Cases B and C
pt1 = pointsL[0]
if newInBorders: # Case B
newcmds.append((pt1[0], pt1[1], 2)) # Go to intersection point, exposure off
newcmds.append(cmd) # Go to destination point, exposure on
else: # Case C
newcmds.append((pt1[0], pt1[1], 1)) # Go to intersection point, exposure on
newcmds.append((x, y, 2)) # Go to destination point, exposure off
d = 2 # Make next 'lastd' represent exposure off
else: # Case A, two points of intersection
pt1 = pointsL[0]
pt2 = pointsL[1]
newcmds.append((pt1[0], pt1[1], 2)) # Go to first intersection point, exposure off
newcmds.append((pt2[0], pt2[1], 1)) # Draw to second intersection point, exposure on
newcmds.append((x, y, 2)) # Go to destination point, exposure off
d = 2 # Make next 'lastd' represent exposure off
lastx, lasty, lastd = x, y, d
lastInBorders = newInBorders
else:
# It's a string indicating an aperture change, G-code, or RS-274X
# command (e.g., "D13", "G75", "%LPD*%")
newcmds.append(cmd)
if cmd[0]=='D' and int(cmd[1:])>=10: # Don't interpret D01, D02, D03
lastAperture = config.GAT[cmd]
self.commands[layername] = newcmds
def trimGerber(self):
for layername in self.commands.keys():
self.trimGerberLayer(layername)
def trimExcellon(self):
"Remove plunge commands that are outside job dimensions"
keys = self.xcommands.keys()
for toolname in keys:
# Remember Excellon is 2.4 format while Gerber data is 2.5 format
validList = [(x,y) for x,y in self.xcommands[toolname] if self.inBorders(10*x,10*y)]
if validList:
self.xcommands[toolname] = validList
else:
del self.xcommands[toolname]
del self.xdiam[toolname]
# This class encapsulates a Job object, providing absolute
# positioning information.
class JobLayout:
def __init__(self, job):
self.job = job
self.x = None
self.y = None
def canonicalize(self): # Must return a JobLayout object as a list
return [self]
def writeGerber(self, fid, layername):
assert self.x is not None
self.job.writeGerber(fid, layername, self.x, self.y)
def aperturesAndMacros(self, layername):
return self.job.aperturesAndMacros(layername)
def writeExcellon(self, fid, diameter):
assert self.x is not None
self.job.writeExcellon(fid, diameter, self.x, self.y)
def writeDrillHits(self, fid, diameter, toolNum):
assert self.x is not None
self.job.writeDrillHits(fid, diameter, toolNum, self.x, self.y)
def writeCutLines(self, fid, drawing_code, X1, Y1, X2, Y2):
"""Draw a board outline using the given aperture code"""
def notEdge(x, X):
return round(abs(1000*(x-X)))
assert self.x and self.y
radius = config.GAT[drawing_code].dimx/2.0
# Start at lower-left, proceed clockwise
x = self.x - radius
y = self.y - radius
left = notEdge(self.x, X1)
right = notEdge(self.x+self.width_in(), X2)
bot = notEdge(self.y, Y1)
top = notEdge(self.y+self.height_in(), Y2)
BL = ((x), (y))
TL = ((x), (y+self.height_in()+2*radius))
TR = ((x+self.width_in()+2*radius), (y+self.height_in()+2*radius))
BR = ((x+self.width_in()+2*radius), (y))
if not left:
BL = (BL[0]+2*radius, BL[1])
TL = (TL[0]+2*radius, TL[1])
if not top:
TL = (TL[0], TL[1]-2*radius)
TR = (TR[0], TR[1]-2*radius)
if not right:
TR = (TR[0]-2*radius, TR[1])
BR = (BR[0]-2*radius, BR[1])
if not bot:
BL = (BL[0], BL[1]+2*radius)
BR = (BR[0], BR[1]+2*radius)
BL = (util.in2gerb(BL[0]), util.in2gerb(BL[1]))
TL = (util.in2gerb(TL[0]), util.in2gerb(TL[1]))
TR = (util.in2gerb(TR[0]), util.in2gerb(TR[1]))
BR = (util.in2gerb(BR[0]), util.in2gerb(BR[1]))
# The "if 1 or ..." construct draws all four sides of the job. By
# removing the 1 from the expression, only the sides that do not
# correspond to panel edges are drawn. The former is probably better
# since panels tend to have a little slop from the cutting operation
# and it's easier to just cut it smaller when there's a cut line.
# The way it is now with "if 1 or....", much of this function is
# unnecessary. Heck, we could even just use the boardoutline layer
# directly.
if 1 or left:
fid.write('X%07dY%07dD02*\n' % BL)
fid.write('X%07dY%07dD01*\n' % TL)
if 1 or top:
if not left: fid.write('X%07dY%07dD02*\n' % TL)
fid.write('X%07dY%07dD01*\n' % TR)
if 1 or right:
if not top: fid.write('X%07dY%07dD02*\n' % TR)
fid.write('X%07dY%07dD01*\n' % BR)
if 1 or bot:
if not right: fid.write('X%07dY%07dD02*\n' % BR)
fid.write('X%07dY%07dD01*\n' % BL)
def setPosition(self, x, y):
self.x=x
self.y=y
def width_in(self):
return self.job.width_in()
def height_in(self):
return self.job.height_in()
def drillhits(self, diameter):
tools = self.job.findTools(diameter)
total = 0
for tool in tools:
try:
total += len(self.job.xcommands[tool])
except:
pass
return total
def jobarea(self):
return self.job.jobarea()
def rotateJob(job, degrees = 90, firstpass = True):
"""Create a new job from an existing one, rotating by specified degrees in 90 degree passes"""
GAT = config.GAT
GAMT = config.GAMT
##print "rotating job:", job.name, degrees, firstpass
if firstpass:
if degrees == 270:
J = Job(job.name+'*rotated270')
elif degrees == 180:
J = Job(job.name+'*rotated180')
else:
J = Job(job.name+'*rotated90')
else:
J = Job(job.name)
# Keep the origin (lower-left) in the same place
J.maxx = job.minx + job.maxy-job.miny
J.maxy = job.miny + job.maxx-job.minx
J.minx = job.minx
J.miny = job.miny
RevGAT = config.buildRevDict(GAT) # RevGAT[hash] = aperturename
RevGAMT = config.buildRevDict(GAMT) # RevGAMT[hash] = aperturemacroname
# Keep list of tool diameters and default tool list
J.xdiam = job.xdiam
J.ToolList = job.ToolList
J.Repeat = job.Repeat
# D-code translation table is the same, except we have to rotate
# those apertures which have an orientation: rectangles, ovals, and macros.
ToolChangeReplace = {}
for layername in job.apxlat.keys():
J.apxlat[layername] = {}
for ap in job.apxlat[layername].keys():
code = job.apxlat[layername][ap]
A = GAT[code]
if A.apname in ('Circle', 'Octagon'):
# This aperture is fine. Copy it over.
J.apxlat[layername][ap] = code
continue
# Must rotate the aperture
APR = A.rotated(RevGAMT)
# Does it already exist in the GAT?
hash = APR.hash()
try:
# Yup...add it to apxlat
newcode = RevGAT[hash]
except KeyError:
# Must add new aperture to GAT
newcode = aptable.addToApertureTable(APR)
# Rebuild RevGAT
#RevGAT = config.buildRevDict(GAT)
RevGAT[hash] = newcode
J.apxlat[layername][ap] = newcode
# Must also replace all tool change commands from
# old code to new command.
ToolChangeReplace[code] = newcode
# Now we copy commands, rotating X,Y positions.
# Rotations will occur counterclockwise about the
# point (minx,miny). Then, we shift to the right
# by the height so that the lower-left point of
# the rotated job continues to be (minx,miny).
#
# We also have to take aperture change commands and
# replace them with the new aperture code if we have
# a rotation.
offset = job.maxy-job.miny
for layername in job.commands.keys():
J.commands[layername] = []
J.apertures[layername] = []
for cmd in job.commands[layername]:
# Is it a drawing command?
if type(cmd) is types.TupleType:
if len(cmd)==3:
x, y, d = map(__builtin__.int, cmd)
II=JJ=None
else:
x, y, II, JJ, d, signed = map(__builtin__.int, cmd) # J is already used as Job object
else:
# No, must be a string indicating aperture change, G-code, or RS274-X command.
if cmd[0] in ('G', '%'):
# G-codes and RS274-X commands are just copied verbatim and not affected by rotation
J.commands[layername].append(cmd)
continue
# It's a D-code. See if we need to replace aperture changes with a rotated aperture.
# But only for D-codes >= 10.
if int(cmd[1:]) < 10:
J.commands[layername].append(cmd)
continue
try:
newcmd = ToolChangeReplace[cmd]
J.commands[layername].append(newcmd)
J.apertures[layername].append(newcmd)
except KeyError:
J.commands[layername].append(cmd)
J.apertures[layername].append(cmd)
continue
# (X,Y) --> (-Y,X) effects a 90-degree counterclockwise shift
# Adding 'offset' to -Y maintains the lower-left origin of (minx,miny).
newx = -(y - job.miny) + job.minx + offset
newy = (x-job.minx) + job.miny
# For circular interpolation commands, (I,J) components are always relative
# so we do not worry about offsets, just reverse their sense, i.e., I becomes J
# and J becomes I. For 360-degree circular interpolation, I/J are signed and we
# must map (I,J) --> (-J,I).
if II is not None:
if signed:
J.commands[layername].append((newx, newy, -JJ, II, d, signed))
else:
J.commands[layername].append((newx, newy, JJ, II, d, signed))
else:
J.commands[layername].append((newx,newy,d))
if 0:
print job.minx, job.miny, offset
print layername
print J.commands[layername]
# Finally, rotate drills. Offset is in hundred-thousandths (2.5) while Excellon
# data is in 2.4 format.
for tool in job.xcommands.keys():
J.xcommands[tool] = []
for x,y in job.xcommands[tool]:
newx = -(10*y - job.miny) + job.minx + offset
newy = (10*x - job.minx) + job.miny
newx = int(round(newx/10.0))
newy = int(round(newy/10.0))
J.xcommands[tool].append((newx,newy))
# Rotate some more if required
degrees -= 90
if degrees > 0:
return rotateJob(J, degrees, False)
else:
##print "rotated:", J.name
return J