#!/usr/bin/env python
"""
Parse the GerbMerge configuration file.

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

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 ConfigParser
import re
import string

import jobs
import aptable

# Configuration dictionary. Specify floats as strings. Ints can be specified
# as ints or strings.
Config = {
   'xspacing': '0.125',              # Spacing in horizontal direction
   'yspacing': '0.125',              # Spacing in vertical direction
   'panelwidth': '12.6',             # X-Dimension maximum panel size (Olimex)
   'panelheight': '7.8',             # Y-Dimension maximum panel size (Olimex)
   'cropmarklayers': None,           # e.g., *toplayer,*bottomlayer
   'cropmarkwidth': '0.01',          # Width (inches) of crop lines
   'cutlinelayers': None,            # as for cropmarklayers
   'cutlinewidth': '0.01',           # Width (inches) of cut lines
   'minimumfeaturesize': 0,          # Minimum dimension for selected layers
   'toollist': None,                 # Name of file containing default tool list
   'drillclustertolerance': '.002',  # Tolerance for clustering drill sizes
   'allowmissinglayers': 0,          # Set to 1 to allow multiple jobs to have non-matching layers
   'fabricationdrawingfile': None,   # Name of file to which to write fabrication drawing, or None
   'fabricationdrawingtext': None,   # Name of file containing text to write to fab drawing
   'excellondecimals': 4,            # Number of digits after the decimal point in input Excellon files
   'excellonleadingzeros': 0,        # Generate leading zeros in merged Excellon output file
   'outlinelayerfile': None,         # Name of file to which to write simple box outline, or None
   'scoringfile': None,              # Name of file to which to write scoring data, or None
   'leftmargin': 0,                  # Inches of extra room to leave on left side of panel for tooling
   'topmargin': 0,                   # Inches of extra room to leave on top side of panel for tooling
   'rightmargin': 0,                 # Inches of extra room to leave on right side of panel for tooling
   'bottommargin': 0,                # Inches of extra room to leave on bottom side of panel for tooling
   'fiducialpoints': None,           # List of X,Y co-ordinates at which to draw fiducials
   'fiducialcopperdiameter': 0.08,   # Diameter of copper part of fiducial
   'fiducialmaskdiameter': 0.32,     # Diameter of fiducial soldermask opening
   }

# This dictionary is indexed by lowercase layer name and has as values a file
# name to use for the output.
MergeOutputFiles = {
  'boardoutline': 'merged.boardoutline.ger',
  'drills':       'merged.drills.xln',
  'placement':    'merged.placement.txt',
  'toollist':     'merged.toollist.drl'
  }

# The global aperture table, indexed by aperture code (e.g., 'D10')
GAT = {}

# The global aperture macro table, indexed by macro name (e.g., 'M3', 'M4R' for rotated macros)
GAMT = {}

# The list of all jobs loaded, indexed by job name (e.g., 'PowerBoard')
Jobs = {}

# The set of all Gerber layer names encountered in all jobs. Doesn't
# include drills.
LayerList = {'boardoutline': 1}

# The tool list as read in from the DefaultToolList file in the configuration
# file. This is a dictionary indexed by tool name (e.g., 'T03') and
# a floating point number as the value, the drill diameter in inches.
DefaultToolList = {}

# The GlobalToolMap dictionary maps tool name to diameter in inches. It
# is initially empty and is constructed after all files are read in. It
# only contains actual tools used in jobs.
GlobalToolMap = {}

# The GlobalToolRMap dictionary is a reverse dictionary of ToolMap, i.e., it maps
# diameter to tool name.
GlobalToolRMap = {}

##############################################################################

# This configuration option determines whether trimGerber() is called
TrimGerber = 1

# This configuration option determines whether trimExcellon() is called
TrimExcellon = 1

# This configuration option determines the minimum size of feature dimensions for
# each layer. It is a dictionary indexed by layer name (e.g. '*topsilkscreen') and 
# has a floating point number as the value (in inches).
MinimumFeatureDimension = {}

# This configuration option is a positive integer that determines the maximum
# amout of time to allow for random placements (seconds). A SearchTimeout of 0
# indicates that no timeout should occur and random placements will occur
# forever until a KeyboardInterrupt is raised.
SearchTimeout = 0

# Construct the reverse-GAT/GAMT translation table, keyed by aperture/aperture macro
# hash string. The value is the aperture code (e.g., 'D10') or macro name (e.g., 'M5').
def buildRevDict(D):
  RevD = {}
  for key,val in D.items():
    RevD[val.hash()] = key
  return RevD

def parseStringList(L):
  """Parse something like '*toplayer, *bottomlayer' into a list of names
     without quotes, spaces, etc."""

  if 0:
    if L[0]=="'":
      if L[-1] != "'":
        raise RuntimeError, "Illegal configuration string '%s'" % L
      L = L[1:-1]

    elif L[0]=='"':
      if L[-1] != '"':
        raise RuntimeError, "Illegal configuration string '%s'" % L
      L = L[1:-1]

  # This pattern matches quotes at the beginning and end...quotes must match
  quotepat = re.compile(r'^([' "'" '"' r']?)([^\1]*)\1$')
  delimitpat = re.compile(r'[ \t]*[,;][ \t]*')

  match = quotepat.match(L)
  if match:
    L = match.group(2)

  return delimitpat.split(L)

# Parse an Excellon tool list file of the form
#
#   T01 0.035in
#   T02 0.042in
def parseToolList(fname):  
  TL = {}

  try:
    fid = file(fname, 'rt')
  except Exception, detail:
    raise RuntimeError, "Unable to open tool list file '%s':\n  %s" % (fname, str(detail))

  pat_in  = re.compile(r'\s*(T\d+)\s+([0-9.]+)\s*in\s*')
  pat_mm  = re.compile(r'\s*(T\d+)\s+([0-9.]+)\s*mm\s*')
  pat_mil = re.compile(r'\s*(T\d+)\s+([0-9.]+)\s*(?:mil)?')
  for line in fid.xreadlines():
    line = string.strip(line)
    if (not line) or (line[0] in ('#', ';')): continue

    mm = 0
    mil = 0
    match = pat_in.match(line)
    if not match:
      mm = 1
      match = pat_mm.match(line)
      if not match:
        mil = 1
        match = pat_mil.match(line)
        if not match:
          continue
          #raise RuntimeError, "Illegal tool list specification:\n  %s" % line

    tool, size = match.groups()

    try:
      size = float(size)
    except:
      raise RuntimeError, "Tool size in file '%s' is not a valid floating-point number:\n  %s" % (fname,line)

    if mil:
      size = size*0.001  # Convert mil to inches
    elif mm:    
      size = size/25.4   # Convert mm to inches

    # Canonicalize tool so that T1 becomes T01
    tool = 'T%02d' % int(tool[1:])

    if TL.has_key(tool):
      raise RuntimeError, "Tool '%s' defined more than once in tool list file '%s'" % (tool,fname)

    TL[tool]=size
  fid.close()

  return TL

# This function parses the job configuration file and does
# everything needed to:
#
#   * parse global options and store them in the Config dictionary
#     as natural types (i.e., ints, floats, lists)
#
#   * Read Gerber/Excellon data and populate the Jobs dictionary
#
#   * Read Gerber/Excellon data and populate the global aperture
#     table, GAT, and the global aperture macro table, GAMT
#
#   * read the tool list file and populate the DefaultToolList dictionary
def parseConfigFile(fname, Config=Config, Jobs=Jobs):
  global DefaultToolList

  CP = ConfigParser.ConfigParser()
  CP.readfp(file(fname,'rt'))

  # First parse global options
  if CP.has_section('Options'):
    for opt in CP.options('Options'):
      # Is it one we expect
      if Config.has_key(opt):
        # Yup...override it
        Config[opt] = CP.get('Options', opt)

      elif CP.defaults().has_key(opt):
        pass   # Ignore DEFAULTS section keys

      elif opt in ('fabricationdrawing', 'outlinelayer'):
        print '*'*73
        print '\nThe FabricationDrawing and OutlineLayer configuration options have been'
        print 'renamed as of GerbMerge version 1.0. Please consult the documentation for'
        print 'a description of the new options, then modify your configuration file.\n'
        print '*'*73
        sys.exit(1)
      else:
        raise RuntimeError, "Unknown option '%s' in [Options] section of configuration file" % opt
  else:
    raise RuntimeError, "Missing [Options] section in configuration file"

  # Ensure we got a tool list
  if not Config.has_key('toollist'):
    raise RuntimeError, "INTERNAL ERROR: Missing tool list assignment in [Options] section"

  # Make integers integers, floats floats
  for key,val in Config.items():
    try:
      val = int(val)
      Config[key]=val
    except:
      try:
        val = float(val)
        Config[key]=val
      except:
        pass

  # Process lists of strings
  if Config['cutlinelayers']:
    Config['cutlinelayers'] = parseStringList(Config['cutlinelayers'])
  if Config['cropmarklayers']:
    Config['cropmarklayers'] = parseStringList(Config['cropmarklayers'])
    
  # Process list of minimum feature dimensions
  if Config['minimumfeaturesize']:
    temp = Config['minimumfeaturesize'].split(",")
    try:
      for index in range(0, len(temp), 2):
        MinimumFeatureDimension[ temp[index] ] = float( temp[index + 1] )
    except:
      raise RuntimeError, "Illegal configuration string:" + Config['minimumfeaturesize']

  # Process MergeOutputFiles section to set output file names
  if CP.has_section('MergeOutputFiles'):
    for opt in CP.options('MergeOutputFiles'):
      # Each option is a layer name and the output file for this name
      if opt[0]=='*' or opt in ('boardoutline', 'drills', 'placement', 'toollist'):
        MergeOutputFiles[opt] = CP.get('MergeOutputFiles', opt)

  # Now, we go through all jobs and collect Gerber layers
  # so we can construct the Global Aperture Table.
  apfiles = []

  for jobname in CP.sections():
    if jobname=='Options': continue
    if jobname=='MergeOutputFiles': continue
    if jobname=='GerbMergeGUI': continue

    # Ensure all jobs have a board outline
    if not CP.has_option(jobname, 'boardoutline'):
      raise RuntimeError, "Job '%s' does not have a board outline specified" % jobname
    
    if not CP.has_option(jobname, 'drills'):
      raise RuntimeError, "Job '%s' does not have a drills layer specified" % jobname

    for layername in CP.options(jobname):
      if layername[0]=='*' or layername=='boardoutline':
        fname = CP.get(jobname, layername)
        apfiles.append(fname)

        if layername[0]=='*':
          LayerList[layername]=1

  # Now construct global aperture tables, GAT and GAMT. This step actually
  # reads in the jobs for aperture data but doesn't store Gerber
  # data yet.
  aptable.constructApertureTable(apfiles)
  del apfiles

  if 0:
    keylist = GAMT.keys()
    keylist.sort()
    for key in keylist:
      print '%s' % GAMT[key]
    sys.exit(0)

  # Parse the tool list
  if Config['toollist']:
    DefaultToolList = parseToolList(Config['toollist'])

  # Now get jobs. Each job implies layer names, and we
  # expect consistency in layer names from one job to the
  # next. Two reserved layer names, however, are
  # BoardOutline and Drills.

  Jobs.clear()

  do_abort = 0
  errstr = 'ERROR'
  if Config['allowmissinglayers']:
    errstr = 'WARNING'

  for jobname in CP.sections():
    if jobname=='Options': continue
    if jobname=='MergeOutputFiles': continue
    if jobname=='GerbMergeGUI': continue

    print 'Reading data from', jobname, '...'

    J = jobs.Job(jobname)

    # Parse the job settings, like tool list, first, since we are not
    # guaranteed to have ConfigParser return the layers in the same order that
    # the user wrote them, and we may get Gerber files before we get a tool
    # list! Same thing goes for ExcellonDecimals. We need to know what this is
    # before parsing any Excellon files.
    for layername in CP.options(jobname):
      fname = CP.get(jobname, layername)

      if layername == 'toollist':
        J.ToolList = parseToolList(fname)
      elif layername=='excellondecimals':
        try:
          J.ExcellonDecimals = int(fname)
        except:
          raise RuntimeError, "Excellon decimals '%s' in config file is not a valid integer" % fname
      elif layername=='repeat':
        try:
          J.Repeat = int(fname)
        except:
          raise RuntimeError, "Repeat count '%s' in config file is not a valid integer" % fname

    for layername in CP.options(jobname):
      fname = CP.get(jobname, layername)

      if layername=='boardoutline':
        J.parseGerber(fname, layername, updateExtents=1)
      elif layername[0]=='*':
        J.parseGerber(fname, layername, updateExtents=0)
      elif layername=='drills':
        J.parseExcellon(fname)

    # Emit warnings if some layers are missing
    LL = LayerList.copy()
    for layername in J.apxlat.keys():
      assert LL.has_key(layername)
      del LL[layername]

    if LL:
      if errstr=='ERROR':
        do_abort=1

      print '%s: Job %s is missing the following layers:' % (errstr, jobname)
      for layername in LL.keys():
        print '  %s' % layername

    # Store the job in the global Jobs dictionary, keyed by job name
    Jobs[jobname] = J

  if do_abort:
    raise RuntimeError, 'Exiting since jobs are missing layers. Set AllowMissingLayers=1\nto override.'

if __name__=="__main__":
  CP = parseConfigFile(sys.argv[1])
  print Config
  sys.exit(0)

  if 0:
    for key, val in CP.defaults().items():
      print '%s: %s' % (key,val)

    for section in CP.sections():
      print '[%s]' % section
      for opt in CP.options(section):
        print '  %s=%s' % (opt, CP.get(section, opt))
