From 606e41d4b7969c1f1d0a935aa1e957631e71cd39 Mon Sep 17 00:00:00 2001 From: jaseg Date: Fri, 21 Jan 2022 21:15:53 +0100 Subject: Layer merging WIP --- gerbonara/gerber/excellon.py | 48 +++++++ gerbonara/gerber/graphic_objects.py | 12 ++ gerbonara/gerber/layer_rules.py | 20 +-- gerbonara/gerber/layers.py | 252 ++++++++++++++++++++++++++++++------ gerbonara/gerber/rs274x.py | 23 +++- 5 files changed, 303 insertions(+), 52 deletions(-) diff --git a/gerbonara/gerber/excellon.py b/gerbonara/gerber/excellon.py index 0900859..dc2ea86 100755 --- a/gerbonara/gerber/excellon.py +++ b/gerbonara/gerber/excellon.py @@ -19,6 +19,7 @@ import math import operator import warnings import functools +import dataclasses from enum import Enum from dataclasses import dataclass from collections import Counter @@ -105,10 +106,57 @@ class ExcellonFile(CamFile): 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 __bool__(self): + return bool(self.objects) + + @property + def is_plated(self): + return all(obj.plated for obj in self.objects) + + @property + def is_nonplated(self): + return all(obj.plated == False for obj in self.objects) # False, not None + + @property + def is_plating_unknown(self): + return all(obj.plated is None for obj in self.objects) # False, not None + + @property + def is_mixed_plating(self): + return len({obj.plated for obj in self.objects}) > 1 + + def append(self, obj_or_comment): + if isinstnace(obj_or_comment, str): + self.comments.append(obj_or_comment) + else: + self.objects.append(obj_or_comment) + + def to_gerber(self): + apertures = {} + out = GerberFile() + out.comments = self.comments + + for obj in self.objects: + if id(obj.tool) not in apertures: + apertures[id(obj.tool)] = CircleAperture(obj.tool.diameter) + + out.objects.append(dataclasses.replace(obj, aperture=apertures[id(obj.tool)])) + + out.apertures = list(apertures.values()) + @property def generator(self): return self.generator_hints[0] if self.generator_hints else None + def merge(self, other): + if other is None: + return + + self.objects += other.objects + self.comments += other.comments + self.generator_hints = None + self.import_settings = None + @classmethod def open(kls, filename, plated=None, settings=None): filename = Path(filename) diff --git a/gerbonara/gerber/graphic_objects.py b/gerbonara/gerber/graphic_objects.py index 032b562..c626ba3 100644 --- a/gerbonara/gerber/graphic_objects.py +++ b/gerbonara/gerber/graphic_objects.py @@ -62,6 +62,10 @@ class Flash(GerberObject): def tool(self, value): self.aperture = value + @property + def plated(self): + return self.tool.plated + def _with_offset(self, dx, dy): return replace(self, x=self.x+dx, y=self.y+dy) @@ -216,6 +220,10 @@ class Line(GerberObject): def tool(self, value): self.aperture = value + @property + def plated(self): + return self.tool.plated + def to_primitives(self, unit=None): conv = self.converted(unit) yield gp.Line(*conv.p1, *conv.p2, self.aperture.equivalent_width(unit), polarity_dark=self.polarity_dark) @@ -285,6 +293,10 @@ class Arc(GerberObject): def tool(self, value): self.aperture = value + @property + def plated(self): + return self.tool.plated + def _rotate(self, rotation, cx=0, cy=0): # rotate center first since we need old x1, y1 here new_cx, new_cy = gp.rotate_point(*self.center, rotation, cx, cy) diff --git a/gerbonara/gerber/layer_rules.py b/gerbonara/gerber/layer_rules.py index 6dd16c6..e0a6954 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]+)', - 'outline mech': r'.*\.(gko|gm[0-9]+)', + 'drill 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.*', - 'outline mech': r'.*\.(gm[0-9]+)|.*edge.cuts.*', + 'drill 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 - 'outline mech': r'.*\.outline\.gbr', + 'drill outline': r'.*\.outline\.gbr', 'drill plated': r'.*\.plated-drill.cnc', 'drill nonplated': r'.*\.unplated-drill.cnc', }, @@ -63,12 +63,12 @@ MATCH_RULES = { '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', + 'bottom copper': r'.*\.bot', + 'bottop mask': r'.*\.smb', + 'bottop silk': r'.*\.ssb', + 'bottop paste': r'.*\.spb', 'inner copper': r'.*\.in([0-9]+)', - 'outline gerber': r'.*\.(fab|drd)', + 'drill outline': r'.*\.(fab|drd)', 'drill plated': r'.*\.tap', 'drill nonplated': r'.*\.npt', }, @@ -84,12 +84,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', - 'outline mech': r'.*(\.dim|\.mil|\.gml)|.*\.(?:board)?outline\.ger|profile\.gbr', + 'drill outline': r'.*(\.dim|\.mil|\.gml)|.*\.(?:board)?outline\.ger|profile\.gbr', 'drill plated': r'.*\.(txt|exc|drd|xln)', }, 'siemens': { - 'outline mech': r'.*ContourPlated.ncd', + 'drill 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 9e34bd8..26dd9c1 100644 --- a/gerbonara/gerber/layers.py +++ b/gerbonara/gerber/layers.py @@ -23,6 +23,20 @@ from collections import namedtuple from .excellon import ExcellonFile from .ipc356 import IPCNetlist + +STANDARD_LAYERS = [ + '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(): @@ -84,51 +98,43 @@ 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): + if re.match('bot(tom)?|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)): + + elif (m := re.match('(la?y?e?r?|in(ner)?)\W*(?P[0-9]+)', fn)): use = 'copper' - side = f'inner_{m[1]}' + side = f'inner_{m["num"]:02d}' + elif re.match('film', fn): use = 'copper' - elif re.match('drill|rout?e?|outline'): + + elif re.match('out(line)?'): + use = 'drill' + side = 'outline' + + elif re.match('drill|rout?e?'): 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}' class LayerStack: @@ -175,35 +181,199 @@ class LayerStack: 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() } + drill_layers = [] + layers = { key: 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 '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 == 'drill outline': + layers['outline'] = layer + + elif 'drill' in key: + drill_layers.append(layer) - 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) + side, _, use = key.partition(' ') + layers[(side, use)] = layer - 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.') + hints = { 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([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) + return kls(layers, drill_layers, board_name=board_name) - def __init__(self, layers, board_name=None): - self.layers = layers + def __init__(self, graphic_layers, drill_layers, board_name=None): + self.graphic_layers = graphic_layers + self.-drill_layers = drill_layers self.board_name = board_name + 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): + 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 + return [self.drill_pth, self.drill_npth, self.drill_unknown] + + @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 __getitem__(self, index): + if isinstance(index, str): + side, _, use = index.partition(' ') + return self.layers.get((side, use)) + + elif isinstance(index, tuple): + return self.layers.get(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', 'outline') } + + @property + def bottom_side(self): + return { key: self[key] for key in ('bottom copper', 'bottom mask', 'bottom silk', 'bottom paste', 'outline') } + + @property + def outline(self): + return self['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('outline', other['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) + diff --git a/gerbonara/gerber/rs274x.py b/gerbonara/gerber/rs274x.py index 75178f7..a572d94 100644 --- a/gerbonara/gerber/rs274x.py +++ b/gerbonara/gerber/rs274x.py @@ -32,6 +32,7 @@ from pathlib import Path from itertools import count, chain from io import StringIO import textwrap +import dataclasses from .cam import CamFile, FileSettings from .utils import sq_distance, rotate_point, MM, Inch, units, InterpMode @@ -39,6 +40,7 @@ from .aperture_macros.parse import ApertureMacro, GenericMacros from . import graphic_primitives as gp from . import graphic_objects as go from . import apertures +from .excellon import ExcellonFile def points_close(a, b): @@ -74,11 +76,26 @@ class GerberFile(CamFile): def __init__(self, filename=None): super().__init__(filename) - self.apertures = [] + self.apertures = [] # FIXME get rid of this? apertures are already in the objects. self.comments = [] self.objects = [] self.import_settings = None + def to_excellon(self): + new_objs = [] + new_tools = {} + for obj in self.objects: + if not isinstance(obj, Line) or isinstance(obj, Arc) or isinstance(obj, Flash) or \ + not isinstance(obj.aperture, CircleAperture): + raise ValueError('Cannot convert {type(obj)} to excellon!') + + if not (new_tool := new_tools.get(id(obj.aperture))): + # TODO plating? + new_tool = new_tools[id(obj.aperture)] = ExcellonTool(obj.aperture.diameter) + new_obj = dataclasses.replace(obj, aperture=new_tool) + + return ExcellonFile(objects=new_objs, comments=self.comments) + def to_svg(self, tag=Tag, margin=0, arg_unit=MM, svg_unit=MM, force_bounds=None, color='black'): if force_bounds is None: @@ -115,6 +132,10 @@ class GerberFile(CamFile): def merge(self, other): """ Merge other GerberFile into this one """ + if other is None: + return + + self.import_settings = None self.comments += other.comments # dedup apertures -- cgit