#!/usr/bin/env python
"""
Merge several RS274X (Gerber) files generated by Eagle into a single
job.

This program expects that each separate job has at least three files:
  - a board outline (RS274X)
  - data layers (copper, silkscreen, etc. in RS274X format)
  - an Excellon drill file

Furthermore, it is expected that each job was generated by Eagle
using the GERBER_RS274X plotter, except for the drill file which
was generated by the EXCELLON plotter.

This program places all jobs into a single job.

--------------------------------------------------------------------

This program is licensed under the GNU General Public License (GPL).
See http://www.fsf.org for details of the license.

Andrew Sterian
Padnos College of Engineering and Computing
Grand Valley State University
<steriana@claymore.engineer.gvsu.edu>
<http://claymore.engineer.gvsu.edu/~steriana>
"""

import sys
import os
import getopt
import re

import aptable
import jobs
import config
import parselayout
import fabdrawing
import strokes
import tiling
import tilesearch1
import tilesearch2
import placement
import schwartz
import util
import scoring
import drillcluster

VERSION_MAJOR=1
VERSION_MINOR=6

RANDOM_SEARCH = 1
EXHAUSTIVE_SEARCH = 2
FROM_FILE = 3
config.AutoSearchType = RANDOM_SEARCH
config.RandomSearchExhaustiveJobs = 2
config.PlacementFile = None

def usage():
  print \
"""
Usage: gerbmerge [Options] configfile [layoutfile]

Options:
    -h, --help          -- This help summary
    -v, --version       -- Program version and contact information
    --random-search     -- Automatic placement using random search (default)
    --full-search       -- Automatic placement using exhaustive search
    --place-file=fn     -- Read placement from file
    --rs-fsjobs=N       -- When using random search, exhaustively search N jobs
                           for each random placement (default: N=2)
    --no-trim-gerber    -- Do not attempt to trim Gerber data to extents of board
    --no-trim-excellon  -- Do not attempt to trim Excellon data to extents of board
    --octagons=fmt      -- Generate octagons in two different styles depending on
                           the value of 'fmt':

                              fmt is 'rotate' :  0.0 rotation
                              fmt is 'normal' : 22.5 rotation (default)

If a layout file is not specified, automatic placement is performed. If the
placement is read from a file, then no automatic placement is performed and
the layout file (if any) is ignored.

NOTE: The dimensions of each job are determined solely by the maximum extent of
the board outline layer for each job.
"""
  sys.exit(1)

def writeGerberHeader22degrees(fid):
  fid.write( \
"""G75*
G70*
%OFA0B0*%
%FSLAX25Y25*%
%IPPOS*%
%LPD*%
%AMOC8*
5,1,8,0,0,1.08239X$1,22.5*
%
""")

def writeGerberHeader0degrees(fid):
  fid.write( \
"""G75*
G70*
%OFA0B0*%
%FSLAX25Y25*%
%IPPOS*%
%LPD*%
%AMOC8*
5,1,8,0,0,1.08239X$1,0.0*
%
""")

writeGerberHeader = writeGerberHeader22degrees

def writeApertureMacros(fid, usedDict):
  keys = config.GAMT.keys()
  keys.sort()
  for key in keys:
    if key in usedDict:
      config.GAMT[key].writeDef(fid)

def writeApertures(fid, usedDict):
  keys = config.GAT.keys()
  keys.sort()
  for key in keys:
    if key in usedDict:
      config.GAT[key].writeDef(fid)

def writeGerberFooter(fid):
  fid.write('M02*\n')

def writeExcellonHeader(fid):
  fid.write('%\n')

def writeExcellonFooter(fid):
  fid.write('M30\n')

def writeExcellonTool(fid, tool, size):
  fid.write('%sC%f\n' % (tool, size))

def writeCropMarks(fid, drawing_code, OriginX, OriginY, MaxXExtent, MaxYExtent):
  """Add corner crop marks on the given layer"""

  # Draw 125mil lines at each corner, with line edge right up against
  # panel border. This means the center of the line is D/2 offset
  # from the panel border, where D is the drawing line diameter.
  fid.write('%s*\n' % drawing_code)    # Choose drawing aperture

  offset = config.GAT[drawing_code].dimx/2.0

  # Lower-left
  x = OriginX + offset
  y = OriginY + offset
  fid.write('X%07dY%07dD02*\n' % (util.in2gerb(x+0.125), util.in2gerb(y+0.000)))
  fid.write('X%07dY%07dD01*\n' % (util.in2gerb(x+0.000), util.in2gerb(y+0.000)))
  fid.write('X%07dY%07dD01*\n' % (util.in2gerb(x+0.000), util.in2gerb(y+0.125)))

  # Lower-right
  x = MaxXExtent - offset
  y = OriginY + offset
  fid.write('X%07dY%07dD02*\n' % (util.in2gerb(x+0.000), util.in2gerb(y+0.125)))
  fid.write('X%07dY%07dD01*\n' % (util.in2gerb(x+0.000), util.in2gerb(y+0.000)))
  fid.write('X%07dY%07dD01*\n' % (util.in2gerb(x-0.125), util.in2gerb(y+0.000)))

  # Upper-right
  x = MaxXExtent - offset
  y = MaxYExtent - offset
  fid.write('X%07dY%07dD02*\n' % (util.in2gerb(x-0.125), util.in2gerb(y+0.000)))
  fid.write('X%07dY%07dD01*\n' % (util.in2gerb(x+0.000), util.in2gerb(y+0.000)))
  fid.write('X%07dY%07dD01*\n' % (util.in2gerb(x+0.000), util.in2gerb(y-0.125)))

  # Upper-left
  x = OriginX + offset
  y = MaxYExtent - offset
  fid.write('X%07dY%07dD02*\n' % (util.in2gerb(x+0.000), util.in2gerb(y-0.125)))
  fid.write('X%07dY%07dD01*\n' % (util.in2gerb(x+0.000), util.in2gerb(y+0.000)))
  fid.write('X%07dY%07dD01*\n' % (util.in2gerb(x+0.125), util.in2gerb(y+0.000)))

def disclaimer():
  print """
****************************************************
*           R E A D    C A R E F U L L Y           *
*                                                  *
* This program comes with no warranty. You use     *
* this program at your own risk. Do not submit     *
* board files for manufacture until you have       *
* thoroughly inspected the output of this program  *
* using a previewing program such as:              *
*                                                  *
* Windows:                                         *
*          - GC-Prevue <http://www.graphicode.com> *
*          - ViewMate  <http://www.pentalogix.com> *
*                                                  *
* Linux:                                           *
*          - gerbv <http://gerbv.sourceforge.net>  *
*                                                  *
* By using this program you agree to take full     *
* responsibility for the correctness of the data   *
* that is generated by this program.               *
****************************************************

To agree to the above terms, press 'y' then Enter.
Any other key will exit the program.

"""

  s = raw_input()
  if s == 'y':
    print
    return

  print "\nExiting..."
  sys.exit(0)

def tile_jobs(Jobs):
  """Take a list of raw Job objects and find best tiling by calling tile_search"""

  # We must take the raw jobs and construct a list of 4-tuples (Xdim,Ydim,job,rjob).
  # This means we must construct a rotated job for each entry. We first sort all
  # jobs from largest to smallest. This should give us the best tilings first so
  # we can interrupt the tiling process and get a decent layout.
  L = []
  #sortJobs = schwartz.schwartz(Jobs, jobs.Job.jobarea)
  sortJobs = schwartz.schwartz(Jobs, jobs.Job.maxdimension)
  sortJobs.reverse()

  for job in sortJobs:
    Xdim = job.width_in()
    Ydim = job.height_in()
    rjob = jobs.rotateJob(job, 90)  ##NOTE: This will only try 90 degree rotations though 180 & 270 are available

    for count in range(job.Repeat):
      L.append( (Xdim,Ydim,job,rjob) )

  PX,PY = config.Config['panelwidth'],config.Config['panelheight']
  if config.AutoSearchType==RANDOM_SEARCH:
    tile = tilesearch2.tile_search2(L, PX, PY)
  else:
    tile = tilesearch1.tile_search1(L, PX, PY)

  if not tile:
    raise RuntimeError, 'Panel size %.2f"x%.2f" is too small to hold jobs' % (PX,PY)

  return tile

if __name__=="__main__":
  try:
    opts, args = getopt.getopt(sys.argv[1:], 'hv', ['help', 'version', 'octagons=', 'random-search', 'full-search',
                           'rs-fsjobs=', 'place-file=', 'no-trim-gerber', 'no-trim-excellon'])
  except getopt.GetoptError:
    usage()

  for opt, arg in opts:
    if opt in ('-h', '--help'):
      usage()
    elif opt in ('-v', '--version'):
      print """
GerbMerge Version %d.%d  --  Combine multiple Gerber/Excellon files

This program is licensed under the GNU General Public License (GPL). See
http://www.fsf.org for details of this license.

Andrew Sterian
Padnos College of Engineering and Computing
Grand Valley State University
<steriana@gvsu.edu>
<http://claymore.engineer.gvsu.edu/~steriana>
""" % (VERSION_MAJOR, VERSION_MINOR)
      sys.exit(0)
    elif opt in ('--octagons',):
      if arg=='rotate':
        writeGerberHeader = writeGerberHeader0degrees
      elif arg=='normal':
        writeGerberHeader = writeGerberHeader22degrees
      else:
        print 'Unknown octagon format'
        usage()
    elif opt in ('--random-search',):
      config.AutoSearchType = RANDOM_SEARCH
    elif opt in ('--full-search',):
      config.AutoSearchType = EXHAUSTIVE_SEARCH
    elif opt in ('--rs-fsjobs',):
      config.RandomSearchExhaustiveJobs = int(arg)
    elif opt in ('--place-file',):
      config.AutoSearchType = FROM_FILE
      config.PlacementFile = arg
    elif opt in ('--no-trim-gerber',):
      config.TrimGerber = 0
    elif opt in ('--no-trim-excellon',):
      config.TrimExcellon = 0
    else:
      raise RuntimeError, "Unknown option: %s" % opt

  if len(args) > 2 or len(args) < 1:
    usage()

  disclaimer()

  # Load up the Jobs global dictionary, also filling out GAT, the
  # global aperture table and GAMT, the global aperture macro table.
  config.parseConfigFile(args[0])

  # Force all X and Y coordinates positive by adding absolute value of minimum X and Y
  for name, job in config.Jobs.iteritems():
    min_x, min_y = job.mincoordinates()
    shift_x = shift_y = 0
    if min_x < 0: shift_x = abs(min_x)
    if min_y < 0: shift_y = abs(min_y)
    if (shift_x > 0) or (shift_y > 0):
      job.fixcoordinates( shift_x, shift_y )

  # Display job properties                                                                
  for job in config.Jobs.values():
    print 'Job %s:' % job.name,
    if job.Repeat > 1:
      print '(%d instances)' % job.Repeat
    else:
      print
    print '  Extents: (%d,%d)-(%d,%d)' % (job.minx,job.miny,job.maxx,job.maxy)
    print '  Size: %f" x %f"' % (job.width_in(), job.height_in())
    print

  # Trim drill locations and flash data to board extents
  if config.TrimExcellon:
    print 'Trimming Excellon data to board outlines ...'
    for job in config.Jobs.values():
      job.trimExcellon()

  if config.TrimGerber:
    print 'Trimming Gerber data to board outlines ...'
    for job in config.Jobs.values():
      job.trimGerber()

  # We start origin at (0.1", 0.1") just so we don't get numbers close to 0
  # which could trip up Excellon leading-0 elimination.
  OriginX = OriginY = 0.1

  # Read the layout file and construct the nested list of jobs. If there
  # is no layout file, do auto-layout.
  print 'Performing layout ...'
  if len(args) > 1:
    Layout = parselayout.parseLayoutFile(args[1])

    # Do the layout, updating offsets for each component job.
    X = OriginX + config.Config['leftmargin']
    Y = OriginY + config.Config['bottommargin']

    for row in Layout:
      row.setPosition(X, Y)
      Y += row.height_in() + config.Config['yspacing']

    # Construct a canonical placement from the layout
    Place = placement.Placement()
    Place.addFromLayout(Layout)

    del Layout

  elif config.AutoSearchType == FROM_FILE:
    Place = placement.Placement()
    Place.addFromFile(config.PlacementFile, config.Jobs)
  else:
    # Do an automatic layout based on our tiling algorithm.
    tile = tile_jobs(config.Jobs.values())

    Place = placement.Placement()
    Place.addFromTiling(tile, OriginX + config.Config['leftmargin'], OriginY + config.Config['bottommargin'])

  (MaxXExtent,MaxYExtent) = Place.extents()
  MaxXExtent += config.Config['rightmargin']
  MaxYExtent += config.Config['topmargin']

  # Start printing out the Gerbers. In preparation for drawing cut marks
  # and crop marks, make sure we have an aperture to draw with. Use a 10mil line.
  # If we're doing a fabrication drawing, we'll need a 1mil line.
  OutputFiles = []

  try:
    fullname = config.MergeOutputFiles['placement']
  except KeyError:
    fullname = 'merged.placement.txt'
  Place.write(fullname)
  OutputFiles.append(fullname)

  # For cut lines
  AP = aptable.Aperture(aptable.Circle, 'D??', config.Config['cutlinewidth'])
  drawing_code_cut = aptable.findInApertureTable(AP)
  if drawing_code_cut is None:
    drawing_code_cut = aptable.addToApertureTable(AP)

  # For crop marks
  AP = aptable.Aperture(aptable.Circle, 'D??', config.Config['cropmarkwidth'])
  drawing_code_crop = aptable.findInApertureTable(AP)
  if drawing_code_crop is None:
    drawing_code_crop = aptable.addToApertureTable(AP)

  # For fabrication drawing.
  AP = aptable.Aperture(aptable.Circle, 'D??', 0.001)
  drawing_code1 = aptable.findInApertureTable(AP)
  if drawing_code1 is None:
    drawing_code1 = aptable.addToApertureTable(AP)

  print 'Writing merged output files ...'

  for layername in config.LayerList.keys():
    lname = layername
    if lname[0]=='*':
      lname = lname[1:]

    try:
      fullname = config.MergeOutputFiles[layername]
    except KeyError:
      fullname = 'merged.%s.ger' % lname
    OutputFiles.append(fullname)
    #print 'Writing %s ...' % fullname
    fid = file(fullname, 'wt')
    writeGerberHeader(fid)
    
    # Determine which apertures and macros are truly needed
    apUsedDict = {}
    apmUsedDict = {}
    for job in Place.jobs:
      apd, apmd = job.aperturesAndMacros(layername)
      apUsedDict.update(apd)
      apmUsedDict.update(apmd)

    # Increase aperature sizes to match minimum feature dimension                         
    if config.MinimumFeatureDimension.has_key(layername):
    
      print '  Thickening', lname, 'feature dimensions ...'
      
      # Fix each aperture used in this layer
      for ap in apUsedDict.keys():
        new = config.GAT[ap].getAdjusted( config.MinimumFeatureDimension[layername] )
        if not new: ## current aperture size met minimum requirement
          continue
        else:       ## new aperture was created
          new_code = aptable.findOrAddAperture(new) ## get name of existing aperture or create new one if needed
          del apUsedDict[ap]                        ## the old aperture is no longer used in this layer
          apUsedDict[new_code] = None               ## the new aperture will be used in this layer
     
          # Replace all references to the old aperture with the new one
          for joblayout in Place.jobs:
            job = joblayout.job ##access job inside job layout 
            temp = []
            if job.hasLayer(layername):             #### ADS START
              for x in job.commands[layername]:
                if x == ap:
                  temp.append(new_code) ## replace old aperture with new one
                else:
                  temp.append(x)        ## keep old command
              job.commands[layername] = temp        #### ADS END

    if config.Config['cutlinelayers'] and (layername in config.Config['cutlinelayers']):
      apUsedDict[drawing_code_cut]=None

    if config.Config['cropmarklayers'] and (layername in config.Config['cropmarklayers']):
      apUsedDict[drawing_code_crop]=None
      
    # Write only necessary macro and aperture definitions to Gerber file
    writeApertureMacros(fid, apmUsedDict)
    writeApertures(fid, apUsedDict)

    #for row in Layout:
    #  row.writeGerber(fid, layername)

    #  # Do cut lines
    #  if config.Config['cutlinelayers'] and (layername in config.Config['cutlinelayers']):
    #    fid.write('%s*\n' % drawing_code_cut)    # Choose drawing aperture
    #    row.writeCutLines(fid, drawing_code_cut, OriginX, OriginY, MaxXExtent, MaxYExtent)

    # Finally, write actual flash data
    for job in Place.jobs:
      job.writeGerber(fid, layername)

      if config.Config['cutlinelayers'] and (layername in config.Config['cutlinelayers']):
        fid.write('%s*\n' % drawing_code_cut)    # Choose drawing aperture
        job.writeCutLines(fid, drawing_code_cut, OriginX, OriginY, MaxXExtent, MaxYExtent)

    if config.Config['cropmarklayers']:
      if layername in config.Config['cropmarklayers']:
        writeCropMarks(fid, drawing_code_crop, OriginX, OriginY, MaxXExtent, MaxYExtent)

    writeGerberFooter(fid)
    fid.close()

  # Write board outline layer if selected
  fullname = config.Config['outlinelayerfile']
  if fullname and fullname.lower() != "none":
    OutputFiles.append(fullname)
    #print 'Writing %s ...' % fullname
    fid = file(fullname, 'wt')
    writeGerberHeader(fid)

    # Write width-1 aperture to file
    AP = aptable.Aperture(aptable.Circle, 'D10', 0.001)
    AP.writeDef(fid)

    # Choose drawing aperture D10
    fid.write('D10*\n')

    # Draw the rectangle
    fid.write('X%07dY%07dD02*\n' % (util.in2gerb(OriginX), util.in2gerb(OriginY)))        # Bottom-left
    fid.write('X%07dY%07dD01*\n' % (util.in2gerb(OriginX), util.in2gerb(MaxYExtent)))     # Top-left
    fid.write('X%07dY%07dD01*\n' % (util.in2gerb(MaxXExtent), util.in2gerb(MaxYExtent)))  # Top-right
    fid.write('X%07dY%07dD01*\n' % (util.in2gerb(MaxXExtent), util.in2gerb(OriginY)))     # Bottom-right
    fid.write('X%07dY%07dD01*\n' % (util.in2gerb(OriginX), util.in2gerb(OriginY)))        # Bottom-left

    writeGerberFooter(fid)
    fid.close()

  # Write scoring layer if selected
  fullname = config.Config['scoringfile']
  if fullname and fullname.lower() != "none":
    OutputFiles.append(fullname)
    #print 'Writing %s ...' % fullname
    fid = file(fullname, 'wt')
    writeGerberHeader(fid)

    # Write width-1 aperture to file
    AP = aptable.Aperture(aptable.Circle, 'D10', 0.001)
    AP.writeDef(fid)

    # Choose drawing aperture D10
    fid.write('D10*\n')

    # Draw the scoring lines
    scoring.writeScoring(fid, Place, OriginX, OriginY, MaxXExtent, MaxYExtent)

    writeGerberFooter(fid)
    fid.close()

  # Get a list of all tools used by merging keys from each job's dictionary
  # of tools.
  if 0:
    Tools = {}
    for job in config.Jobs.values():
      for key in job.xcommands.keys():
        Tools[key] = 1

    Tools = Tools.keys()
    Tools.sort()
  else:
    toolNum = 0

    # First construct global mapping of diameters to tool numbers
    for job in config.Jobs.values():
      for tool,diam in job.xdiam.items():
        if config.GlobalToolRMap.has_key(diam):
          continue

        toolNum += 1
        config.GlobalToolRMap[diam] = "T%02d" % toolNum

    # Cluster similar tools sizes to reduce number of drills
    if config.Config['drillclustertolerance'] > 0:
      config.GlobalToolRMap = drillcluster.cluster( config.GlobalToolRMap, config.Config['drillclustertolerance'] )
      drillcluster.remap( Place.jobs, config.GlobalToolRMap.items() )

    # Now construct mapping of tool numbers to diameters
    for diam,tool in config.GlobalToolRMap.items():
      config.GlobalToolMap[tool] = diam

    # Tools is just a list of tool names
    Tools = config.GlobalToolMap.keys()
    Tools.sort()   

  fullname = config.Config['fabricationdrawingfile']
  if fullname and fullname.lower() != 'none':
    if len(Tools) > strokes.MaxNumDrillTools:
      raise RuntimeError, "Only %d different tool sizes supported for fabrication drawing." % strokes.MaxNumDrillTools

    OutputFiles.append(fullname)
    #print 'Writing %s ...' % fullname
    fid = file(fullname, 'wt')
    writeGerberHeader(fid)
    writeApertures(fid, {drawing_code1: None})
    fid.write('%s*\n' % drawing_code1)    # Choose drawing aperture

    fabdrawing.writeFabDrawing(fid, Place, Tools, OriginX, OriginY, MaxXExtent, MaxYExtent)

    writeGerberFooter(fid)
    fid.close()
    
  # Finally, print out the Excellon
  try:
    fullname = config.MergeOutputFiles['drills']
  except KeyError:
    fullname = 'merged.drills.xln'
  OutputFiles.append(fullname)
  #print 'Writing %s ...' % fullname
  fid = file(fullname, 'wt')

  writeExcellonHeader(fid)

  # Ensure each one of our tools is represented in the tool list specified
  # by the user.
  for tool in Tools:
    try:
      size = config.GlobalToolMap[tool]
    except:
      raise RuntimeError, "INTERNAL ERROR: Tool code %s not found in global tool map" % tool
      
    writeExcellonTool(fid, tool, size)

    #for row in Layout:
    #  row.writeExcellon(fid, size)
    for job in Place.jobs:
        job.writeExcellon(fid, size)
  
  writeExcellonFooter(fid)
  fid.close()

  # Compute stats
  jobarea = 0.0
  #for row in Layout:
  #  jobarea += row.jobarea()
  for job in Place.jobs:
    jobarea += job.jobarea()
    
  totalarea = ((MaxXExtent-OriginX)*(MaxYExtent-OriginY))

  ToolStats = {}
  drillhits = 0
  for tool in Tools:
    ToolStats[tool]=0
    #for row in Layout:
    #  hits = row.drillhits(config.GlobalToolMap[tool])
    #  ToolStats[tool] += hits
    #  drillhits += hits
    for job in Place.jobs:
      hits = job.drillhits(config.GlobalToolMap[tool])
      ToolStats[tool] += hits
      drillhits += hits

  try:
    fullname = config.MergeOutputFiles['toollist']
  except KeyError:
    fullname = 'merged.toollist.drl'
  OutputFiles.append(fullname)
  #print 'Writing %s ...' % fullname
  fid = file(fullname, 'wt')

  print '-'*50
  print '     Job Size : %f" x %f"' % (MaxXExtent-OriginX, MaxYExtent-OriginY)
  print '     Job Area : %.2f sq. in.' % totalarea
  print '   Area Usage : %.1f%%' % (jobarea/totalarea*100)
  print '   Drill hits : %d' % drillhits
  print 'Drill density : %.1f hits/sq.in.' % (drillhits/totalarea)

  print '\nTool List:'
  for tool in Tools:
    if ToolStats[tool]:
      fid.write('%s %.4fin\n' % (tool, config.GlobalToolMap[tool]))
      print '  %s %.4f" %5d hits' % (tool, config.GlobalToolMap[tool], ToolStats[tool])

  fid.close()

  print
  print 'Output Files :'
  for f in OutputFiles:
    print '  ', f

  if (MaxXExtent-OriginX)>config.Config['panelwidth'] or (MaxYExtent-OriginY)>config.Config['panelheight']:
    print '*'*75
    print '*'
    print '* ERROR: Merged job exceeds panel dimensions of %.1f"x%.1f"' % (config.Config['panelwidth'],config.Config['panelheight'])
    print '*'
    print '*'*75
    sys.exit(1)

  # Done!
