diff options
Diffstat (limited to 'gerbonara/layers.py')
-rw-r--r-- | gerbonara/layers.py | 244 |
1 files changed, 193 insertions, 51 deletions
diff --git a/gerbonara/layers.py b/gerbonara/layers.py index 4b9d360..96c2230 100644 --- a/gerbonara/layers.py +++ b/gerbonara/layers.py @@ -39,6 +39,7 @@ from .cam import FileSettings, LazyCamFile from .layer_rules import MATCH_RULES
from .utils import sum_bounds, setup_svg, MM, Tag, convex_hull
from . import graphic_objects as go
+from . import apertures as ap
from . import graphic_primitives as gp
@@ -112,31 +113,61 @@ class NamingScheme: }
+def apply_rules(filenames, rules):
+ certain = False
+ gen = {}
+ already_matched = set()
+ header_regex = rules.pop('header regex', [])
+ header_regex_matched = [False] * len(header_regex)
-def _match_files(filenames):
- matches = {}
- for generator, rules in MATCH_RULES.items():
- already_matched = set()
- gen = {}
- matches[generator] = gen
- for layer, regex in rules.items():
- for fn in filenames:
- if fn in already_matched:
- continue
+ file_headers = {}
+ def get_header(path):
+ if path not in file_headers:
+ with open(path) as f:
+ file_headers[path] = f.read(16384)
+ return file_headers[path]
- 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
+ for layer, regex in rules.items():
+ for fn in filenames:
+ if fn in already_matched:
+ continue
- gen[target] = gen.get(target, []) + [fn]
- already_matched.add(fn)
- return matches
+ target = None
+ 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]
+ already_matched.add(fn)
+
+ for i, (match_type, layer_match, header_match) in enumerate(header_regex):
+ if re.fullmatch(layer_match, fn.name, re.IGNORECASE) or (
+ target is not None and re.fullmatch(layer_match, target, re.IGNORECASE)):
+ if re.search(header_match, get_header(fn)):
+
+ if 'sufficient' in match_type:
+ certain = True
+
+ header_regex_matched[i] = True
+ if any('required' in match_type and not match
+ for match, (match_type, *_) in zip(header_regex_matched, header_regex)):
+ return False, {}
+
+ return certain, gen
def _best_match(filenames):
- matches = _match_files(filenames)
+ matches = {}
+ for generator, rules in MATCH_RULES.items():
+ certain, candidate = apply_rules(filenames, rules)
+
+ if certain:
+ return generator, candidate
+
+ matches[generator] = candidate
+
matches = sorted(matches.items(), key=lambda pair: len(pair[1]))
generator, files = matches[-1]
return generator, files
@@ -243,7 +274,7 @@ def _layername_autoguesser(fn): elif re.search('film', fn):
use = 'copper'
- elif re.search('out(line)?', fn):
+ elif re.search('out(line)?|board.?geom(etry)?', fn):
use = 'outline'
side = 'mechanical'
@@ -273,6 +304,9 @@ def _sort_layername(val): assert side.startswith('inner_')
return int(side[len('inner_'):])
+def convex_hull_to_lines(points, unit=MM):
+ for (x1, y1), (x2, y2) in zip(points, points[1:] + points):
+ yield go.Line(x1, y1, x2, y2, aperture=ap.CircleAperture(unit(0.1, MM), unit=unit), unit=unit)
class LayerStack:
""" :py:class:`LayerStack` represents a set of Gerber files that describe different layers of the same board.
@@ -385,7 +419,7 @@ class LayerStack: with ZipFile(file) as f:
f.extractall(path=tmp_indir)
- inst = kls.open_dir(tmp_indir, board_name=board_name, lazy=lazy)
+ inst = kls.open_dir(tmp_indir, board_name=board_name, lazy=lazy, overrides=overrides, autoguess=autoguess)
inst.tmpdir = tmpdir
inst.original_path = Path(original_path or file)
inst.was_zipped = True
@@ -421,6 +455,7 @@ class LayerStack: given value.
:rtype: :py:class:`LayerStack`
"""
+ print_layermap = False
if autoguess:
generator, filemap = _best_match(files)
@@ -445,14 +480,51 @@ class LayerStack: filemap[layer].remove(fn)
filemap[layer] = filemap.get(layer, []) + [fn]
+ if 'autoguess' in filemap:
+ warnings.warn(f'This generator ({generator}) often exports ambiguous filenames. Falling back to autoguesser for some files. Use at your own peril. Autoguessed files: {", ".join(f.name for f in filemap["autoguess"])}')
+ print_layermap = True
+ autoguess_filenames = filemap.pop('autoguess')
+
+ matched = set()
+ for key, values in _do_autoguess(autoguess_filenames).items():
+ filemap[key] = filemap.get(key, []) + values
+ matched |= set(values)
+
+ if generator == 'allegro':
+ # Allegro gerbers often contain the inner layers with completely random filenames and no indication of
+ # layer ordering except for drawings in the mechanical files. We fall back to alphabetic ordering.
+ for fn in autoguess_filenames:
+ if fn not in matched:
+ with open(fn) as f:
+ header = f.read(16384)
+ if re.search(r'G04 Layer:\s*ETCH/.*\*', header):
+ filemap['unknown copper'] = filemap.get('unknown copper', []) + [fn]
+
+ if (unk := filemap.pop('unknown copper', None)):
+ unk = sorted(unk, key=str)
+ if 'top copper' not in filemap:
+ filemap['top copper'], *unk = [unk]
+ if 'bottom copper' not in filemap:
+ *unk, filemap['bottom copper'] = [unk]
+
+ i = 1
+ while unk and i < 128:
+ key = f'inner_{i:02d} copper'
+ if key not in filemap:
+ filemap[key] = [unk.pop(0)]
+ i += 1
+
if sum(len(files) for files in filemap.values()) < 6 and autoguess:
warnings.warn('Ambiguous gerber filenames. Trying last-resort autoguesser.')
generator = None
+ print_layermap = True
filemap = _do_autoguess(files)
if len(filemap) < 6:
raise ValueError('Cannot figure out gerber file mapping. Partial map is: ', filemap)
excellon_settings, external_tools = None, None
+ automatch_drill_scale = False
+
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
@@ -470,16 +542,22 @@ class LayerStack: if (external_tools := parse_allegro_logfile(file.read_text())):
break
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.
+ else:
+ # 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.
+ # We'll run an automatic scale matching later.
+ excellon_settings = FileSettings(number_format=(2, 4))
+ automatch_drill_scale = True
+
+ print('remaining filemap')
+ import pprint
+ pprint.pprint(filemap)
- filemap = _do_autoguess([ f for files in filemap.values() 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 == 'zuken':
- filemap = _do_autoguess([ f for files in filemap.values() 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
@@ -503,7 +581,12 @@ class LayerStack: else:
excellon_settings = None
- ambiguous = [ f'{key} ({", ".join(x.name for x in value)})' for key, value in filemap.items() if len(value) > 1 and not 'drill' in key ]
+ ambiguous = [ f'{key} ({", ".join(x.name for x in value)})'
+ for key, value in filemap.items()
+ if len(value) > 1 and\
+ not 'drill' in key and\
+ not 'excellon' in key and\
+ not key == 'other unknown']
if ambiguous:
raise SystemError(f'Ambiguous layer names: {", ".join(ambiguous)}')
@@ -512,8 +595,11 @@ class LayerStack: 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)}')
+ if len(paths) > 1 and\
+ not 'drill' in key and\
+ not 'excellon' in key and\
+ not key == 'other unknown':
+ raise ValueError(f'Multiple matching files found for {key} layer: {", ".join(map(str, value))}')
for path in paths:
id_result = identify_file(path.read_text())
@@ -574,9 +660,72 @@ class LayerStack: board_name = re.sub(r'^\W+', '', board_name)
board_name = re.sub(r'\W+$', '', board_name)
- return kls(layers, drill_pth, drill_npth, drill_layers, board_name=board_name,
+ if automatch_drill_scale:
+ top_copper = layers[('top', 'copper')].to_excellon(errors='ignore', holes_only=True)
+
+ # precision is matching precision in mm
+ def map_coords(obj, precision=0.01, scale=1):
+ obj = obj.converted(MM)
+ return round(obj.x*scale/precision), round(obj.y*scale/precision)
+
+ aper_coords = {map_coords(obj) for obj in top_copper.drills()}
+
+ for drill_file in [drill_pth, drill_npth, *drill_layers]:
+ if not drill_file or not drill_pth.import_settings._file_has_fixed_width_coordinates:
+ continue
+
+ scale_matches = {}
+ for exp in range(-6, 6):
+ scale = 10**exp
+ hole_coords = {map_coords(obj, scale=scale) for obj in drill_file.drills()}
+
+ scale_matches[scale] = len(aper_coords - hole_coords), len(hole_coords - aper_coords)
+ scales_out = [(max(a, b), scale) for scale, (a, b) in scale_matches.items()]
+ _matches, scale = sorted(scales_out)[0]
+ warnings.warn(f'Performing automatic alignment of poorly exported drill layer. Scale matching results: {scale_matches}. Chosen scale: {scale}')
+
+ # Note: This is only used with allegro files, which use decimal points and explicit units in their tool
+ # definitions. Thus, we only scale object coordinates, and not apertures.
+ for obj in drill_file.objects:
+ obj.scale(scale)
+
+ stack = kls(layers, drill_pth, drill_npth, drill_layers, board_name=board_name,
original_path=original_path, was_zipped=was_zipped, generator=[*all_generator_hints, None][0])
+ if print_layermap:
+ warnings.warn('Auto-guessed layer map:\n' + stack.format_layer_map())
+ return stack
+
+ def format_layer_map(self):
+ lines = []
+ def print_layer(prefix, file):
+ nonlocal lines
+ if file is None:
+ lines.append(f'{prefix} <not found>')
+ else:
+ lines.append(f'{prefix} {file.original_path.name} {file}')
+
+ lines.append(' Drill files:')
+ print_layer(' Plated holes:', self.drill_pth)
+ print_layer(' Nonplated holes:', self.drill_npth)
+ for i, l in enumerate(self._drill_layers):
+ print_layer(f' Additional drill layer {i}:', l)
+
+ print_layer(' Board outline:', self.get('mechanical outline'))
+
+ lines.append(' Soldermask:')
+ print_layer(' Top:', self.get('top mask'))
+ print_layer(' Bottom:', self.get('bottom mask'))
+
+ lines.append(' Silkscreen:')
+ print_layer(' Top:', self.get('top silk'))
+ print_layer(' Bottom:', self.get('bottom silk'))
+
+ lines.append(' Copper:')
+ for (side, _use), layer in self.copper_layers:
+ print_layer(f' {side}:', layer)
+ return '\n'.join(lines)
+
def save_to_zipfile(self, path, prefix='', overwrite_existing=True, board_name=None, naming_scheme={},
gerber_settings=None, excellon_settings=None):
""" Save this board into a zip file at the given path. For other options, see
@@ -879,7 +1028,7 @@ class LayerStack: id=f'l-mechanical-outline', **stroke_attrs, **inkscape_attrs(f'outline'),
transform=layer_transform))
- sc_y, tl_y = -1, (bounds[0][1] + bounds[1][1])
+ sc_y, tl_y = 1, 0
if side == 'bottom':
sc_x, tl_x = -1, (bounds[0][0] + bounds[1][0])
else:
@@ -1114,22 +1263,6 @@ class LayerStack: polys.append(' '.join(poly.path_d()) + ' Z')
return ' '.join(polys)
- def outline_convex_hull(self, tol=0.01, unit=MM):
- points = []
- for obj in self.outline.instance.objects:
- if isinstance(obj, go.Line):
- line = obj.as_primitive(unit)
- points.append((line.x1, line.y1))
- points.append((line.x2, line.y2))
-
- elif isinstance(obj, go.Arc):
- for obj in obj.approximate(tol, unit):
- line = obj.as_primitive(unit)
- points.append((line.x1, line.y1))
- points.append((line.x2, line.y2))
-
- return convex_hull(points)
-
def outline_polygons(self, tol=0.01, unit=MM):
""" Iterator yielding this boards outline as a list of ordered :py:class:`~.graphic_objects.Arc` and
:py:class:`~.graphic_objects.Line` objects. This method first sorts all lines and arcs on the outline layer into
@@ -1144,6 +1277,14 @@ class LayerStack: :param tol: :py:obj:`float` setting the tolerance below which two points are considered equal
:param unit: :py:class:`.LengthUnit` or str (``'mm'`` or ``'inch'``). SVG document unit. Default: mm
"""
+
+ if not self.outline:
+ warnings.warn("Board has no outline layer, or the outline layer could not be identified by file name. Using the copper layers' convex hull instead.")
+ points = sum((layer.instance.convex_hull(tol, unit) for (_side, _use), layer in self.copper_layers), start=[])
+ yield list(convex_hull_to_lines(convex_hull(points), unit))
+ return
+
+ maybe_allegro_hint = '' if self.generator != 'allegro' else ' This file looks like it was generated by Allegro/OrCAD. These tools produce quite mal-formed gerbers, and often export text on the outline layer. If you generated this file yourself, maybe try twiddling with the export settings.'
polygons = []
lines = [ obj.as_primitive(unit) for obj in self.outline.instance.objects if isinstance(obj, (go.Line, go.Arc)) ]
@@ -1172,13 +1313,14 @@ class LayerStack: j = 0 if d1 < d2 else 1
if (nearest, j) in joins and joins[(nearest, j)] != (cur, i):
- warnings.warn(f'Three-way intersection on outline layer at: {(nearest, j)}; {(cur, i)}; and {joins[(nearest, j)]}. Falling back to returning the convex hull of the outline layer.')
- return self.outline_convex_hull(tol, unit)
+ warnings.warn(f'Three-way intersection on outline layer at: {(nearest, j)}; {(cur, i)}; and {joins[(nearest, j)]}. Falling back to returning the convex hull of the outline layer.{maybe_allegro_hint}')
+ yield list(convex_hull_to_lines(self.outline.instance.convex_hull(tol, unit), unit))
+ return
if (cur, i) in joins and joins[(cur, i)] != (nearest, j):
- warnings.warn(f'three-way intersection on outline layer at: {(nearest, j)}; {(cur, i)}; and {joins[(nearest, j)]}. Falling back to returning the convex hull of the outline layer.')
- return self.outline_convex_hull(tol, unit)
-
+ warnings.warn(f'Three-way intersection on outline layer at: {(nearest, j)}; {(cur, i)}; and {joins[(nearest, j)]}. Falling back to returning the convex hull of the outline layer.{maybe_allegro_hint}')
+ yield list(convex_hull_to_lines(self.outline.instance.convex_hull(tol, unit), unit))
+ return
joins[(cur, i)] = (nearest, j)
joins[(nearest, j)] = (cur, i)
|