Layer matcher WIP

This commit is contained in:
jaseg 2022-01-29 02:42:29 +01:00
parent 39756077b4
commit 35f24607fe
7 changed files with 235 additions and 117 deletions

View file

@ -152,8 +152,8 @@ class FileSettings:
class CamFile:
def __init__(self, filename=None, layer_name=None):
self.filename = filename
def __init__(self, original_path=None, layer_name=None):
self.original_path = original_path
self.layer_name = layer_name
self.import_settings = None
self.objects = []

View file

@ -71,15 +71,22 @@ def parse_allegro_ncparam(data, settings=None):
# still be able to extract the same information from the human-readable ncdrill.log.
if settings is None:
self.settings = FileSettings(number_format=(None, None))
settings = FileSettings(number_format=(None, None))
lz_supp, tz_supp = False, False
nf_int, nf_frac = settings.number_format
for line in data.splitlines():
line = re.sub(r'\s+', ' ', line.strip())
if (match := re.fullmatch(r'FORMAT ([0-9]+\.[0-9]+)', line)):
x, _, y = match[1].partition('.')
settings.number_format = int(x), int(y)
nf_int, nf_frac = int(x), int(y)
elif (match := re.fullmatch(r'INTEGER-PLACES ([0-9]+)', line)):
nf_int = int(match[1])
elif (match := re.fullmatch(r'DECIMAL-PLACES ([0-9]+)', line)):
nf_frac = int(match[1])
elif (match := re.fullmatch(r'COORDINATES (ABSOLUTE|.*)', line)):
# I have not been able to find a single incremental-notation allegro file. Probably that is for the better.
@ -100,17 +107,59 @@ def parse_allegro_ncparam(data, settings=None):
raise SyntaxError('Allegro Excellon parameters specify both leading and trailing zero suppression. We do not '
'know how to parse this. Please raise an issue on our issue tracker and provide an example file.')
settings.number_format = nf_int, nf_frac
settings.zeros = 'leading' if lz_supp else 'trailing'
return settings
def parse_allegro_logfile(data):
found_tools = {}
unit = None
for line in data.splitlines():
line = line.strip()
line = re.sub('\s+', ' ', line)
if (m := re.match(r'OUTPUT-UNITS (METRIC|ENGLISH|INCHES)', line)):
# I have no idea wth is the difference between "ENGLISH" and "INCHES". I think one might just be the one
# Allegro uses in footprint files, with the other one being used in gerber exports.
unit = MM if m[1] == 'METRIC' else Inch
elif (m := re.match(r'T(?P<index1>[0-9]+) (?P<index2>[0-9]+)\. (?P<diameter>[0-9/.]+) [0-9. /+-]* (?P<plated>PLATED|NON_PLATED|OPTIONAL) [0-9]+', line)):
index1, index2 = int(m['index1']), int(m['index2'])
if index1 != index2:
return {}
diameter = float(m['diameter'])
if unit == Inch:
diameter /= 1000
is_plated = None if m['plated'] is None else (m['plated'] in ('PLATED', 'OPTIONAL'))
found_tools[index1] = ExcellonTool(diameter=diameter, plated=is_plated, unit=unit)
return found_tools
class ExcellonFile(CamFile):
def __init__(self, objects=None, comments=None, import_settings=None, filename=None, generator_hints=None):
super().__init__(filename=filename)
def __init__(self, objects=None, comments=None, import_settings=None, original_path=None, generator_hints=None):
super().__init__(original_path=original_path)
self.objects = objects or []
self.comments = comments or []
self.import_settings = import_settings
self.generator_hints = generator_hints or [] # This is a purely informational goodie from the parser. Use it as you wish.
def __str__(self):
name = f'{self.original_path.name} ' if self.original_path else ''
if self.is_plated:
plating = 'plated'
elif self.is_nonplated:
plating = 'nonplated'
elif self.is_mixed_plating:
plating = 'mixed plating'
else:
plating = 'unknown plating'
return f'<ExcellonFile {name}{plating} with {len(list(self.drills()))} drills, {len(list(self.slots()))} slots using {len(self.drill_sizes())} tools>'
def __repr__(self):
return str(self)
def __bool__(self):
return not self.is_empty
@ -165,6 +214,7 @@ class ExcellonFile(CamFile):
@classmethod
def open(kls, filename, plated=None, settings=None):
filename = Path(filename)
logfile_tools = None
# Parse allegro parameter files.
# Prefer nc_param.txt over ncparam.log since the txt is the machine-readable one.
@ -172,16 +222,23 @@ class ExcellonFile(CamFile):
for fn in 'nc_param.txt', 'ncdrill.log':
if (param_file := filename.parent / fn).is_file():
settings = parse_allegro_ncparam(param_file.read_text())
warnings.warn(f'Loaded allegro-style excellon settings file {param_file}')
break
return kls.from_string(filename.read_text(), settings=settings, filename=filename, plated=plated)
# TODO add try/except aronud this
log_file = filename.parent / 'ncdrill.log'
if log_file.is_file():
logfile_tools = parse_allegro_logfile(log_file.read_text())
return kls.from_string(filename.read_text(), settings=settings,
filename=filename, plated=plated, logfile_tools=logfile_tools)
@classmethod
def from_string(kls, data, settings=None, filename=None, plated=None):
parser = ExcellonParser(settings)
def from_string(kls, data, settings=None, filename=None, plated=None, logfile_tools=None):
parser = ExcellonParser(settings, logfile_tools=logfile_tools)
parser.do_parse(data, filename=filename)
return kls(objects=parser.objects, comments=parser.comments, import_settings=settings,
generator_hints=parser.generator_hints, filename=filename)
generator_hints=parser.generator_hints, original_path=filename)
def _generate_statements(self, settings, drop_comments=True):
@ -309,6 +366,12 @@ class ExcellonFile(CamFile):
def drill_sizes(self):
return sorted({ obj.tool.diameter for obj in self.objects })
def drills(self):
return (obj for obj in self.objects if isinstance(obj, Flash))
def slots(self):
return (obj for obj in self.objects if not isinstance(obj, Flash))
@property
def bounds(self):
if not self.objects:
@ -330,7 +393,7 @@ class ProgramState(Enum):
class ExcellonParser(object):
def __init__(self, settings=None):
def __init__(self, settings=None, logfile_tools=None):
# NOTE XNC files do not contain an explicit number format specification, but all values have decimal points.
# Thus, we set the default number format to (None, None). If the file does not contain an explicit specification
# and FileSettings.parse_gerber_value encounters a number without an explicit decimal point, it will throw a
@ -352,6 +415,7 @@ class ExcellonParser(object):
self.generator_hints = []
self.lineno = None
self.filename = None
self.logfile_tools = logfile_tools or {}
def warn(self, msg):
warnings.warn(f'{self.filename}:{self.lineno} "{self.line}": {msg}', SyntaxWarning)
@ -462,9 +526,17 @@ class ExcellonParser(object):
if index == 0: # T0 is used as END marker, just ignore
return
elif index not in self.tools:
raise SyntaxError(f'Undefined tool index {index} selected.')
if not self.tools and index in self.logfile_tools:
# allegro is just wonderful.
self.warn(f'Undefined tool index {index} selected. We found an allegro drill log file next to this, so '
'we will use tool definitions from there.')
self.active_tool = self.logfile_tools[index]
self.active_tool = self.tools[index]
else:
raise SyntaxError(f'Undefined tool index {index} selected.')
else:
self.active_tool = self.tools[index]
coord = lambda name, key=None: fr'({name}(?P<{key or name}>[+-]?[0-9]*\.?[0-9]*))?'
xy_coord = coord('X') + coord('Y')
@ -478,8 +550,7 @@ class ExcellonParser(object):
dy = int(match['Y'] or '0')
for i in range(int(match['count'])):
self.pos[0] += dx
self.pos[1] += dy
self.pos = (self.pos[0] + dx, self.pos[1] + dy)
# FIXME fix API below
if not self.ensure_active_tool():
return
@ -689,7 +760,7 @@ class ExcellonParser(object):
if match[2] not in ('', '2'):
raise SyntaxError(f'Unsupported FMAT format version {match["version"]}')
@exprs.match('G40|G41|G42|{coord("F")}')
@exprs.match(r'G40|G41|G42|F[0-9]+')
def handle_unhandled(self, match):
self.warn(f'{match[0]} excellon command intended for CAM tools found in EDA file.')

View file

@ -11,7 +11,7 @@ MATCH_RULES = {
'bottom silk': r'.*\.gbo',
'bottom paste': r'.*\.gbp',
'inner copper': r'.*\.gp?([0-9]+)',
'drill outline': r'.*\.(gko|gm[0-9]+)',
'mechanical outline': r'.*\.(gko|gm[0-9]+)',
'drill unknown': r'.*\.(txt)',
},
@ -25,7 +25,7 @@ MATCH_RULES = {
'bottom silk': r'.*\.gbo|.*b.silks.*',
'bottom paste': r'.*\.gbp|.*b.paste.*',
'inner copper': r'.*\.gp?([0-9]+)|.*inn?e?r?([0-9]+).cu.*',
'drill outline': r'.*\.(gm[0-9]+)|.*edge.cuts.*',
'mechanical outline': r'.*\.(gm[0-9]+)|.*edge.cuts.*',
'drill plated': r'.*\.(drl)',
},
@ -39,7 +39,7 @@ MATCH_RULES = {
'bottom silk': r'.*\.bottomsilk\.\w+',
'bottom paste': r'.*\.bottompaste\.\w+',
'inner copper': r'.*\.inner_l([0-9]+)\.\w+', # FIXME verify this
'drill outline': r'.*\.outline\.gbr',
'mechanical outline': r'.*\.outline\.gbr',
'drill plated': r'.*\.plated-drill.cnc',
'drill nonplated': r'.*\.unplated-drill.cnc',
},
@ -64,10 +64,10 @@ MATCH_RULES = {
'top silk': r'.*\.PosiTop',
'top paste': r'.*\.PasteTop',
'bottom copper': r'.*\.Bot',
'bottop mask': r'.*\.StopBot',
'bottop silk': r'.*\.PosiBot',
'bottop paste': r'.*\.PasteBot',
'drill outline': r'.*\.Outline',
'bottom mask': r'.*\.StopBot',
'bottom silk': r'.*\.PosiBot',
'bottom paste': r'.*\.PasteBot',
'mechanical outline': r'.*\.Outline',
'drill plated': r'.*\.Drill',
},
@ -77,11 +77,11 @@ MATCH_RULES = {
'top silk': r'.*\.sst',
'top paste': r'.*\.spt',
'bottom copper': r'.*\.bot',
'bottop mask': r'.*\.smb',
'bottop silk': r'.*\.ssb',
'bottop paste': r'.*\.spb',
'bottom mask': r'.*\.smb',
'bottom silk': r'.*\.ssb',
'bottom paste': r'.*\.spb',
'inner copper': r'.*\.in([0-9]+)',
'drill outline': r'.*\.(fab|drd)',
'mechanical outline': r'.*\.(fab|drd)',
'drill plated': r'.*\.tap',
'drill nonplated': r'.*\.npt',
},
@ -97,12 +97,12 @@ MATCH_RULES = {
'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',
'drill outline': r'.*(\.dim|\.mil|\.gml)|.*\.(?:board)?outline\.ger|profile\.gbr',
'mechanical outline': r'.*(\.dim|\.mil|\.gml)|.*\.(?:board)?outline\.ger|profile\.gbr',
'drill plated': r'.*\.(txt|exc|drd|xln)',
},
'siemens': {
'drill outline': r'.*ContourPlated.ncd',
'mechanical outline': r'.*ContourPlated.ncd',
'inner copper': r'.*L([0-9]+).gdo',
'bottom silk': r'.*SilkscreenBottom.gdo',
'top silk': r'.*SilkscreenTop.gdo',

View file

@ -30,7 +30,7 @@ from .layer_rules import MATCH_RULES
STANDARD_LAYERS = [
'outline',
'mechanical outline',
'top copper',
'top mask',
'top silk',
@ -49,10 +49,12 @@ def match_files(filenames):
matches[generator] = gen
for layer, regex in rules.items():
for fn in filenames:
if (m := re.fullmatch(regex, fn.name.lower())):
if (m := re.fullmatch(regex, fn.name, re.IGNORECASE)):
if layer == 'inner copper':
layer = 'inner_' + ''.join(m.groups()) + ' copper'
gen[layer] = gen.get(layer, []) + [fn]
target = 'inner_' + ''.join(e or '' for e in m.groups()) + ' copper'
else:
target = layer
gen[target] = gen.get(target, []) + [fn]
return matches
def best_match(filenames):
@ -88,59 +90,67 @@ def common_prefix(l):
def autoguess(filenames):
prefix = common_prefix([f.name for f in filenames])
matches = { layername_autoguesser(f.name[len(prefix):] if f.name.startswith(prefix) else f.name): f
for f in filenames }
matches = {}
for f in filenames:
name = layername_autoguesser(f.name[len(prefix):] if f.name.startswith(prefix) else f.name)
if name != 'unknown unknown':
matches[name] = matches.get(name, []) + [f]
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 len(inner_layers) >= 4 and 'copper top' not in matches and 'copper bottom' not in matches:
if 'inner_01 copper' in matches:
warnings.warn('Could not find copper layer. Re-assigning outermost inner layers to top/bottom copper.')
matches['top copper'] = matches.pop('inner_01 copper')
last_inner = sorted(inner_layers, key=lambda name: int(name.partition(' ')[0].partition('_')[2]))[-1]
matches['bottom copper'] = matches.pop(last_inner)
return matches
def layername_autoguesser(fn):
fn, _, _ext = fn.lower().rpartition('.')
fn, _, ext = fn.lower().rpartition('.')
if ext == 'log':
return 'unknown unknown'
side, use = 'unknown', 'unknown'
if re.match('top|front|pri?m?(ary)?', fn):
if re.search('top|front|pri?m?(ary)?', fn):
side = 'top'
use = 'copper'
if re.match('bot(tom)?|back|sec(ondary)?', fn):
if re.search('bot(tom)?|back|sec(ondary)?', fn):
side = 'bottom'
use = 'copper'
if re.match('silks?(creen)?', fn):
if re.search('silks?(creen)?', fn):
use = 'silk'
elif re.match('(solder)?paste', fn):
elif re.search('(solder)?paste', fn):
use = 'paste'
elif re.match('(solder)?mask', fn):
elif re.search('(solder)?mask', fn):
use = 'mask'
elif (m := re.match(r'(la?y?e?r?|in(ner)?)\W*(?P<num>[0-9]+)', fn)):
use = 'copper'
side = f'inner_{m["num"]:02d}'
elif re.match('film', fn):
use = 'copper'
elif re.match('out(line)?', fn):
use = 'drill'
side = 'outline'
elif re.match('drill|rout?e?', fn):
elif re.search('drill|rout?e?', fn):
use = 'drill'
side = 'unknown'
if re.match(r'np(th)?|(non|un)\W*plated|(non|un)\Wgalv', fn):
if re.search(r'np(th)?|(non|un)\W*plated|(non|un)\Wgalv', fn):
side = 'nonplated'
elif re.match('pth|plated|galv', fn):
elif re.search('pth|plated|galv', fn):
side = 'plated'
return f'{use} {side}'
elif (m := re.search(r'(la?y?e?r?|in(ner)?)\W*(?P<num>[0-9]+)', fn)):
use = 'copper'
side = f'inner_{int(m["num"]):02d}'
elif re.search('film', fn):
use = 'copper'
elif re.search('out(line)?', fn):
use = 'mechanical'
side = 'outline'
return f'{side} {use}'
class LayerStack:
@classmethod
@ -160,7 +170,7 @@ class LayerStack:
if len(filemap) < 6:
raise ValueError('Cannot figure out gerber file mapping. Partial map is: ', filemap)
elif generator == 'geda':
if 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
@ -182,20 +192,39 @@ class LayerStack:
raise SystemError('Cannot figure out gerber file mapping')
# FIXME use layer metadata from comments and ipc file if available
elif generator == 'altium':
excellon_settings = None
if 'mechanical outline' in filemap:
# Use lowest-numbered mechanical layer as outline, ignore others.
mechs = {}
for layer in filemap['mechanical outline']:
if layer.name.lower().endswith('gko'):
filemap['mechanical outline'] = [layer]
break
if (m := re.match(r'.*\.gm([0-9]+)', layer.name, re.IGNORECASE)):
mechs[int(m[1])] = layer
else:
break
else:
filemap['mechanical outline'] = [sorted(mechs.items(), key=lambda x: x[0])[0][1]]
else:
excellon_settings = None
if any(len(value) > 1 for value in filemap.values()):
raise SystemError('Ambgiuous layer names')
ambiguous = [ key for key, value in filemap.items() if len(value) > 1 and not 'drill' in key ]
if ambiguous:
raise SystemError(f'Ambiguous layer names for {", ".join(ambiguous)}')
drill_layers = []
layers = { key: None for key in STANDARD_LAYERS }
layers = {} # { tuple(key.split()): None for key in STANDARD_LAYERS }
for key, paths in filemap.items():
if len(paths) > 1 and not 'drill' in key:
raise ValueError(f'Multiple matching files found for {key} layer: {", ".join(value)}')
for path in paths:
if 'outline' in key or 'drill' in key and identify_file(path.read_text()) != 'gerber':
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:
@ -206,8 +235,8 @@ class LayerStack:
else:
layer = GerberFile.open(path)
if key == 'drill outline':
layers['outline'] = layer
if key == 'mechanical outline':
layers['mechanical', 'outline'] = layer
elif 'drill' in key:
drill_layers.append(layer)
@ -221,9 +250,9 @@ class LayerStack:
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(r'^\W+', '', board_name)
board_name = re.subs(r'\W+$', '', board_name)
board_name = common_prefix([l.original_path.name for l in layers.values() if l is not None])
board_name = re.sub(r'^\W+', '', board_name)
board_name = re.sub(r'\W+$', '', board_name)
return kls(layers, drill_layers, board_name=board_name)
def __init__(self, graphic_layers, drill_layers, board_name=None):
@ -231,6 +260,13 @@ class LayerStack:
self.drill_layers = drill_layers
self.board_name = board_name
def __str__(self):
names = [ f'{side} {use}' for side, use in self.graphic_layers ]
return f'<LayerStack {self.board_name} [{", ".join(names)}] and {len(self.drill_layers)} drill layers>'
def __repr__(self):
return str(self)
def merge_drill_layers(self):
target = ExcellonFile(comments='Drill files merged by gerbonara')
@ -283,7 +319,9 @@ class LayerStack:
def drill_layers(self):
if self._drill_layers:
return self._drill_layers
return [self.drill_pth, self.drill_npth, self.drill_unknown]
if self.drill_pth or self.drill_npth or self.drill_unknown:
return [self.drill_pth, self.drill_npth, self.drill_unknown]
return []
@drill_layers.setter
def drill_layers(self, value):
@ -305,17 +343,17 @@ class LayerStack:
return (side, use) in self.layers
elif isinstance(index, tuple):
return index in self.layers
return index in self.graphic_layers
return index < len(self.copper_layers)
def __getitem__(self, index):
if isinstance(index, str):
side, _, use = index.partition(' ')
return self.layers[(side, use)]
return self.graphic_layers[(side, use)]
elif isinstance(index, tuple):
return self.layers[index]
return self.graphic_layers[index]
return self.copper_layers[index]
@ -336,15 +374,15 @@ class LayerStack:
@property
def top_side(self):
return { key: self[key] for key in ('top copper', 'top mask', 'top silk', 'top paste', 'outline') }
return { key: self[key] for key in ('top copper', 'top mask', 'top silk', 'top paste', 'mechanical outline') }
@property
def bottom_side(self):
return { key: self[key] for key in ('bottom copper', 'bottom mask', 'bottom silk', 'bottom paste', 'outline') }
return { key: self[key] for key in ('bottom copper', 'bottom mask', 'bottom silk', 'bottom paste', 'mechanical outline') }
@property
def outline(self):
return self['outline']
return self['mechanical outline']
def _merge_layer(self, target, source):
if source is None:
@ -392,7 +430,7 @@ class LayerStack:
for i, layer in enumerate(new_inner, start=1):
self[f'inner_{i} copper'] = layer
self._merge_layer('outline', other['outline'])
self._merge_layer('mechanical outline', other['mechanical outline'])
self.normalize_drill_layers()
other.normalize_drill_layers()

View file

@ -53,9 +53,9 @@ class GerberFile(CamFile):
The GerberFile class represents a single gerber file.
"""
def __init__(self, objects=None, comments=None, import_settings=None, filename=None, generator_hints=None,
def __init__(self, objects=None, comments=None, import_settings=None, original_path=None, generator_hints=None,
layer_hints=None, file_attrs=None):
super().__init__(filename=filename)
super().__init__(original_path=original_path)
self.objects = objects or []
self.comments = comments or []
self.generator_hints = generator_hints or []
@ -157,7 +157,7 @@ class GerberFile(CamFile):
with open(filename, "r") as f:
if enable_includes and enable_include_dir is None:
enable_include_dir = filename.parent
return kls.from_string(f.read(), enable_include_dir, filename=filename.name)
return kls.from_string(f.read(), enable_include_dir, filename=filename)
@classmethod
def from_string(kls, data, enable_include_dir=None, filename=None):
@ -217,7 +217,8 @@ class GerberFile(CamFile):
yield 'M02*'
def __str__(self):
return f'<GerberFile with {len(self.apertures)} apertures, {len(self.objects)} objects>'
name = f'{self.original_path.name} ' if self.original_path else ''
return f'<GerberFile {name}with {len(self.apertures)} apertures, {len(self.objects)} objects>'
def save(self, filename, settings=None, drop_comments=True):
with open(filename, 'w', encoding='utf-8') as f: # Encoding is specified as UTF-8 by spec.
@ -637,6 +638,7 @@ class GerberParser:
self.target.import_settings = self.file_settings
self.target.unit = self.file_settings.unit
self.target.file_attrs = self.file_attrs
self.target.original_path = filename
if not self.eof_found:
self.warn('File is missing mandatory M02 EOF marker. File may be truncated.')

View file

@ -31,11 +31,11 @@ REFERENCE_DIRS = {
'IRNASIoTbank1.2.Bot': 'bottom copper',
'IRNASIoTbank1.2.Drill': 'drill plated',
'IRNASIoTbank1.2.Info': None,
'IRNASIoTbank1.2.Outline': 'drill outline',
'IRNASIoTbank1.2.Outline': 'mechanical outline',
'IRNASIoTbank1.2.PasteBot': 'bottom paste',
'IRNASIoTbank1.2.PasteTop': 'top paste',
'IRNASIoTbank1.2.PosiBot': 'bottom silkscreen',
'IRNASIoTbank1.2.PosiTop': 'top silkscreen',
'IRNASIoTbank1.2.PosiBot': 'bottom silk',
'IRNASIoTbank1.2.PosiTop': 'top silk',
'IRNASIoTbank1.2.StopBot': 'bottom mask',
'IRNASIoTbank1.2.StopTop': 'top mask',
'IRNASIoTbank1.2.Tool': None,
@ -45,7 +45,7 @@ REFERENCE_DIRS = {
'allegro': {
'08_057494d-ipc356.ipc': None,
'08_057494d.rou': 'drill outline',
'08_057494d.rou': 'mechanical outline',
'Read_Me.1': None,
'art_param.txt': None,
'assy1.art': None,
@ -73,7 +73,7 @@ REFERENCE_DIRS = {
'MINNOWMAX_REVA2_PUBLIC_TOPSIDE.pdf': None,
'MinnowMax_RevA1_IPC356A.ipc': None,
'MinnowMax_RevA1_DRILL/MinnowMax_RevA1_NCDRILL.drl': 'drill unknown',
'MinnowMax_RevA1_DRILL/MinnowMax_RevA1_NCROUTE.rou': 'drill outline',
'MinnowMax_RevA1_DRILL/MinnowMax_RevA1_NCROUTE.rou': 'drill unknown',
'MinnowMax_RevA1_DRILL/nc_param.txt': None,
'MinnowMax_RevA1_DRILL/ncdrill.log': None,
'MinnowMax_RevA1_DRILL/ncroute.log': None,
@ -116,15 +116,15 @@ REFERENCE_DIRS = {
'Gerber/LimeSDR-QPCIe_1v2.GBO': 'bottom silk',
'Gerber/LimeSDR-QPCIe_1v2.GBP': 'bottom paste',
'Gerber/LimeSDR-QPCIe_1v2.GBS': 'bottom mask',
'Gerber/LimeSDR-QPCIe_1v2.GM1': 'dirll outlinej',
'Gerber/LimeSDR-QPCIe_1v2.GM1': 'mechanical outline',
'Gerber/LimeSDR-QPCIe_1v2.GM14': None,
'Gerber/LimeSDR-QPCIe_1v2.GM15': None,
'Gerber/LimeSDR-QPCIe_1v2.GPB': None,
'Gerber/LimeSDR-QPCIe_1v2.GPT': None,
'Gerber/LimeSDR-QPCIe_1v2.GTL': 'bottom copper',
'Gerber/LimeSDR-QPCIe_1v2.GTO': 'bottom silk',
'Gerber/LimeSDR-QPCIe_1v2.GTP': 'bottom paste',
'Gerber/LimeSDR-QPCIe_1v2.GTS': 'bottom mask',
'Gerber/LimeSDR-QPCIe_1v2.GTL': 'top copper',
'Gerber/LimeSDR-QPCIe_1v2.GTO': 'top silk',
'Gerber/LimeSDR-QPCIe_1v2.GTP': 'top paste',
'Gerber/LimeSDR-QPCIe_1v2.GTS': 'top mask',
'Gerber/LimeSDR-QPCIe_1v2.REP': None,
'Gerber/LimeSDR-QPCIe_1v2.RUL': None,
'Gerber/LimeSDR-QPCIe_1v2.apr': None,
@ -134,22 +134,23 @@ REFERENCE_DIRS = {
'NC Drill/LimeSDR-QPCIe_1v2.LDP': None,
},
'diptrace': {
'mainboard.drl': 'drill plated',
'mainboard_BoardOutline.gbr': 'drill outline',
'mainboard_Bottom.gbr': 'bottom copper',
'mainboard_BottomMask.gbr': 'bottom mask',
'mainboard_Top.gbr': 'top copper',
'mainboard_TopMask.gbr': 'top mask',
'mainboard_TopSilk.gbr': 'top silk',
},
# TODO there are three designs in this folder. make test work with that.
# 'diptrace': {
# 'mainboard.drl': 'drill plated',
# 'mainboard_BoardOutline.gbr': 'mechanical outline',
# 'mainboard_Bottom.gbr': 'bottom copper',
# 'mainboard_BottomMask.gbr': 'bottom mask',
# 'mainboard_Top.gbr': 'top copper',
# 'mainboard_TopMask.gbr': 'top mask',
# 'mainboard_TopSilk.gbr': 'top silk',
# },
'eagle-newer': {
'copper_bottom.gbr': 'bottom copper',
'copper_top.gbr': 'top copper',
'drills.xln': 'drill unknown',
'gerber_job.gbrjob': None,
'profile.gbr': 'drill outline',
'profile.gbr': 'mechanical outline',
'silkscreen_bottom.gbr': 'bottom silk',
'silkscreen_top.gbr': 'top silk',
'soldermask_bottom.gbr': 'bottom mask',
@ -163,7 +164,7 @@ REFERENCE_DIRS = {
'copper_inner_l2.gbr': 'inner_2 copper',
'copper_inner_l3.gbr': 'inner_3 copper',
'copper_top_l1.gbr': 'top copper',
'profile.gbr': 'drill outline',
'profile.gbr': 'mechanical outline',
'silkscreen_bottom.gbr': 'bottom silk',
'silkscreen_top.gbr': 'top silk',
'soldermask_bottom.gbr': 'bottom mask',
@ -173,7 +174,7 @@ REFERENCE_DIRS = {
},
'easyeda': {
'Gerber_BoardOutline.GKO': 'drill outline',
'Gerber_BoardOutline.GKO': 'mechanical outline',
'Gerber_BottomLayer.GBL': 'bottom copper',
'Gerber_BottomSolderMaskLayer.GBS': 'bottom mask',
'Gerber_Drill_NPTH.DRL': 'drill nonplated',
@ -190,7 +191,7 @@ REFERENCE_DIRS = {
},
'fritzing': {
'combined.GKO': 'drill outline',
'combined.GKO': 'mechanical outline',
'combined.gbl': 'bottom copper',
'combined.gbo': 'bottom silk',
'combined.gbs': 'bottom mask',
@ -222,7 +223,7 @@ REFERENCE_DIRS = {
'power-art.gbo': 'bottom silk',
'power-art.gbp': 'bottom paste',
'power-art.gbs': 'bottom mask',
'power-art.gko': 'drill outline',
'power-art.gko': 'mechanical outline',
'power-art.gtl': 'top copper',
'power-art.gto': 'top silk',
'power-art.gtp': 'top paste',
@ -232,7 +233,7 @@ REFERENCE_DIRS = {
},
'siemens': {
'80101_0125_F200_ContourPlated.ncd': 'drill outline',
'80101_0125_F200_ContourPlated.ncd': 'mechanical outline',
'80101_0125_F200_DrillDrawingThrough.gdo': None,
'80101_0125_F200_L01_Top.gdo': 'top copper',
'80101_0125_F200_L02.gdo': 'inner_2 copper',
@ -257,7 +258,7 @@ REFERENCE_DIRS = {
},
'siemens-2': {
'Gerber/BoardOutlline.gdo': 'drill outline',
'Gerber/BoardOutlline.gdo': 'mechanical outline',
'Gerber/DrillDrawingThrough.gdo': None,
'Gerber/EtchLayerBottom.gdo': 'bottom copper',
'Gerber/EtchLayerTop.gdo': 'top copper',
@ -267,7 +268,7 @@ REFERENCE_DIRS = {
'Gerber/SolderPasteTop.gdo': 'top paste',
'Gerber/SoldermaskBottom.gdo': 'bottom mask',
'Gerber/SoldermaskTop.gdo': 'top mask',
'NCDrill/ContourPlated.ncd': 'drill outline',
'NCDrill/ContourPlated.ncd': 'mechanical outline',
'NCDrill/ThruHoleNonPlated.ncd': 'drill nonplated',
'NCDrill/ThruHolePlated.ncd': 'drill plated',
},
@ -278,7 +279,7 @@ REFERENCE_DIRS = {
'design_export.gbo': 'bottom silk',
'design_export.gbp': 'bottom paste',
'design_export.gbs': 'bottom mask',
'design_export.gko': 'drill outline',
'design_export.gko': 'mechanical outline',
'design_export.gtl': 'top copper',
'design_export.gto': 'top silk',
'design_export.gtp': 'top paste',
@ -295,27 +296,34 @@ def test_layer_classifier(ref_dir):
path = reference_path(ref_dir)
print('Reference path is', path)
file_map = { filename: role for filename, role in file_map.items() if role is not None }
rev_file_map = { value: key for key, value in file_map.items() }
rev_file_map = { tuple(value.split()): key for key, value in file_map.items() }
drill_files = { filename: role for filename, role in file_map.items() if role.startswith('drill') }
stack = LayerStack.from_directory(path)
print('loaded layers:', ', '.join(f'{side} {use}' for side, use in stack.graphic_layers))
for side in 'top', 'bottom':
for layer in 'copper', 'silk', 'mask', 'paste':
if 'allegro-2' in ref_dir and layer in ('silk', 'mask', 'paste'):
# This particular example has very poorly named files
continue
if (side, layer) in rev_file_map:
assert (side, layer) in stack
found = stack[side, layer]
assert isinstance(found, GerberFile)
assert found.filename == Path(rev_file_map[side, layer]).name
assert found.original_path.name == Path(rev_file_map[side, layer]).name
else: # not in file_map
assert (side, layer) not in stack
for filename, role in drill_files:
assert any(layer.filename == Path(filename).name for layer in stack.drill_layers)
assert len(stack.drill_layers) == len(drill_files)
assert len(stack.drill_layers) == len(drill_files)
for layer in stack.drill_files:
for filename, role in drill_files.items():
print('drill:', filename, role)
print([(layer.original_path, layer.original_path == Path(filename).name) for layer in stack.drill_layers])
assert any(layer.original_path.name == Path(filename).name for layer in stack.drill_layers)
for layer in stack.drill_layers:
assert isinstance(layer, ExcellonFile)

View file

@ -46,7 +46,6 @@ class RegexMatcher:
def handle(self, inst, line):
for regex, handler in self.mapping.items():
if (match := re.fullmatch(regex, line)):
#print(' handler', handler.__name__)
handler(inst, match)
return True
else: