layer matching WIP
This commit is contained in:
parent
6c92460941
commit
125ef6af40
4 changed files with 331 additions and 387 deletions
|
|
@ -98,25 +98,28 @@ def parse_allegro_ncparam(data, settings=None):
|
|||
|
||||
|
||||
class ExcellonFile(CamFile):
|
||||
def __init__(self, objects=None, comments=None, import_settings=None, filename=None, generator=None):
|
||||
def __init__(self, objects=None, comments=None, import_settings=None, filename=None, generator_hints=None):
|
||||
super().__init__(filename=filename)
|
||||
self.objects = objects or []
|
||||
self.comments = comments or []
|
||||
self.import_settings = import_settings
|
||||
self.generator = generator # This is a purely informational goodie from the parser. Use it as you wish.
|
||||
self.generator_hints = generator_hints or [] # This is a purely informational goodie from the parser. Use it as you wish.
|
||||
|
||||
@property
|
||||
def generator(self):
|
||||
return self.generator_hints[0] if self.generator_hints else None
|
||||
|
||||
@classmethod
|
||||
def open(kls, filename, plated=None):
|
||||
def open(kls, filename, plated=None, settings=None):
|
||||
filename = Path(filename)
|
||||
|
||||
# Parse allegro parameter files.
|
||||
# Prefer nc_param.txt over ncparam.log since the txt is the machine-readable one.
|
||||
for fn in 'nc_param.txt', 'ncdrill.log':
|
||||
if (param_file := filename.parent / fn).isfile():
|
||||
settings = parse_allegro_ncparam(param_file.read_text())
|
||||
break
|
||||
else:
|
||||
settings = None
|
||||
if settings is None:
|
||||
for fn in 'nc_param.txt', 'ncdrill.log':
|
||||
if (param_file := filename.parent / fn).isfile():
|
||||
settings = parse_allegro_ncparam(param_file.read_text())
|
||||
break
|
||||
|
||||
return kls.from_string(filename.read_text(), settings=settings, filename=filename, plated=plated)
|
||||
|
||||
|
|
@ -269,7 +272,7 @@ class ExcellonParser(object):
|
|||
self.pos = 0, 0
|
||||
self.drill_down = False
|
||||
self.is_plated = None
|
||||
self.generator = None
|
||||
self.generator_hints = []
|
||||
|
||||
def _do_parse(self, data):
|
||||
leftover = None
|
||||
|
|
@ -303,7 +306,7 @@ class ExcellonParser(object):
|
|||
def parse_allegro_tooldef(self, match):
|
||||
# NOTE: We ignore the given tolerances here since they are non-standard.
|
||||
self.program_state = ProgramState.HEADER # TODO is this needed? we need a test file.
|
||||
self.generator = 'allegro'
|
||||
self.generator_hints.append('allegro')
|
||||
|
||||
if (index := int(match['index1'])) != int(match['index2']): # index1 has leading zeros, index2 not.
|
||||
raise SyntaxError('BUG: Allegro excellon tool def has mismatching tool indices. Please file a bug report on our issue tracker and provide this file!')
|
||||
|
|
@ -340,6 +343,7 @@ class ExcellonParser(object):
|
|||
warnings.warn('Re-definition of tool index {index}, overwriting old definition.', SyntaxWarning)
|
||||
|
||||
tools[index] = tool
|
||||
self.generator_hints.append('easyeda')
|
||||
|
||||
@exprs.match('T([0-9]+)(([A-Z][.0-9]+)+)') # Tool definition: T** with at least one parameter
|
||||
def parse_normal_tooldef(self, match):
|
||||
|
|
@ -351,10 +355,14 @@ class ExcellonParser(object):
|
|||
|
||||
params = { m[0]: settings.parse_gerber_value(m[1:]) for m in re.findall('[BCFHSTZ][.0-9]+', match[2]) }
|
||||
|
||||
if set(params.keys()) == set('TFSC') and self.generator is None:
|
||||
self.generator = 'target3001' # target files look like altium files without the comments
|
||||
self.tools[index] = ExcellonTool(diameter=params.get('C'), depth_offset=params.get('Z'), plated=self.is_plated)
|
||||
|
||||
if set(params.keys()) == set('TFSC'):
|
||||
self.generator_hints.append('target3001') # target files look like altium files without the comments
|
||||
|
||||
if len(self.tools) >= 3 and list(self.tools.keys()) == reversed(sorted(self.tools.keys())):
|
||||
self.generator_hints.append('geda')
|
||||
|
||||
@exprs.match('T([0-9]+)')
|
||||
def parse_tool_selection(self, match):
|
||||
index = int(match[1])
|
||||
|
|
@ -401,7 +409,7 @@ class ExcellonParser(object):
|
|||
if self.program_state == ProgramState.HEADER:
|
||||
# It seems that only fritzing puts both a '%' start of header thingy and an M48 statement at the beginning
|
||||
# of the file.
|
||||
self.generator = 'fritzing'
|
||||
self.generator_hints('fritzing')
|
||||
elif self.program_state is not None:
|
||||
warnings.warn(f'M48 "header start" statement found in the middle of the file, currently in {self.program_state}', SyntaxWarning)
|
||||
self.program_state = ProgramState.HEADER
|
||||
|
|
@ -554,19 +562,22 @@ class ExcellonParser(object):
|
|||
def handle_inch_mode(self, match):
|
||||
self.settings.unit = Inch
|
||||
|
||||
@exprs.match('(METRIC|INCH),(LZ|TZ)(0*\.0*)?')
|
||||
@exprs.match('(METRIC|INCH)(,LZ|,TZ)?(0*\.0*)?')
|
||||
def parse_easyeda_format(self, match):
|
||||
# geda likes to omit the LZ/TZ
|
||||
self.settings.unit = MM if match[1] == 'METRIC' else Inch
|
||||
self.settings.zeros = 'leading' if match[2] == 'LZ' else 'trailing'
|
||||
if match[2]:
|
||||
self.settings.zeros = 'leading' if match[2] == ',LZ' else 'trailing'
|
||||
# Newer EasyEDA exports have this in an altium-like FILE_FORMAT comment instead. Some files even have both.
|
||||
# This is used by newer autodesk eagles, fritzing and diptrace
|
||||
if match[3]:
|
||||
if self.generator is None:
|
||||
# newer eagles identify themselvees through a comment, and fritzing uses this wonky double-header-start
|
||||
# with a "%" line followed by an "M48" line. Thus, thus must be diptrace.
|
||||
self.generator = 'diptrace'
|
||||
self.generator_hints.append('diptrace')
|
||||
integer, _, fractional = match[3].partition('.')
|
||||
self.settings.number_format = len(integer), len(fractional)
|
||||
self.generator_hints.append('easyeda')
|
||||
|
||||
@exprs.match('G90')
|
||||
@header_command
|
||||
|
|
@ -615,12 +626,12 @@ class ExcellonParser(object):
|
|||
self.settings.notation = {'Leading': 'trailing', 'Trailing': 'leading'}[match[2]]
|
||||
self.settings.unit = to_unit(match[3])
|
||||
self.settings.zeros = match[4].lower()
|
||||
self.generator = 'siemens'
|
||||
self.generator_hints.append('siemens')
|
||||
|
||||
@exprs.match('; Contents: (Thru|.*) / (Drill|Mill) / (Plated|Non-Plated)')
|
||||
def parse_siemens_meta(self, match):
|
||||
self.is_plated = (match[3] == 'Plated')
|
||||
self.generator = 'siemens'
|
||||
self.generator_hints.append('siemens')
|
||||
|
||||
@exprs.match(';FILE_FORMAT=([0-9]:[0-9])')
|
||||
def parse_altium_easyeda_number_format_comment(self, match):
|
||||
|
|
@ -633,6 +644,7 @@ class ExcellonParser(object):
|
|||
# EasyEDA embeds the layer name in a comment. EasyEDA uses separate files for plated/non-plated. The (default?)
|
||||
# layer names are: "Drill PTH", "Drill NPTH"
|
||||
self.is_plated = 'NPTH' not in match[1]
|
||||
self.generator_hints.append('easyeda')
|
||||
|
||||
@exprs.match(';TYPE=(PLATED|NON_PLATED)')
|
||||
def parse_altium_composite_plating_comment(self, match):
|
||||
|
|
@ -642,25 +654,42 @@ class ExcellonParser(object):
|
|||
|
||||
@exprs.match(';(Layer_Color=[-+0-9a-fA-F]*)')
|
||||
def parse_altium_layer_color(self, match):
|
||||
self.generator = 'altium'
|
||||
self.generator_hints.append('altium')
|
||||
self.comments.append(match[1])
|
||||
|
||||
@exprs.match(';HEADER:')
|
||||
def parse_allegro_start_of_header(self, match):
|
||||
self.program_state = ProgramState.HEADER
|
||||
self.generator = 'allegro'
|
||||
self.generator_hints.append('allegro')
|
||||
|
||||
@exprs.match(';GenerationSoftware,Autodesk,EAGLE,.*\*%')
|
||||
def parse_eagle_version_header(self, match):
|
||||
# NOTE: Only newer eagles export drills as XNC files. Older eagles produce an aperture-only gerber file called
|
||||
# "profile.gbr" instead.
|
||||
self.generator = 'eagle'
|
||||
self.generator_hints.append('eagle')
|
||||
|
||||
@exprs.match(';EasyEDA .*')
|
||||
def parse_easyeda_version_header(self, match):
|
||||
self.generator = 'easyeda'
|
||||
self.generator_hints.append('easyeda')
|
||||
|
||||
@exprs.match(';DRILL .*KiCad .*')
|
||||
def parse_kicad_version_header(self, match):
|
||||
self.generator_hints.append('kicad')
|
||||
|
||||
@exprs.match(';FORMAT={([-0-9]+:[-0-9]+) ?/ (.*) / (inch|.*) / decimal}')
|
||||
def parse_kicad_number_format_comment(self, match):
|
||||
x, _, y = match[1].partition(':')
|
||||
x = None if x == '-' else int(x)
|
||||
y = None if y == '-' else int(y)
|
||||
self.settings.number_format = x, y
|
||||
self.settings.notation = match[2]
|
||||
self.settings.unit = Inch if match[3] == 'inch' else MM
|
||||
|
||||
@exprs.match(';(.*)')
|
||||
def parse_comment(self, match):
|
||||
self.comments.append(match[1].strip())
|
||||
|
||||
if all(cmt.startswith(marker)
|
||||
for cmt, marker in zip(reversed(self.comments), ['Version', 'Job', 'User', 'Date'])):
|
||||
self.generator_hints.append('siemens')
|
||||
|
||||
|
|
|
|||
119
gerbonara/gerber/layer_rules.py
Normal file
119
gerbonara/gerber/layer_rules.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
# From https://github.com/tracespace/tracespace
|
||||
|
||||
MATCH_RULES = {
|
||||
'altium': {
|
||||
'top copper': r'.*\.gtl',
|
||||
'top mask': r'.*\.gts',
|
||||
'top silk': r'.*\.gto',
|
||||
'top paste': r'.*\.gtp',
|
||||
'bottom copper': r'.*\.gbl',
|
||||
'bottom mask': r'.*\.gbs',
|
||||
'bottom silk': r'.*\.gbo',
|
||||
'bottom paste': r'.*\.gbp',
|
||||
'inner copper': r'.*\.gp?([0-9]+)',
|
||||
'outline mech': r'.*\.(gko|gm[0-9]+)',
|
||||
'drill unknown': r'.*\.(txt)',
|
||||
},
|
||||
|
||||
'kicad': {
|
||||
'top copper': r'.*\.gtl|.*f.cu.*',
|
||||
'top mask': r'.*\.gts|.*f.mask.*',
|
||||
'top silk': r'.*\.gto|.*f.silks.*',
|
||||
'top paste': r'.*\.gtp|.*f.paste.*',
|
||||
'bottom copper': r'.*\.gbl|.*b.cu.*',
|
||||
'bottom mask': r'.*\.gbs|.*b.mask.*',
|
||||
'bottom silk': r'.*\.gbo|.*b.silks.*',
|
||||
'bottom paste': r'.*\.gbp|.*b.paste.*',
|
||||
'inner copper': r'.*\.gp?([0-9]+)|.*inn?e?r?([0-9]+).cu.*',
|
||||
'outline mech': r'.*\.(gm[0-9]+)|.*edge.cuts.*',
|
||||
'drill plated': r'.*\.(drl)',
|
||||
},
|
||||
|
||||
'geda': {
|
||||
'top copper': r'.*\.top\.\w+',
|
||||
'top mask': r'.*\.topmask\.\w+',
|
||||
'top silk': r'.*\.topsilk\.\w+',
|
||||
'top paste': r'.*\.toppaste\.\w+',
|
||||
'bottom copper': r'.*\.bottom\.\w+',
|
||||
'bottom mask': r'.*\.bottommask\.\w+',
|
||||
'bottom silk': r'.*\.bottomsilk\.\w+',
|
||||
'bottom paste': r'.*\.bottompaste\.\w+',
|
||||
'inner copper': r'.*\.inner_l([0-9]+)\.\w+', # FIXME verify this
|
||||
'outline mech': r'.*\.outline\.gbr',
|
||||
'drill plated': r'.*\.plated-drill.cnc',
|
||||
'drill nonplated': r'.*\.unplated-drill.cnc',
|
||||
},
|
||||
|
||||
'diptrace': {
|
||||
'top copper': r'.*_top\.\w+',
|
||||
'top mask': r'.*_topmask\.\w+',
|
||||
'top silk': r'.*_topsilk\.\w+',
|
||||
'top paste': r'.*_toppaste\.\w+',
|
||||
'bottom copper': r'.*_bottom\.\w+',
|
||||
'bottom mask': r'.*_bottommask\.\w+',
|
||||
'bottom silk': r'.*_bottomsilk\.\w+',
|
||||
'bottom paste': r'.*_bottompaste\.\w+',
|
||||
'inner copper': r'.*_inner_l([0-9]+).*',
|
||||
'bottom paste': r'.*_boardoutline\.\w+', # FIXME verify this
|
||||
'drill plated': r'.*\.(drl)', # diptrace has unplated drills on the outline layer
|
||||
},
|
||||
|
||||
'orcad': {
|
||||
'top copper': r'.*\.top',
|
||||
'top mask': r'.*\.smt',
|
||||
'top silk': r'.*\.sst',
|
||||
'top paste': r'.*\.spt',
|
||||
'top copper': r'.*\.bot',
|
||||
'top mask': r'.*\.smb',
|
||||
'top silk': r'.*\.ssb',
|
||||
'top paste': r'.*\.spb',
|
||||
'inner copper': r'.*\.in([0-9]+)',
|
||||
'outline gerber': r'.*\.(fab|drd)',
|
||||
'drill plated': r'.*\.tap',
|
||||
'drill nonplated': r'.*\.npt',
|
||||
},
|
||||
|
||||
'eagle': {
|
||||
None: r'.*\.(gpi|dri)|pnp_bom',
|
||||
'top copper': r'.*(\.cmp|\.top|\.toplayer\.ger)|.*(copper_top|top_copper).*',
|
||||
'top mask': r'.*(\.stc|\.tsm|\.topsoldermask\.ger)|.*(soldermask_top|top_mask).*',
|
||||
'top silk': r'.*(\.plc|\.tsk|\.topsilkscreen\.ger)|.*(silkscreen_top|top_silk).*',
|
||||
'top paste': r'.*(\.crc|\.tsp|\.tcream\.ger)|.*(solderpaste_top|top_paste).*',
|
||||
'bottom copper': r'.*(\.sld|\.sol\|\.bottom|\.bottomlayer\.ger)|.*(copper_bottom|bottom_copper).*',
|
||||
'bottom mask': r'.*(\.sts|\.bsm|\.bottomsoldermask\.ger)|.*(soldermask_bottom|bottom_mask).*',
|
||||
'bottom silk': r'.*(\.pls|\.bsk|\.bottomsilkscreen\.ger)|.*(silkscreen_bottom|bottom_silk).*',
|
||||
'bottom paste': r'.*(\.crs|\.bsp|\.bcream\.ger)|.*(solderpaste_bottom|bottom_paste).*',
|
||||
'inner copper': r'.*\.ly([0-9]+)|.*\.internalplane([0-9]+)\.ger',
|
||||
'outline mech': r'.*(\.dim|\.mil|\.gml)|.*\.(?:board)?outline\.ger|profile\.gbr',
|
||||
'drill plated': r'.*\.(txt|exc|drd|xln)',
|
||||
},
|
||||
|
||||
'siemens': {
|
||||
'outline mech': r'.*ContourPlated.ncd',
|
||||
'inner copper': r'.*L([0-9]+).gdo',
|
||||
'bottom silk': r'.*SilkscreenBottom.gdo',
|
||||
'top silk': r'.*SilkscreenTop.gdo',
|
||||
'bottom paste': r'.*SolderPasteBottom.gdo',
|
||||
'top paste': r'.*SolderPasteTop.gdo',
|
||||
'bottom mask': r'.*SoldermaskBottom.gdo',
|
||||
'top mask': r'.*SoldermaskTop.gdo',
|
||||
'drill nonplated': r'.*ThruHoleNonPlated.ncd',
|
||||
'drill plated': r'.*ThruHolePlated.ncd',
|
||||
# list this last to prefer the actual excellon files
|
||||
'drill plated': r'.*DrillDrawingThrough.gdo',
|
||||
# match these last to avoid shadowing other layers via substring match
|
||||
'top copper': r'.*Top.gdo',
|
||||
'bottom copper': r'.*Bottom.gdo',
|
||||
},
|
||||
|
||||
'allegro': {
|
||||
# Allegro doesn't have any widespread convention, so we rely heavily on the layer name auto-guesser here.
|
||||
'drill mech': r'.*\.rou',
|
||||
'drill mech': r'.*\.drl',
|
||||
'generic gerber': r'.*\.art',
|
||||
'excellon params': 'nc_param\.txt',
|
||||
# put .log file last to prefer .txt
|
||||
'excellon params': 'ncdrill\.log',
|
||||
'excellon params': 'ncroute\.log',
|
||||
},
|
||||
}
|
||||
|
|
@ -17,392 +17,193 @@
|
|||
|
||||
import os
|
||||
import re
|
||||
import warnings
|
||||
from collections import namedtuple
|
||||
|
||||
from .excellon import ExcellonFile
|
||||
from .ipc356 import IPCNetlist
|
||||
|
||||
def match_fn_eagle(name, suffix):
|
||||
if suffix in ('cmp', 'top') or \ # Older Eagle versions (v7)
|
||||
name.endswith('toplayer.ger') or \ # OSHPark Eagle CAM rules
|
||||
'copper_top' in name or 'top_copper' in name: # Newer Autodesk Eagle versions (v9)
|
||||
return 'top copper gerber'
|
||||
def match_files(filenames):
|
||||
matches = {}
|
||||
for generator, rules in MATCH_RULES.items():
|
||||
gen = {}
|
||||
matches[generator] = gen
|
||||
for layer, regex in rules.items():
|
||||
for fn in filenames:
|
||||
if (m := re.fullmatch(regex, fn.name.lower())):
|
||||
if layer == 'inner copper':
|
||||
layer = 'inner_' + ''.join(m.groups()) + ' copper'
|
||||
gen[layer] = gen.get(layer, []) + [fn]
|
||||
return matches
|
||||
|
||||
if suffix in ('stc', 'tsm') or \
|
||||
name.endswith('topsoldermask.ger') or \
|
||||
'soldermask_top' in name or 'top_mask' in name:
|
||||
return 'top mask gerber'
|
||||
def best_match(filenames):
|
||||
matches = match_files(filenames)
|
||||
matches = sorted(matches.items(), key=lambda pair: len(pair[1]))
|
||||
generator, files = matches[-1]
|
||||
return generator, files
|
||||
|
||||
if suffix in ('plc', 'tsk') or \
|
||||
name.endswith('topsilkscreen.ger') or \
|
||||
'silkscreen_top' in name or 'top_silk' in name:
|
||||
return 'top silk gerber'
|
||||
def identify_file(data):
|
||||
if 'M48' in data or 'G90' in data:
|
||||
return 'excellon'
|
||||
if 'FSLAX' in data or 'FSTAX' in data:
|
||||
return 'gerber'
|
||||
return None
|
||||
|
||||
if suffix in ('crc', 'tsp') or \
|
||||
name.endswith('tcream.ger') or \
|
||||
'solderpaste_top' in name or 'top_paste' in name:
|
||||
return 'top paste gerber'
|
||||
def common_prefix(l):
|
||||
out = []
|
||||
for cand in l:
|
||||
score = lambda n: sum(elem.startswith(cand[:n]) for elem in l)
|
||||
baseline = score(1)
|
||||
if len(l) - baseline > 5:
|
||||
continue
|
||||
for n in range(2, len(cand)):
|
||||
if len(l) - score(n) > 5:
|
||||
break
|
||||
out.append(cand[:n-1])
|
||||
|
||||
if not out:
|
||||
return ''
|
||||
|
||||
return sorted(out, key=len)[-1]
|
||||
|
||||
if suffix in ('sol', 'bot') or \
|
||||
name.endswith('bottomlayer.ger') or \
|
||||
'copper_bottom' in name or 'bottom_copper' in name:
|
||||
return 'bottom copper gerber'
|
||||
def autoguess(filenames):
|
||||
prefix = common_prefix([f.name for f in filenames])
|
||||
|
||||
if suffix in ('sts', 'bsm') or \
|
||||
name.endswith('bottomsoldermask.ger') or \
|
||||
'soldermask_bottom' in name or 'bottom_mask' in name:
|
||||
return 'bottom mask gerber'
|
||||
matches = { layername_autoguesser(f.name[len(prefix):] if f.name.startswith(prefix) else f.name): f
|
||||
for f in filenames }
|
||||
|
||||
if suffix in ('pls', 'bsk') or \
|
||||
name.endswith('bottomsilkscreen.ger') or \
|
||||
'silkscreen_bottom' in name or \
|
||||
'bottom_silk' in name:
|
||||
return 'bottom silk gerber'
|
||||
inner_layers = [ m for m in matches if 'inner' in m ]
|
||||
if len(inner_layers) >= 4 and not 'copper top' in matches and not 'copper bottom' in matches:
|
||||
matches['copper top'] = matches.pop('copper inner1')
|
||||
last_inner = sorted(inner_layers, key=lambda name: int(name.partition(' ')[0].partition('_')[2]))[-1]
|
||||
matches['copper bottom'] = matches.pop(last_inner)
|
||||
|
||||
if suffix in ('crs', 'bsp') or \
|
||||
name.endswith('bcream.ger') or \
|
||||
'solderpaste_bottom' in name or 'bottom_paste' in name:
|
||||
return 'bottom silk gerber'
|
||||
return matches
|
||||
|
||||
if (m := re.fullmatch(r'ly(\d+)', suffix)):
|
||||
return f'inner{m[1]} copper gerber'
|
||||
def layername_autoguesser(fn):
|
||||
fn, _, _ext = fn.lower().rpartition('.')
|
||||
|
||||
if (m := re.fullmatch(r'.*internalplane(\d+).ger', suffix)):
|
||||
return f'inner{m[1]} copper gerber'
|
||||
side, use = 'unknown', 'unknown'
|
||||
hint = ''
|
||||
if re.match('top|front|pri?m?(ary)?', fn):
|
||||
side = 'top'
|
||||
use = 'copper'
|
||||
if re.match('bot|bottom|back|sec(ondary)?', fn):
|
||||
side = 'bottom'
|
||||
use = 'copper'
|
||||
|
||||
if suffix in ('dim', 'mil', 'gml'):
|
||||
return 'outline mechanical gerber'
|
||||
if re.match('silks?(creen)?', fn):
|
||||
use = 'silk'
|
||||
elif re.match('(solder)?paste', fn):
|
||||
use = 'paste'
|
||||
elif re.match('(solder)?mask', fn):
|
||||
use = 'mask'
|
||||
elif (m := re.match('([tbcps])sm([tbcps])', fn)):
|
||||
use = 'mask'
|
||||
hint = m[1] + m[2]
|
||||
elif (m := re.match('([tbcps])sp([tbcps])', fn)):
|
||||
use = 'paste'
|
||||
hint = m[1] + m[2]
|
||||
elif (m := re.match('([tbcps])sl?k([tbcps])', fn)):
|
||||
use = 'silk'
|
||||
hint = m[1] + m[2]
|
||||
elif (m := re.match('(la?y?e?r?|inn?e?r?)\W*([0-9]+)', fn)):
|
||||
use = 'copper'
|
||||
side = f'inner_{m[1]}'
|
||||
elif re.match('film', fn):
|
||||
use = 'copper'
|
||||
elif re.match('drill|rout?e?|outline'):
|
||||
use = 'drill'
|
||||
side = 'unknown'
|
||||
|
||||
if name.endswith('boardoutline.ger'):
|
||||
return 'outline mechanical gerber'
|
||||
|
||||
if name == 'profile.gbr': # older eagle versions
|
||||
return 'outline mechanical gerber'
|
||||
|
||||
def match_fn_altium(name, suffix):
|
||||
if suffix == 'gtl':
|
||||
return 'top copper gerber'
|
||||
|
||||
if suffix == 'gts':
|
||||
return 'top silk gerber'
|
||||
|
||||
if suffix ==
|
||||
|
||||
|
||||
Hint = namedtuple('Hint', 'layer ext name regex content')
|
||||
|
||||
hints = [
|
||||
Hint(layer='top',
|
||||
ext=['gtl', 'cmp', 'top', ],
|
||||
name=['art01', 'top', 'GTL', 'layer1', 'soldcom', 'comp', 'F.Cu', ],
|
||||
regex='',
|
||||
content=[]
|
||||
),
|
||||
Hint(layer='bottom',
|
||||
ext=['gbl', 'sld', 'bot', 'sol', 'bottom', ],
|
||||
name=['art02', 'bottom', 'bot', 'GBL', 'layer2', 'soldsold', 'B.Cu', ],
|
||||
regex='',
|
||||
content=[]
|
||||
),
|
||||
Hint(layer='internal',
|
||||
ext=['in', 'gt1', 'gt2', 'gt3', 'gt4', 'gt5', 'gt6',
|
||||
'g1', 'g2', 'g3', 'g4', 'g5', 'g6', ],
|
||||
name=['art', 'internal', 'pgp', 'pwr', 'gnd', 'ground',
|
||||
'gp1', 'gp2', 'gp3', 'gp4', 'gt5', 'gp6',
|
||||
'In1.Cu', 'In2.Cu', 'In3.Cu', 'In4.Cu',
|
||||
'group3', 'group4', 'group5', 'group6', 'group7', 'group8',
|
||||
'copper_top_l1', 'copper_inner_l2', 'copper_inner_l3', 'copper_bottom_l4', ],
|
||||
regex='',
|
||||
content=[]
|
||||
),
|
||||
Hint(layer='topsilk',
|
||||
ext=['gto', 'sst', 'plc', 'ts', 'skt', 'topsilk'],
|
||||
name=['sst01', 'topsilk', 'silk', 'slk', 'sst', 'F.SilkS', 'silkscreen_top'],
|
||||
regex='',
|
||||
content=[]
|
||||
),
|
||||
Hint(layer='bottomsilk',
|
||||
ext=['gbo', 'ssb', 'pls', 'bs', 'skb', 'bottomsilk'],
|
||||
name=['bsilk', 'ssb', 'botsilk', 'bottomsilk', 'B.SilkS', 'silkscreen_bottom'],
|
||||
regex='',
|
||||
content=[]
|
||||
),
|
||||
Hint(layer='topmask',
|
||||
ext=['gts', 'stc', 'tmk', 'smt', 'tr', 'topmask', ],
|
||||
name=['sm01', 'cmask', 'tmask', 'mask1', 'maskcom', 'topmask',
|
||||
'mst', 'F.Mask', 'soldermask_top'],
|
||||
regex='',
|
||||
content=[]
|
||||
),
|
||||
Hint(layer='bottommask',
|
||||
ext=['gbs', 'sts', 'bmk', 'smb', 'br', 'bottommask', ],
|
||||
name=['sm', 'bmask', 'mask2', 'masksold', 'botmask', 'bottommask',
|
||||
'msb', 'B.Mask', 'soldermask_bottom'],
|
||||
regex='',
|
||||
content=[]
|
||||
),
|
||||
Hint(layer='toppaste',
|
||||
ext=['gtp', 'tm', 'toppaste', ],
|
||||
name=['sp01', 'toppaste', 'pst', 'F.Paste', 'solderpaste_top'],
|
||||
regex='',
|
||||
content=[]
|
||||
),
|
||||
Hint(layer='bottompaste',
|
||||
ext=['gbp', 'bm', 'bottompaste', ],
|
||||
name=['sp02', 'botpaste', 'bottompaste', 'psb', 'B.Paste', 'solderpaste_bottom'],
|
||||
regex='',
|
||||
content=[]
|
||||
),
|
||||
Hint(layer='outline',
|
||||
ext=['gko', 'outline', ],
|
||||
name=['BDR', 'border', 'out', 'outline', 'Edge.Cuts', 'profile'],
|
||||
regex='',
|
||||
content=[]
|
||||
),
|
||||
Hint(layer='ipc_netlist',
|
||||
ext=['ipc'],
|
||||
name=[],
|
||||
regex='',
|
||||
content=[]
|
||||
),
|
||||
Hint(layer='drawing',
|
||||
ext=['fab'],
|
||||
name=['assembly drawing', 'assembly', 'fabrication',
|
||||
'fab drawing', 'fab'],
|
||||
regex='',
|
||||
content=[]
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def layer_signatures(layer_class):
|
||||
for hint in hints:
|
||||
if hint.layer == layer_class:
|
||||
return hint.ext + hint.name
|
||||
return []
|
||||
|
||||
|
||||
def load_layer(filename):
|
||||
return PCBLayer.from_cam(common.read(filename))
|
||||
|
||||
|
||||
def load_layer_data(data, filename=None):
|
||||
return PCBLayer.from_cam(common.loads(data, filename))
|
||||
|
||||
|
||||
def guess_layer_class(filename):
|
||||
try:
|
||||
layer = guess_layer_class_by_content(filename)
|
||||
if layer:
|
||||
return layer
|
||||
except:
|
||||
pass
|
||||
|
||||
try:
|
||||
directory, filename = os.path.split(filename)
|
||||
name, ext = os.path.splitext(filename.lower())
|
||||
for hint in hints:
|
||||
if hint.regex:
|
||||
if re.findall(hint.regex, filename, re.IGNORECASE):
|
||||
return hint.layer
|
||||
|
||||
patterns = [r'^(\w*[.-])*{}([.-]\w*)?$'.format(x) for x in hint.name]
|
||||
if ext[1:] in hint.ext or any(re.findall(p, name, re.IGNORECASE) for p in patterns):
|
||||
return hint.layer
|
||||
except:
|
||||
pass
|
||||
return 'unknown'
|
||||
|
||||
|
||||
def guess_layer_class_by_content(filename):
|
||||
try:
|
||||
file = open(filename, 'r')
|
||||
for line in file:
|
||||
for hint in hints:
|
||||
if len(hint.content) > 0:
|
||||
patterns = [r'^(.*){}(.*)$'.format(x) for x in hint.content]
|
||||
if any(re.findall(p, line, re.IGNORECASE) for p in patterns):
|
||||
return hint.layer
|
||||
except:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def sort_layers(layers, from_top=True):
|
||||
layer_order = ['outline', 'toppaste', 'topsilk', 'topmask', 'top',
|
||||
'internal', 'bottom', 'bottommask', 'bottomsilk',
|
||||
'bottompaste']
|
||||
append_after = ['drill', 'drawing']
|
||||
|
||||
output = []
|
||||
drill_layers = [layer for layer in layers if layer.layer_class == 'drill']
|
||||
internal_layers = list(sorted([layer for layer in layers
|
||||
if layer.layer_class == 'internal']))
|
||||
|
||||
for layer_class in layer_order:
|
||||
if layer_class == 'internal':
|
||||
output += internal_layers
|
||||
elif layer_class == 'drill':
|
||||
output += drill_layers
|
||||
else:
|
||||
for layer in layers:
|
||||
if layer.layer_class == layer_class:
|
||||
output.append(layer)
|
||||
if not from_top:
|
||||
output = list(reversed(output))
|
||||
|
||||
for layer_class in append_after:
|
||||
for layer in layers:
|
||||
if layer.layer_class == layer_class:
|
||||
output.append(layer)
|
||||
return output
|
||||
|
||||
|
||||
class PCBLayer(object):
|
||||
""" Base class for PCB Layers
|
||||
|
||||
Parameters
|
||||
----------
|
||||
source : CAMFile
|
||||
CAMFile representing the layer
|
||||
|
||||
|
||||
Attributes
|
||||
----------
|
||||
filename : string
|
||||
Source Filename
|
||||
|
||||
"""
|
||||
@classmethod
|
||||
def from_cam(cls, camfile):
|
||||
filename = camfile.filename
|
||||
layer_class = guess_layer_class(filename)
|
||||
if isinstance(camfile, ExcellonFile) or (layer_class == 'drill'):
|
||||
return DrillLayer.from_cam(camfile)
|
||||
elif layer_class == 'internal':
|
||||
return InternalLayer.from_cam(camfile)
|
||||
if isinstance(camfile, IPCNetlist):
|
||||
layer_class = 'ipc_netlist'
|
||||
return cls(filename, layer_class, camfile)
|
||||
|
||||
def __init__(self, filename=None, layer_class=None, cam_source=None, **kwargs):
|
||||
super(PCBLayer, self).__init__(**kwargs)
|
||||
self.filename = filename
|
||||
self.layer_class = layer_class
|
||||
self.cam_source = cam_source
|
||||
self.surface = None
|
||||
self.primitives = cam_source.primitives if cam_source is not None else []
|
||||
|
||||
@property
|
||||
def bounds(self):
|
||||
if self.cam_source is not None:
|
||||
return self.cam_source.bounds
|
||||
else:
|
||||
return None
|
||||
|
||||
def __repr__(self):
|
||||
return '<PCBLayer: {}>'.format(self.layer_class)
|
||||
if re.match('np(th)?|(non|un)\W*plated|(non|un)\Wgalv', fn):
|
||||
side = 'nonplated'
|
||||
elif re.match('pth|plated|galv', fn):
|
||||
side = 'plated'
|
||||
|
||||
if side is None and hint:
|
||||
hint = set(hint)
|
||||
if len(hint) == 1:
|
||||
and hint[0] in 'tpc':
|
||||
side = 'top'
|
||||
else
|
||||
side = 'bottom'
|
||||
|
||||
return f'{use} {side}'
|
||||
|
||||
class LayerStack:
|
||||
@classmethod
|
||||
def from_directory(cls, directory, board_name=None, verbose=False):
|
||||
layers = []
|
||||
names = set()
|
||||
def from_directory(kls, directory, board_name=None, verbose=False):
|
||||
|
||||
# Validate
|
||||
directory = os.path.abspath(directory)
|
||||
if not os.path.isdir(directory):
|
||||
raise TypeError('{} is not a directory.'.format(directory))
|
||||
directory = Path(directory)
|
||||
if not directory.is_dir():
|
||||
raise FileNotFoundError(f'{directory} is not a directory')
|
||||
|
||||
# Load gerber files
|
||||
for filename in os.listdir(directory):
|
||||
try:
|
||||
camfile = gerber_read(os.path.join(directory, filename))
|
||||
layer = PCBLayer.from_cam(camfile)
|
||||
layers.append(layer)
|
||||
name = os.path.splitext(filename)[0]
|
||||
if len(os.path.splitext(filename)) > 1:
|
||||
_name, ext = os.path.splitext(name)
|
||||
if ext[1:] in layer_signatures(layer.layer_class):
|
||||
name = _name
|
||||
if layer.layer_class == 'drill' and 'drill' in ext:
|
||||
name = _name
|
||||
names.add(name)
|
||||
if verbose:
|
||||
print('[PCB]: Added {} layer <{}>'.format(layer.layer_class,
|
||||
filename))
|
||||
except ParseError:
|
||||
if verbose:
|
||||
print('[PCB]: Skipping file {}'.format(filename))
|
||||
except IOError:
|
||||
if verbose:
|
||||
print('[PCB]: Skipping file {}'.format(filename))
|
||||
files = [ path for path in directory.glob('**/*') if path.is_file() ]
|
||||
generator, filemap = best_match(files)
|
||||
|
||||
# Try to guess board name
|
||||
if board_name is None:
|
||||
if len(names) == 1:
|
||||
board_name = names.pop()
|
||||
if len(filemap) < 6:
|
||||
generator = None
|
||||
filemap = autoguess(files)
|
||||
if len(filemap < 6):
|
||||
raise ValueError('Cannot figure out gerber file mapping')
|
||||
|
||||
elif generator == 'geda':
|
||||
# geda is written by geniuses who waste no bytes of unnecessary output so it doesn't actually include the
|
||||
# number format in files that use imperial units. Unfortunately it also doesn't include any hints that the
|
||||
# file was generated by geda, so we have to guess by context whether this is just geda being geda or
|
||||
# potential user error.
|
||||
excellon_settings = FileSettings(number_format=(2, 4))
|
||||
|
||||
elif generator == 'allegro':
|
||||
# Allegro puts information that is absolutely vital for parsing its excellon files... into another file,
|
||||
# next to the actual excellon file. Despite pretty much everyone else having figured out a way to put that
|
||||
# info into the excellon file itself, even if only as a comment.
|
||||
if 'excellon params' in filemap:
|
||||
excellon_settings = parse_allegro_ncparam(filemap['excellon params'][0].read_text())
|
||||
del filemap['excellon params']
|
||||
# Ignore if we can't find the param file -- maybe the user has convinced Allegro to actually put this
|
||||
# information into a comment, or maybe they have made Allegro just use decimal points like XNC does.
|
||||
|
||||
filemap = autoguess([ f for files in filemap for f in files ])
|
||||
if len(filemap < 6):
|
||||
raise SystemError('Cannot figure out gerber file mapping')
|
||||
|
||||
else:
|
||||
excellon_settings = None
|
||||
|
||||
if any(len(value) > 1 for value in filemap.values()):
|
||||
raise SystemError('Ambgiuous layer names')
|
||||
|
||||
filemap = { key: values[0] for key, value in filemap.items() }
|
||||
|
||||
layers = {}
|
||||
for key, path in filemap.items():
|
||||
if 'outline' in key or 'drill' in key and identify_file(path.read_text()) != 'gerber':
|
||||
if 'nonplated' in key:
|
||||
plated = False
|
||||
elif 'plated' in key:
|
||||
plated = True
|
||||
else:
|
||||
plated = None
|
||||
layers[key] = ExcellonFile.open(path, plated=plated, settings=excellon_settings)
|
||||
else:
|
||||
board_name = os.path.basename(directory)
|
||||
# Return PCB
|
||||
return cls(layers, board_name)
|
||||
layers[key] = GerberFile.open(path)
|
||||
|
||||
def __init__(self, layers, name=None):
|
||||
self.layers = sort_layers(layers)
|
||||
self.name = name
|
||||
hints = { layers[key].generator_hints } + { generator }
|
||||
if len(hints) > 1:
|
||||
warnings.warn('File identification returned ambiguous results. Please raise an issue on the gerbonara '
|
||||
'tracker and if possible please provide these input files for reference.')
|
||||
|
||||
board_name = common_prefix([f.name for f in filemap.values()])
|
||||
board_name = re.subs('^\W+', '', board_name)
|
||||
board_name = re.subs('\W+$', '', board_name)
|
||||
return kls(layers, board_name=board_name)
|
||||
|
||||
def __init__(self, layers, board_name=None):
|
||||
self.layers = layers
|
||||
self.board_name = board_name
|
||||
|
||||
def __len__(self):
|
||||
return len(self.layers)
|
||||
|
||||
@property
|
||||
def top_layers(self):
|
||||
board_layers = [l for l in reversed(self.layers) if l.layer_class in
|
||||
('topsilk', 'topmask', 'top')]
|
||||
drill_layers = [l for l in self.drill_layers if 'top' in l.layers]
|
||||
# Drill layer goes under soldermask for proper rendering of tented vias
|
||||
return [board_layers[0]] + drill_layers + board_layers[1:]
|
||||
|
||||
@property
|
||||
def bottom_layers(self):
|
||||
board_layers = [l for l in self.layers if l.layer_class in
|
||||
('bottomsilk', 'bottommask', 'bottom')]
|
||||
drill_layers = [l for l in self.drill_layers if 'bottom' in l.layers]
|
||||
# Drill layer goes under soldermask for proper rendering of tented vias
|
||||
return [board_layers[0]] + drill_layers + board_layers[1:]
|
||||
|
||||
@property
|
||||
def drill_layers(self):
|
||||
return [l for l in self.layers if l.layer_class == 'drill']
|
||||
|
||||
@property
|
||||
def copper_layers(self):
|
||||
return list(reversed([layer for layer in self.layers if
|
||||
layer.layer_class in
|
||||
('top', 'bottom', 'internal')]))
|
||||
|
||||
@property
|
||||
def outline_layer(self):
|
||||
for layer in self.layers:
|
||||
if layer.layer_class == 'outline':
|
||||
return layer
|
||||
|
||||
@property
|
||||
def layer_count(self):
|
||||
""" Number of *COPPER* layers
|
||||
"""
|
||||
return len([l for l in self.layers if l.layer_class in
|
||||
('top', 'bottom', 'internal')])
|
||||
|
||||
@property
|
||||
def board_bounds(self):
|
||||
for layer in self.layers:
|
||||
if layer.layer_class == 'outline':
|
||||
return layer.bounding_box
|
||||
|
||||
for layer in self.layers:
|
||||
if layer.layer_class == 'top':
|
||||
return layer.bounding_box
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
*\.o
|
||||
firmware/firmware.bin
|
||||
firmware/firmware.elf
|
||||
firmware/firmware.hex
|
||||
firmware/firmware.map
|
||||
Loading…
Add table
Add a link
Reference in a new issue