From e422243a6e76d0b798ae8f175a717c193be4d22a Mon Sep 17 00:00:00 2001 From: jaseg Date: Sat, 21 May 2022 15:29:18 +0200 Subject: Fix layer stack SVG export --- gerbonara/cam.py | 30 +++++++++++++++++++ gerbonara/graphic_primitives.py | 3 +- gerbonara/layer_rules.py | 4 +-- gerbonara/layers.py | 66 +++++++++++++++++++++++++++++++---------- gerbonara/rs274x.py | 2 +- gerbonara/tests/test_rs274x.py | 2 +- 6 files changed, 86 insertions(+), 21 deletions(-) diff --git a/gerbonara/cam.py b/gerbonara/cam.py index 27b49ce..3e00fb6 100644 --- a/gerbonara/cam.py +++ b/gerbonara/cam.py @@ -22,6 +22,9 @@ from dataclasses import dataclass from copy import deepcopy from enum import Enum import string +import shutil +from pathlib import Path +from functools import cached_property from .utils import LengthUnit, MM, Inch, Tag, sum_bounds, setup_svg from . import graphic_primitives as gp @@ -246,6 +249,14 @@ class CamFile: self.layer_name = layer_name self.import_settings = import_settings + @property + def is_lazy(self): + return False + + @property + def instance(self): + return self + def to_svg(self, margin=0, arg_unit=MM, svg_unit=MM, force_bounds=None, fg='black', bg='white', tag=Tag): if force_bounds: bounds = svg_unit.convert_bounds_from(arg_unit, force_bounds) @@ -386,3 +397,22 @@ class CamFile: """ Test if this file contains any objects """ return not self.is_empty +class LazyCamFile: + def __init__(self, klass, path, *args, **kwargs): + self._class = klass + self.original_path = Path(path) + self._args = args + self._kwargs = kwargs + + @cached_property + def instance(self): + return self._class.open(self.original_path, *self._args, **self._kwargs) + + @property + def is_lazy(self): + return True + + def save(self, filename, *args, **kwargs): + """ Copy this Gerber file to the new path. """ + shutil.copy(self.original_path, filename) + diff --git a/gerbonara/graphic_primitives.py b/gerbonara/graphic_primitives.py index df49327..bfb1e89 100644 --- a/gerbonara/graphic_primitives.py +++ b/gerbonara/graphic_primitives.py @@ -207,13 +207,14 @@ class Arc(GraphicPrimitive): dx, dy = self.x1 - self.cx, self.y1 - self.cy x1 = self.x1 + dx/arc_r * r y1 = self.y1 + dy/arc_r * r - + # same for C -> P2 dx, dy = self.x2 - self.cx, self.y2 - self.cy x2 = self.x2 + dx/arc_r * r y2 = self.y2 + dy/arc_r * r arc = arc_bounds(x1, y1, x2, y2, self.cx, self.cy, self.clockwise) + return add_bounds(endpoints, arc) # FIXME add "include_center" switch def to_svg(self, fg='black', bg='white', tag=Tag): diff --git a/gerbonara/layer_rules.py b/gerbonara/layer_rules.py index c77c198..6a53f4e 100644 --- a/gerbonara/layer_rules.py +++ b/gerbonara/layer_rules.py @@ -39,11 +39,11 @@ MATCH_RULES = { 'kicad': { 'top copper': r'.*\.gtl|.*f.cu.(gbr|gtl)', 'top mask': r'.*\.gts|.*f.mask.(gbr|gts)', - 'top silk': r'.*\.gto|.*f.silks.(gbr|gto)', + 'top silk': r'.*\.gto|.*f.silks(creen)?.(gbr|gto)', 'top paste': r'.*\.gtp|.*f.paste.(gbr|gtp)', 'bottom copper': r'.*\.gbl|.*b.cu.(gbr|gbl)', 'bottom mask': r'.*\.gbs|.*b.mask.(gbr|gbs)', - 'bottom silk': r'.*\.gbo|.*b.silks.(gbr|gbo)', + 'bottom silk': r'.*\.gbo|.*b.silks(creen)?.(gbr|gbo)', 'bottom paste': r'.*\.gbp|.*b.paste.(gbr|gbp)', 'inner copper': r'.*\.gp?([0-9]+)|.*inn?e?r?([0-9]+).cu.(?:gbr|g[0-9]+)', 'mechanical outline': r'.*\.(gm[0-9]+)|.*edge.cuts.(gbr|gm1)', diff --git a/gerbonara/layers.py b/gerbonara/layers.py index b79f474..9f42be8 100644 --- a/gerbonara/layers.py +++ b/gerbonara/layers.py @@ -21,13 +21,15 @@ import os import re import warnings import copy +import itertools from collections import namedtuple from pathlib import Path +from zipfile import ZipFile, is_zipfile from .excellon import ExcellonFile, parse_allegro_ncparam, parse_allegro_logfile from .rs274x import GerberFile from .ipc356 import Netlist -from .cam import FileSettings +from .cam import FileSettings, LazyCamFile from .layer_rules import MATCH_RULES from .utils import sum_bounds, setup_svg, MM, Tag @@ -208,13 +210,40 @@ class LayerStack: self.netlist = netlist @classmethod - def from_directory(kls, directory, board_name=None): + def open(kls, path, board_name=None, lazy=False): + path = Path(path) + if path.is_dir(): + return kls.from_directory(path, board_name=board_name, lazy=lazy) + elif path.suffix.lower() == '.zip' or is_zipfile(path): + return kls.from_zipfile(path, board_name=board_name, lazy=lazy) + else: + return kls.from_files([path], board_name=board_name, lazy=lazy) + + @classmethod + def from_zipfile(kls, filename, board_name=None, lazy=False): + tmpdir = tempfile.TemporaryDirectory() + tmp_indir = Path(tmpdir) / dirname + tmp_indir.mkdir() + + with ZipFile(source) as f: + f.extractall(path=tmp_indir) + + inst = kls.from_directory(tmp_indir, board_name=board_name, lazy=lazy) + inst.tmpdir = tmpdir + return inst + + @classmethod + def from_directory(kls, directory, board_name=None, lazy=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() ] + return kls.from_files(files, board_name=board_name, lazy=lazy) + + @classmethod + def from_files(kls, files, board_name=None, lazy=False): generator, filemap = best_match(files) if sum(len(files) for files in filemap.values()) < 6: @@ -290,7 +319,7 @@ class LayerStack: id_result = identify_file(path.read_text()) if 'netlist' in key: - layer = Netlist.open(path) + layer = LazyCamFile(Netlist, path) elif ('outline' in key or 'drill' in key) and id_result != 'gerber': if id_result is None: @@ -304,10 +333,13 @@ class LayerStack: plated = True else: plated = None - layer = ExcellonFile.open(path, plated=plated, settings=excellon_settings, external_tools=external_tools) + layer = LazyCamFile(ExcellonFile, path, plated=plated, settings=excellon_settings, external_tools=external_tools) else: - layer = GerberFile.open(path) + layer = LazyCamFile(GerberFile, path) + + if not lazy: + layer = layer.open() if key == 'mechanical outline': layers['mechanical', 'outline'] = layer @@ -325,10 +357,11 @@ class LayerStack: 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.') + if not lazy: + 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) @@ -431,29 +464,30 @@ class LayerStack: if force_bounds: bounds = svg_unit.convert_bounds_from(arg_unit, force_bounds) else: - bounds = self.bounding_box(svg_unit, default=((0, 0), (0, 0))) + bounds = self.outline.instance.bounding_box(svg_unit, default=((0, 0), (0, 0))) tags = [] - for use, color in {'copper': 'black', 'mask': 'blue', 'silk': 'red'}: + for use, color in {'copper': 'black', 'mask': 'blue', 'silk': 'red'}.items(): if (side, use) not in self: + warnings.warn(f'Layer "{side} {use}" not found. Found layers: {", ".join(side + " " + use for side, use in self.graphic_layers)}') continue layer = self[(side, use)] - tags.append(tag('g', list(layer.svg_objects(svg_unit=svg_unit, fg=color, bg="white", tag=Tag)), + tags.append(tag('g', list(layer.instance.svg_objects(svg_unit=svg_unit, fg=color, bg="white", tag=Tag)), id=f'l-{side}-{use}')) for i, layer in enumerate(self.drill_layers): - tags.append(tag('g', list(layer.svg_objects(svg_unit=svg_unit, fg='magenta', bg="white", tag=Tag)), - id=f'l-{drill}-{i}')) + tags.append(tag('g', list(layer.instance.svg_objects(svg_unit=svg_unit, fg='magenta', bg="white", tag=Tag)), + id=f'l-drill-{i}')) - return setup_svg(tags, bounds, margin=margin, arg_unit=arg_unit, svg_unit=svg_unit, pagecolor=bg, tag=tag) + return setup_svg(tags, bounds, margin=margin, arg_unit=arg_unit, svg_unit=svg_unit, pagecolor="white", tag=tag) def bounding_box(self, unit=MM, default=None): return sum_bounds(( layer.bounding_box(unit, default=default) - for layer in (self.graphic_layers + self.drill_layers) ), default=default) + for layer in itertools.chain(self.graphic_layers.values(), self.drill_layers) ), default=default) def merge_drill_layers(self): target = ExcellonFile(comments='Drill files merged by gerbonara') diff --git a/gerbonara/rs274x.py b/gerbonara/rs274x.py index ea1ea1d..5bfcc3f 100644 --- a/gerbonara/rs274x.py +++ b/gerbonara/rs274x.py @@ -533,7 +533,7 @@ class GerberParser: 'eof': r"M0?[02]", 'ignored': r"(?PM01)", # NOTE: The official spec says names can be empty or contain commas. I think that doesn't make sense. - 'attribute': r"(?PG04 #@! %)?(?PTF|TA|TO|TD)(?P[._$a-zA-Z][._$a-zA-Z0-9]*)(,(?P.*))", + 'attribute': r"(?PG04 #@! %)?(?PTF|TA|TO|TD)(?P[._$a-zA-Z][._$a-zA-Z0-9]*)?(,(?P.*))?", # Eagle file attributes handled above. 'comment': r"G0?4(?P[^*]*)", } diff --git a/gerbonara/tests/test_rs274x.py b/gerbonara/tests/test_rs274x.py index 5a3916a..5d3a0c7 100644 --- a/gerbonara/tests/test_rs274x.py +++ b/gerbonara/tests/test_rs274x.py @@ -449,7 +449,7 @@ def test_compositing(file_a, file_b, angle, offset, tmpfile, print_on_error): @filter_syntax_warnings @pytest.mark.parametrize('reference', REFERENCE_FILES, indirect=True) -def test_svg_export(reference, tmpfile): +def test_svg_export_gerber(reference, tmpfile): if reference.name in ('silkscreen_bottom.gbr', 'silkscreen_top.gbr', 'top_silk.GTO'): # Some weird svg rendering artifact. Might be caused by mismatching svg units between gerbv and us. Result looks # fine though. -- cgit