Layer merging WIP
This commit is contained in:
parent
125ef6af40
commit
606e41d4b7
5 changed files with 303 additions and 52 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<num>[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)}')
|
||||
|
||||
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
|
||||
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:
|
||||
plated = None
|
||||
layers[key] = ExcellonFile.open(path, plated=plated, settings=excellon_settings)
|
||||
else:
|
||||
layers[key] = GerberFile.open(path)
|
||||
layer = 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.')
|
||||
if key == 'drill outline':
|
||||
layers['outline'] = layer
|
||||
|
||||
elif 'drill' in key:
|
||||
drill_layers.append(layer)
|
||||
|
||||
else:
|
||||
side, _, use = key.partition(' ')
|
||||
layers[(side, use)] = layer
|
||||
|
||||
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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue