summaryrefslogtreecommitdiff
path: root/gerbonara/layers.py
diff options
context:
space:
mode:
authorjaseg <git@jaseg.de>2022-01-30 20:11:38 +0100
committerjaseg <git@jaseg.de>2022-01-30 20:11:38 +0100
commitc3ca4f95bd59f69d45e582a4149327f57a360760 (patch)
tree5f43c61a261698e2f671b5238a7aa9a71a0f6d23 /gerbonara/layers.py
parent259a56186820923c78a5688f59bd8249cf958b5f (diff)
downloadgerbonara-c3ca4f95bd59f69d45e582a4149327f57a360760.tar.gz
gerbonara-c3ca4f95bd59f69d45e582a4149327f57a360760.tar.bz2
gerbonara-c3ca4f95bd59f69d45e582a4149327f57a360760.zip
Rename gerbonara/gerber package to just gerbonara
Diffstat (limited to 'gerbonara/layers.py')
-rw-r--r--gerbonara/layers.py482
1 files changed, 482 insertions, 0 deletions
diff --git a/gerbonara/layers.py b/gerbonara/layers.py
new file mode 100644
index 0000000..fcaf0fc
--- /dev/null
+++ b/gerbonara/layers.py
@@ -0,0 +1,482 @@
+#! /usr/bin/env python
+# -*- coding: utf-8 -*-
+#
+# Copyright 2014 Hamilton Kibbe <ham@hamiltonkib.be>
+# Copyright 2021 Jan Götte <code@jaseg.de>
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import os
+import re
+import warnings
+from collections import namedtuple
+from pathlib import Path
+
+from .excellon import ExcellonFile
+from .rs274x import GerberFile
+from .ipc356 import Netlist
+from .cam import FileSettings
+from .layer_rules import MATCH_RULES
+
+
+STANDARD_LAYERS = [
+ 'mechanical outline',
+ 'top copper',
+ 'top mask',
+ 'top silk',
+ 'top paste',
+ 'bottom copper',
+ 'bottom mask',
+ 'bottom silk',
+ 'bottom paste',
+ ]
+
+
+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, re.IGNORECASE)):
+ if layer == 'inner copper':
+ 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):
+ 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:
+ return 'excellon'
+
+ if 'G90' in data and ';LEADER:' in data: # yet another allegro special case
+ return 'excellon'
+
+ if 'FSLAX' in data or 'FSTAX' in data:
+ return 'gerber'
+
+ if 'UNITS CUST' in data:
+ return 'ipc356'
+
+ 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 = {}
+ 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 '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('.')
+
+ if ext in ('log', 'err'):
+ return 'unknown unknown'
+
+ side, use = 'unknown', 'unknown'
+ if re.search('top|front|pri?m?(ary)?', fn):
+ side = 'top'
+ use = 'copper'
+ if re.search('bot(tom)?|back|sec(ondary)?', fn):
+ side = 'bottom'
+ use = 'copper'
+
+ if re.search('silks?(creen)?', fn):
+ use = 'silk'
+
+ elif re.search('(solder)?paste', fn):
+ use = 'paste'
+
+ elif re.search('(solder)?mask', fn):
+ use = 'mask'
+
+ elif re.search('drill|rout?e?', fn):
+ use = 'drill'
+ side = 'unknown'
+
+ if re.search(r'np(th)?|(non|un)\W*plated|(non|un)\Wgalv', fn):
+ side = 'nonplated'
+
+ elif re.search('pth|plated|galv', fn):
+ side = 'plated'
+
+ 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 = 'outline'
+ side = 'mechanical'
+
+ elif 'ipc' in fn and '356' in fn:
+ use = 'netlist'
+ side = 'other'
+
+ elif 'netlist' in fn:
+ use = 'netlist'
+ side = 'other'
+
+ return f'{side} {use}'
+
+class LayerStack:
+ @classmethod
+ 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)
+ print('detected generator', generator)
+
+ if len(filemap) < 6:
+ warnings.warn('Ambiguous gerber filenames. Trying last-resort autoguesser.')
+ generator = None
+ filemap = autoguess(files)
+ if len(filemap) < 6:
+ raise ValueError('Cannot figure out gerber file mapping. Partial map is: ', filemap)
+
+ 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
+ # 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')
+ # 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
+
+ import pprint
+ pprint.pprint(filemap)
+
+ 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 = []
+ netlist = None
+ 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:
+ id_result = identify_file(path.read_text())
+ print('id_result', id_result)
+
+ if 'netlist' in key:
+ layer = Netlist.open(path)
+
+ elif ('outline' in key or 'drill' in key) and id_result != 'gerber':
+ if id_result is None:
+ # Since e.g. altium uses ".txt" as the extension for its drill files, we have to assume the
+ # current file might not be a drill file after all.
+ continue
+
+ if 'nonplated' in key:
+ plated = False
+ elif 'plated' in key:
+ plated = True
+ else:
+ plated = None
+ layer = ExcellonFile.open(path, plated=plated, settings=excellon_settings)
+ else:
+
+ layer = GerberFile.open(path)
+
+ if key == 'mechanical outline':
+ layers['mechanical', 'outline'] = layer
+
+ elif 'drill' in key:
+ drill_layers.append(layer)
+
+ elif 'netlist' in key:
+ if netlist:
+ warnings.warn(f'Found multiple netlist files, using only first one. Have: {netlist.original_path.name}, got {path.name}')
+ else:
+ netlist = layer
+
+ else:
+ side, _, use = key.partition(' ')
+ layers[(side, use)] = layer
+
+ hints = set(layer.generator_hints) | { generator }
+ if len(hints) > 1:
+ warnings.warn('File identification returned ambiguous results. Please raise an issue on the gerbonara '
+ 'tracker and if possible please provide these input files for reference.')
+
+ board_name = common_prefix([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, netlist, board_name=board_name)
+
+ def __init__(self, graphic_layers, drill_layers, netlist=None, board_name=None):
+ self.graphic_layers = graphic_layers
+ self.drill_layers = drill_layers
+ self.board_name = board_name
+ self.netlist = netlist
+
+ 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')
+
+ for layer in self.drill_layers:
+ if isinstance(layer, GerberFile):
+ layer = layer.to_excellon()
+
+ target.merge(layer)
+
+ self.drill_layers = [target]
+
+ def normalize_drill_layers(self):
+ # TODO: maybe also separate into drill and route?
+ drill_pth, drill_npth, drill_aux = [], [], []
+
+ for layer in self.drill_layers:
+ if isinstance(layer, GerberFile):
+ layer = layer.to_excellon()
+
+ if layer.is_plated:
+ drill_pth.append(layer)
+ elif layer.is_nonplated:
+ drill_pth.append(layer)
+ else:
+ drill_aux.append(layer)
+
+ pth_out, *rest = drill_pth or [ExcellonFile()]
+ for layer in rest:
+ pth_out.merge(layer)
+
+ npth_out, *rest = drill_npth or [ExcellonFile()]
+ for layer in rest:
+ npth_out.merge(layer)
+
+ unknown_out = ExcellonFile()
+ for layer in drill_aux:
+ for obj in layer.objects:
+ if obj.plated is None:
+ unknown_out.append(obj)
+ elif obj.plated:
+ pth_out.append(obj)
+ else:
+ npth_out.append(obj)
+
+ self.drill_pth, self.drill_npth = pth_out, npth_out
+ self.drill_unknown = unknown_out if unknown_out else None
+ self._drill_layers = []
+
+ @property
+ def drill_layers(self):
+ if self._drill_layers:
+ return self._drill_layers
+ 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):
+ self._drill_layers = value
+ self.drill_pth = self.drill_npth = self.drill_unknown = None
+
+ def __len__(self):
+ return len(self.layers)
+
+ def get(self, index, default=None):
+ if self.contains(key):
+ return self[key]
+ else:
+ return default
+
+ def __contains__(self, index):
+ if isinstance(index, str):
+ side, _, use = index.partition(' ')
+ return (side, use) in self.layers
+
+ elif isinstance(index, tuple):
+ 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.graphic_layers[(side, use)]
+
+ elif isinstance(index, tuple):
+ return self.graphic_layers[index]
+
+ return self.copper_layers[index]
+
+ @property
+ def copper_layers(self):
+ copper_layers = [ (key, layer) for key, layer in self.layers.items() if key.endswith('copper') ]
+
+ def sort_layername(val):
+ key, _layer = val
+ if key.startswith('top'):
+ return -1
+ if key.startswith('bottom'):
+ return 1e99
+ assert key.startswith('inner_')
+ return int(key[len('inner_'):])
+
+ return [ layer for _key, layer in sorted(copper_layers, key=sort_layername) ]
+
+ @property
+ def top_side(self):
+ 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', 'mechanical outline') }
+
+ @property
+ def outline(self):
+ return self['mechanical outline']
+
+ def _merge_layer(self, target, source):
+ if source is None:
+ return
+
+ if self[target] is None:
+ self[target] = source
+
+ else:
+ self[target].merge(source)
+
+ def merge(self, other):
+ all_keys = set(self.layers.keys()) | set(other.layers.keys())
+ exclude = { key.split() for key in STANDARD_LAYERS }
+ all_keys = { key for key in all_keys if key not in exclude }
+ if all_keys:
+ warnings.warn('Cannot merge unknown layer types: {" ".join(all_keys)}')
+
+ for side in 'top', 'bottom':
+ for use in 'copper', 'mask', 'silk', 'paste':
+ self._merge_layer((side, use), other[side, use])
+
+ our_inner, their_inner = self.copper_layers[1:-1], other.copper_layers[1:-1]
+
+ if bool(our_inner) != bool(their_inner):
+ warnings.warn('Merging board without inner layers into board with inner layers, inner layers will be empty on first board.')
+
+ elif our_inner and their_inner:
+ warnings.warn('Merging boards with different inner layer counts. Will fill inner layers starting at core.')
+
+ diff = len(our_inner) - len(their_inner)
+ their_inner = ([None] * max(0, diff//2)) + their_inner + ([None] * max(0, diff//2))
+ our_inner = ([None] * max(0, -diff//2)) + their_inner + ([None] * max(0, -diff//2))
+
+ new_inner = []
+ for ours, theirs in zip(our_inner, their_inner):
+ if ours is None:
+ new_inner.append(theirs)
+ elif theirs is None:
+ new_inner.append(ours)
+ else:
+ ours.merge(theirs)
+ new_inner.append(ours)
+
+ for i, layer in enumerate(new_inner, start=1):
+ self[f'inner_{i} copper'] = layer
+
+ self._merge_layer('mechanical outline', other['mechanical outline'])
+
+ self.normalize_drill_layers()
+ other.normalize_drill_layers()
+
+ self.drill_pth.merge(other.drill_pth)
+ self.drill_npth.merge(other.drill_npth)
+ self.drill_unknown.merge(other.drill_unknown)
+ self.netlist.merge(other.netlist)
+