#!/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 Unwired Devices LLC http://github.com/unwireddevices/gerbmerge """ import sys import re import string import builtins 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 # TODO: # # Need to add error checking for metric/imperial units matching those of the files input # Check fabdrawing.py to see if writeDrillHits is scaling properly (the only place it is used) # 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\*$'), # new Gerber Attributes re.compile(r'^%TF.*\*%'), re.compile(r'^%TA.*\*%'), re.compile(r'^%TD.*\*%'), # 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\*%'), # [andreika]: don't ignore 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+)(?:G85X([+-]?\d+)Y([+-]?\d+))?$') # Plunge command with optional G85 xydraw_pat2 = re.compile(r'^X([+-]?\d+\.\d*)Y([+-]?\d+\.\d*)(?:G85X([+-]?\d+\.\d*)Y([+-]?\d+\.\d*))?$') # Plunge command with optional G85 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 re.compile(r'^FMAT,2$'),# KiCad work-around re.compile(r'^G05$'), # Drill Mode re.compile(r'^G90$') # Absolute Mode ) # 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,STOP_X,STOP_Y) integer tuple. # STOP_X and STOP_Y are not none only if this is a G85 command. 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): # add metric support (1/1000 mm vs. 1/100,000 inch) if config.Config['measurementunits'] == 'inch': "Return width in INCHES" return float(self.maxx-self.minx)*0.00001 else: return float(self.maxx-self.minx)*0.001 def height_in(self): # add metric support (1/1000 mm vs. 1/100,000 inch) if config.Config['measurementunits'] == 'inch': "Return height in INCHES" return float(self.maxy-self.miny)*0.00001 else: return float(self.maxy-self.miny)*0.001 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.items(): # 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) == tuple: ## ensure that command is of type tuple command_list = list(c) ## convert tuple to list if (type( command_list[0] ) == int) \ and (type( command_list[1] ) == int): ## 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.items(): # 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] ) == int ) \ and ( type( command_list[1] ) == int ): ## ensure that first two elemenst are integers command_list[0] += x_shift / 10 command_list[1] += y_shift / 10 if ( type( command_list[2] ) == int ) \ and ( type( command_list[3] ) == int ): ## ensure that first two elemenst are integerslen(command_list) == 4: # G85 command, need to shift the second pair of xy, too. command_list[2] += x_shift / 10 command_list[3] += 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 = open(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 # [andreika]: use local units conversion units_div = 1.0 # [andreika]: store fill mode separately because of G01 can be inside G36/37 in_fill_mode = False # 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 = str.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) # [andreika]: apply units if type(A.dimx) == float or type(A.dimx) == int: A.dimx *= units_div if type(A.dimy) == float or type(A.dimy) == int: A.dimy *= units_div hash = A.hash() if hash not in RevGAT: #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 # DipTrace specific fixes, but could be emitted by any CAD program. They are Standard Gerber RS-274X # a hack to fix lack of recognition for metric direction from DipTrace - %MOMM*% if (line[:7] == '%MOMM*%'): # [andreika]: just set units to mm, no error if (config.Config['measurementunits'] == 'inch'): #raise RuntimeError, "File %s units do match config file" % fullname units_div = 1.0 / 25.4 continue else: #print "ignoring metric directive: " + line continue # ignore it so func doesn't choke on it # [andreika]: add reciprocal conversion if (line[:7] == '%MOIN*%'): if (config.Config['measurementunits'] == 'mm'): units_div = 25.4 continue else: continue # ignore it so func doesn't choke on it if line[:3] == '%SF': # scale factor - we will ignore it print('Scale factor parameter ignored: ' + line) continue # end basic diptrace fixes # 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 hash not in RevGAMT: 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 # allow for metric - scale to 1/1000 mm # [andreika]: use local units if config.Config['measurementunits'] == 'inch': 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) else: if item[0]=='X': # M.N specification for X-axis. fracpart = int(item[2]) x_div = 10.0**(3-fracpart) #print "x_div= %5.3f." % x_div if item[0]=='Y': # M.N specification for Y-axis. fracpart = int(item[2]) y_div = 10.0**(3-fracpart) #print "y_div= %5.3f." % y_div 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). # added 71 - specify mm (metric) if gcode in [54, 70, 90, 71]: 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 # [andreika]: we store fill mode separately if gcode==36: in_fill_mode = True elif gcode==37: in_fill_mode = False 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) # Diptrace hack # There is a D2* command in board outlines. I believe this should be D02. Let's change it then when it occurs: if (currtool == 'D1'): currtool = 'D01' if (currtool == 'D2'): currtool = 'D02' if (currtool == 'D3'): currtool = 'D03' # 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 currtool not in self.apxlat[layername]: 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 = list(map(builtins.int, match.groups())) else: match = drawX_pat.match(sub_line) if match: x, d = list(map(builtins.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 = list(map(builtins.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 = list(map(builtins.int, match.groups())) else: match = cdrawX_pat.match(sub_line) if match: x, I, J, d = list(map(builtins.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 = list(map(builtins.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): # [andreika]: check for fill mode more accurately if not in_fill_mode: 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) # [andreika]: add units_div x = int(round(x*x_div*units_div)) y = int(round(y*y_div*units_div)) if I is not None: I = int(round(I*x_div*units_div)) J = int(round(J*y_div*units_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 = open(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 s is not None: if not suppress_leading: s = s + '0'*(zeropadto-len(s)) V.append(int(round(int(s)*divisor))) else: V.append(None) return tuple(V) # Helper function to convert X/Y strings into integers in units of ten-thousandth of an inch. def xln2tenthou2 (L, divisor=divisor, zeropadto=zeropadto): V = [] for s in L: if s is not None: V.append(int(float(s)*1000*divisor)) else: V.append(None) return tuple(V) for line in fid: # Get rid of CR characters line = str.replace(line, '\x0D', '') # add support for DipTrace if line[:6]=='METRIC': if (config.Config['measurementunits'] == 'inch'): raise RuntimeError("File %s units do match config file" % fullname) else: #print "ignoring METRIC directive: " + line continue # ignore it so func doesn't choke on it if line[:3] == 'T00': # a tidying up that we can ignore continue # end metric/diptrace support # 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 currtool in self.xdiam: 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:]) # KiCad specific fixes if currtool == 'T00': continue # end KiCad fixes # 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, stop_x, stop_y = xln2tenthou(match.groups()) else: match = xydraw_pat2.match(line) if match: x, y, stop_x, stop_y = xln2tenthou2(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,stop_x,stop_y)) except KeyError: self.xcommands[currtool] = [(x,y,stop_x,stop_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 layername in self.commands 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 # add metric support (1/1000 mm vs. 1/100,000 inch) if config.Config['measurementunits'] == 'inch': # First convert given inches to 2.5 co-ordinates X = int(round(Xoff/0.00001)) Y = int(round(Yoff/0.00001)) else: # First convert given mm to 5.3 co-ordinates X = int(round(Xoff/0.001)) Y = int(round(Yoff/0.001)) # 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 tuple: 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 list(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 args: fid - output file diameter Xoff - offset of this board instance in full units (float) Yoff - offset of this board instance in full units (float) """ # 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". if config.Config['measurementunits'] == 'inch': X = int(round(Xoff/0.00001)) # First work in 2.5 format to match Gerber Y = int(round(Yoff/0.00001)) else: X = int(round(Xoff/0.001)) # First work in 5.3 format to match Gerber Y = int(round(Yoff/0.001)) # 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) def formatForXln(num): """ helper to convert from our 2.4 internal format to config's excellon format returns string """ divisor = 10.0**(4 - config.Config['excellondecimals']) if config.Config['excellonleadingzeros']: fmtstr = '%06d' else: fmtstr = '%d' return fmtstr % (num / divisor) # Boogie for ltool in ltools: if ltool in self.xcommands: for cmd in self.xcommands[ltool]: x, y, stop_x, stop_y = cmd new_x = x+DX new_y = y+DY if stop_x is None: fid.write('X%sY%s\n' % (formatForXln(new_x), formatForXln(new_y))) else: new_stop_x = stop_x+DX new_stop_y = stop_y+DY fid.write('X%sY%sG85X%sY%s\n' % (formatForXln(new_x), formatForXln(new_y), formatForXln(new_stop_x), formatForXln(new_stop_y))) 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""" # add metric support (1/1000 mm vs. 1/100,000 inch) if config.Config['measurementunits'] == 'inch': # First convert given inches to 2.5 co-ordinates X = int(round(Xoff/0.00001)) Y = int(round(Yoff/0.00001)) else: # First convert given inches to 5.3 co-ordinates X = int(round(Xoff/0.001)) Y = int(round(Yoff/0.001)) # 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 ltool in self.xcommands: for cmd in self.xcommands[ltool]: x, y, stop_x, stop_y = cmd # add metric support (1/1000 mm vs. 1/100,000 inch) # TODO - verify metric scaling is correct??? makestroke.drawDrillHit(fid, 10*x+DX, 10*y+DY, toolNum) if stop_x is not None: makestroke.drawDrillHit(fid, 10*stop_x+DX, 10*stop_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 layername in self.apertures: 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 list(self.apxlat[layername].values()): lastCode = aptable.findHighestApertureCode(list(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) == tuple: # 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). - sdd s.b. 0.1mil??? # Should we make this configurable? # add metric support (1/1000 mm vs. 1/100,000 inch) # if config.Config['measurementunits'] == 'inch': # minFlash = 10; # else # minFlash = if min(newRectWidth, newRectHeight) >= 10: # sdd - change for metric case at some point # 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 list(self.commands.keys()): self.trimGerberLayer(layername) def trimExcellon(self): "Remove plunge commands that are outside job dimensions" keys = list(self.xcommands.keys()) for toolname in keys: # Remember Excellon is 2.4 format while Gerber data is 2.5 format validList = [tup for tup in self.xcommands[toolname] if (self.inBorders(10*tup[0],10*tup[1]) and (tup[2] is None or self.inBorders(10*tup[2],10*tup[3])))] 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 #if job has a boardoutline layer, write it, else calculate one outline_layer = 'boardoutline'; if self.job.hasLayer(outline_layer): # somewhat of a hack here; making use of code in gerbmerge, around line 516, # we are going to replace the used of the existing draw code in the boardoutline # file with the one passed in (which was created from layout.cfg ('CutLineWidth') # It is a hack in that we are assuming there is only one draw code in the # boardoutline file. We are just going to ignore that definition and change # all usages of that code to our new one. As a side effect, it will make # the merged boardoutline file invalid, but we aren't using it with this method. temp = [] for x in self.job.commands[outline_layer]: if x[0] == 'D': temp.append(drawing_code) ## replace old aperture with new one else: temp.append(x) ## keep old command self.job.commands[outline_layer] = temp #self.job.writeGerber(fid, outline_layer, X1, Y1) self.writeGerber(fid, outline_layer) else: 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, flip = 0, 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, flip, firstpass if firstpass: rotatedFlipped = "" if degrees == 270: rotatedFlipped += '*rotated270' elif degrees == 180: rotatedFlipped += '*rotated180' elif degrees == 90: rotatedFlipped += '*rotated90' if flip == 1: rotatedFlipped += '*flippedH' elif flip == -1: rotatedFlipped += '*flippedV' J = Job(job.name + rotatedFlipped) else: J = Job(job.name) # Keep the origin (lower-left) in the same place if degrees != 0: J.maxx = job.minx + job.maxy-job.miny J.maxy = job.miny + job.maxx-job.minx J.minx = job.minx J.miny = job.miny doFlip = 0 else: doFlip = flip if flip == 1: J.minx = job.maxx J.maxx = job.minx J.miny = job.miny J.maxy = job.maxy elif flip == -1: J.minx = job.minx J.maxx = job.maxx J.miny = job.maxy J.maxy = 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 list(job.apxlat.keys()): J.apxlat[layername] = {} for ap in list(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, doFlip) # 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. # [andreika]: add 'fixedrotationorigin' setting to disable shifting if config.Config['fixedrotationorigin']: offset = 0 else: offset = job.maxy-job.miny for layername in list(job.commands.keys()): J.commands[layername] = [] J.apertures[layername] = [] for cmd in job.commands[layername]: # Is it a drawing command? if type(cmd) is tuple: if len(cmd)==3: x, y, d = list(map(builtins.int, cmd)) II=JJ=None else: x, y, II, JJ, d, signed = list(map(builtins.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 if (doFlip == 1): newx = job.maxx - (x - job.minx) newy = y elif (doFlip == -1): newx = x newy = job.maxy - (y - job.miny) else: # (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 list(job.xcommands.keys()): J.xcommands[tool] = [] for x,y,stop_x,stop_y in job.xcommands[tool]: # add metric support (1/1000 mm vs. 1/100,000 inch) # NOTE: There don't appear to be any need for a change. The usual x10 factor seems to apply if (doFlip == 1): newx = job.maxx - (10*x - job.minx) newy = 10*y elif (doFlip == -1): newx = 10*x newy = job.maxy - (10*y - job.miny) else: 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)) if stop_x is not None: if (doFlip == 1): newstop_x = job.maxx - (10*stop_x - job.minx) newstop_y = 10*stop_y elif (doFlip == -1): newstop_x = 10*stop_x newstop_y = job.maxy - (10*stop_y - job.miny) else: newstop_x = -(10*stop_y - job.miny) + job.minx + offset newstop_y = (10*stop_x - job.minx) + job.miny newstop_x = int(round(newstop_x/10.0)) newstop_y = int(round(newstop_y/10.0)) else: newstop_x = None newstop_y = None J.xcommands[tool].append((newx,newy,newstop_x,newstop_y)) if (degrees == 0): flip = 0 # Rotate or flip some more if required degrees -= 90 if degrees > 0 or flip != 0: return rotateJob(J, degrees, flip, False) else: ##print "rotated:", J.name return J