diff options
-rw-r--r-- | gerbonara/gerber/cam.py | 4 | ||||
-rwxr-xr-x | gerbonara/gerber/excellon.py | 99 | ||||
-rw-r--r-- | gerbonara/gerber/layer_rules.py | 26 | ||||
-rw-r--r-- | gerbonara/gerber/layers.py | 136 | ||||
-rw-r--r-- | gerbonara/gerber/rs274x.py | 10 | ||||
-rw-r--r-- | gerbonara/gerber/tests/test_layers.py | 76 | ||||
-rw-r--r-- | gerbonara/gerber/utils.py | 1 |
7 files changed, 235 insertions, 117 deletions
diff --git a/gerbonara/gerber/cam.py b/gerbonara/gerber/cam.py index ccfa7a2..411bc65 100644 --- a/gerbonara/gerber/cam.py +++ b/gerbonara/gerber/cam.py @@ -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 = [] diff --git a/gerbonara/gerber/excellon.py b/gerbonara/gerber/excellon.py index 3931660..b3fbd97 100755 --- a/gerbonara/gerber/excellon.py +++ b/gerbonara/gerber/excellon.py @@ -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] + + else: + raise SyntaxError(f'Undefined tool index {index} selected.') - self.active_tool = self.tools[index] + 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.') diff --git a/gerbonara/gerber/layer_rules.py b/gerbonara/gerber/layer_rules.py index 1733964..871e44f 100644 --- a/gerbonara/gerber/layer_rules.py +++ b/gerbonara/gerber/layer_rules.py @@ -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', diff --git a/gerbonara/gerber/layers.py b/gerbonara/gerber/layers.py index a439de3..69f9316 100644 --- a/gerbonara/gerber/layers.py +++ b/gerbonara/gerber/layers.py @@ -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()
diff --git a/gerbonara/gerber/rs274x.py b/gerbonara/gerber/rs274x.py index 5973ead..07dbac4 100644 --- a/gerbonara/gerber/rs274x.py +++ b/gerbonara/gerber/rs274x.py @@ -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.') diff --git a/gerbonara/gerber/tests/test_layers.py b/gerbonara/gerber/tests/test_layers.py index 62ef522..482d340 100644 --- a/gerbonara/gerber/tests/test_layers.py +++ b/gerbonara/gerber/tests/test_layers.py @@ -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 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_files: + for layer in stack.drill_layers: assert isinstance(layer, ExcellonFile) diff --git a/gerbonara/gerber/utils.py b/gerbonara/gerber/utils.py index 00ba41e..5bc03e5 100644 --- a/gerbonara/gerber/utils.py +++ b/gerbonara/gerber/utils.py @@ -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: |