Layer matcher WIP
This commit is contained in:
parent
39756077b4
commit
35f24607fe
7 changed files with 235 additions and 117 deletions
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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.')
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue