From 125ef6af40307402f8c9854fae9e208573ed9d37 Mon Sep 17 00:00:00 2001 From: jaseg Date: Fri, 21 Jan 2022 19:20:28 +0100 Subject: layer matching WIP --- gerbonara/gerber/excellon.py | 75 ++- gerbonara/gerber/layer_rules.py | 119 +++++ gerbonara/gerber/layers.py | 549 +++++++-------------- .../gerber/tests/resources/diptrace/.gitignore | 5 - 4 files changed, 346 insertions(+), 402 deletions(-) create mode 100644 gerbonara/gerber/layer_rules.py delete mode 100644 gerbonara/gerber/tests/resources/diptrace/.gitignore diff --git a/gerbonara/gerber/excellon.py b/gerbonara/gerber/excellon.py index 01d42c8..0900859 100755 --- a/gerbonara/gerber/excellon.py +++ b/gerbonara/gerber/excellon.py @@ -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') + diff --git a/gerbonara/gerber/layer_rules.py b/gerbonara/gerber/layer_rules.py new file mode 100644 index 0000000..6dd16c6 --- /dev/null +++ b/gerbonara/gerber/layer_rules.py @@ -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', + }, +} diff --git a/gerbonara/gerber/layers.py b/gerbonara/gerber/layers.py index f980042..9e34bd8 100644 --- a/gerbonara/gerber/layers.py +++ b/gerbonara/gerber/layers.py @@ -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' - - if suffix in ('stc', 'tsm') or \ - name.endswith('topsoldermask.ger') or \ - 'soldermask_top' in name or 'top_mask' in name: - return 'top mask gerber' - - if suffix in ('plc', 'tsk') or \ - name.endswith('topsilkscreen.ger') or \ - 'silkscreen_top' in name or 'top_silk' in name: - return 'top silk gerber' - - if suffix in ('crc', 'tsp') or \ - name.endswith('tcream.ger') or \ - 'solderpaste_top' in name or 'top_paste' in name: - return 'top paste gerber' - - if suffix in ('sol', 'bot') or \ - name.endswith('bottomlayer.ger') or \ - 'copper_bottom' in name or 'bottom_copper' in name: - return 'bottom copper gerber' - - if suffix in ('sts', 'bsm') or \ - name.endswith('bottomsoldermask.ger') or \ - 'soldermask_bottom' in name or 'bottom_mask' in name: - return 'bottom mask gerber' - - if suffix in ('pls', 'bsk') or \ - name.endswith('bottomsilkscreen.ger') or \ - 'silkscreen_bottom' in name or \ - 'bottom_silk' in name: - return 'bottom silk gerber' - - if suffix in ('crs', 'bsp') or \ - name.endswith('bcream.ger') or \ - 'solderpaste_bottom' in name or 'bottom_paste' in name: - return 'bottom silk gerber' - - if (m := re.fullmatch(r'ly(\d+)', suffix)): - return f'inner{m[1]} copper gerber' - - if (m := re.fullmatch(r'.*internalplane(\d+).ger', suffix)): - return f'inner{m[1]} copper gerber' - - if suffix in ('dim', 'mil', 'gml'): - return 'outline mechanical gerber' - - 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 +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 + +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 + +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 + +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] + +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 } + + 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) + + return matches + +def layername_autoguesser(fn): + fn, _, _ext = fn.lower().rpartition('.') + + 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 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 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}' - Parameters - ---------- - source : CAMFile - CAMFile representing the layer - - - Attributes - ---------- - filename : string - Source Filename - - """ +class LayerStack: @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 ''.format(self.layer_class) + def from_directory(kls, directory, board_name=None, verbose=False): + + directory = Path(directory) + if not directory.is_dir(): + raise FileNotFoundError(f'{directory} is not a directory') + + files = [ path for path in directory.glob('**/*') if path.is_file() ] + generator, filemap = best_match(files) + + 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: + layers[key] = GerberFile.open(path) + 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.') -class LayerStack: - @classmethod - def from_directory(cls, directory, board_name=None, verbose=False): - layers = [] - names = set() - - # Validate - directory = os.path.abspath(directory) - if not os.path.isdir(directory): - raise TypeError('{} is not a directory.'.format(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)) - - # Try to guess board name - if board_name is None: - if len(names) == 1: - board_name = names.pop() - else: - board_name = os.path.basename(directory) - # Return PCB - return cls(layers, board_name) + 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, name=None): - self.layers = sort_layers(layers) - self.name = 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 - diff --git a/gerbonara/gerber/tests/resources/diptrace/.gitignore b/gerbonara/gerber/tests/resources/diptrace/.gitignore deleted file mode 100644 index 586088e..0000000 --- a/gerbonara/gerber/tests/resources/diptrace/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -*\.o -firmware/firmware.bin -firmware/firmware.elf -firmware/firmware.hex -firmware/firmware.map -- cgit