From 8b40d15dab376c92b37b0939515e7bdee7b83301 Mon Sep 17 00:00:00 2001 From: jaseg Date: Sat, 25 Feb 2023 17:31:16 +0100 Subject: Moar doc --- gerbonara/__init__.py | 4 +- gerbonara/__main__.py | 20 +---- gerbonara/apertures.py | 2 + gerbonara/cam.py | 36 +++----- gerbonara/cli.py | 26 +++--- gerbonara/excellon.py | 4 + gerbonara/layers.py | 225 +++++++++++++++++++++++++++++++++++++++++++++---- gerbonara/rs274x.py | 7 +- 8 files changed, 253 insertions(+), 71 deletions(-) (limited to 'gerbonara') diff --git a/gerbonara/__init__.py b/gerbonara/__init__.py index cadd528..614f0b8 100644 --- a/gerbonara/__init__.py +++ b/gerbonara/__init__.py @@ -20,7 +20,9 @@ Gerbonara ========= -gerbonara provides utilities for working with Gerber (RS-274X) and Excellon files in python. +gerbonara provides utilities for working with PCB artwork files in Gerber/RS274-X, XNC/Excellon and IPC-356 formats. It +includes convenience functions to match file names to layer types that match the default settings of a number of common +EDA tools. """ from .rs274x import GerberFile diff --git a/gerbonara/__main__.py b/gerbonara/__main__.py index 57b3737..69c55fb 100644 --- a/gerbonara/__main__.py +++ b/gerbonara/__main__.py @@ -2,24 +2,8 @@ import click -from .layers import LayerStack - - -@click.command() -@click.option('-t' ,'--top', help='Render board top side.', is_flag=True) -@click.option('-b' ,'--bottom', help='Render board bottom side.', is_flag=True) -@click.argument('gerber_dir_or_zip', type=click.Path(exists=True)) -@click.argument('output_svg', required=False, default='-', type=click.File('w')) -def render(gerber_dir_or_zip, output_svg, top, bottom): - if (bool(top) + bool(bottom)) != 1: - raise click.UsageError('Excactly one of --top or --bottom must be given.') - - stack = LayerStack.open(gerber_dir_or_zip, lazy=True) - print(f'Loaded {stack}') - - svg = stack.to_pretty_svg(side=('top' if top else 'bottom')) - output_svg.write(str(svg)) +from .cli import cli if __name__ == '__main__': - render() + cli() diff --git a/gerbonara/apertures.py b/gerbonara/apertures.py index b4bf67b..d9137da 100644 --- a/gerbonara/apertures.py +++ b/gerbonara/apertures.py @@ -269,6 +269,8 @@ class CircleAperture(Aperture): @dataclass class RectangleAperture(Aperture): + """ Gerber rectangle aperture. Can only be used for flashes, since the line width of an interpolation of a rectangle + aperture is not well-defined and there is no tool that implements it in a geometrically correct way. """ _gerber_shape_code = 'R' _human_readable_shape = 'rect' #: float with the width of the rectangle in :py:attr:`unit` units. diff --git a/gerbonara/cam.py b/gerbonara/cam.py index d5a2efa..59c4969 100644 --- a/gerbonara/cam.py +++ b/gerbonara/cam.py @@ -48,7 +48,9 @@ class FileSettings: #: Angle unit. Should be ``'degree'`` unless you really know what you're doing. angle_unit : str = 'degree' #: Zero suppression settings. Must be one of ``None``, ``'leading'`` or ``'trailing'``. See note at - #: :py:class:`.FileSettings` for meaning. + #: :py:class:`.FileSettings` for meaning in Excellon files. ``None`` will produce explicit decimal points, which + #: should work for most tools. For Gerber files, the other settings are fine, but for Excellon files, which lack a + #: standardized way to indicate number format, explicit decimal points are the best way to avoid mis-parsing. zeros : bool = None #: Number format. ``(integer, decimal)`` tuple of number of integer and decimal digits. At most ``(6,7)`` by spec. number_format : tuple = (None, None) @@ -78,7 +80,9 @@ class FileSettings: @classmethod def defaults(kls): - """ Return a set of good default FileSettings that will work for all gerber or excellon files. """ + """ Return a set of good default settings that will work for all gerber or excellon files. These default + settings are metric units, 4 integer digits (for up to 10 m by 10 m size), 5 fractional digits (for 10 µm + resolution) and :py:obj:`None` zero suppression, meaning that explicit decimal points are going to be used.""" return FileSettings(unit=MM, number_format=(4,5), zeros=None) def to_radian(self, value): @@ -119,13 +123,16 @@ class FileSettings: @property def is_metric(self): + """ Return true if this :py:class:`.FileSettings` has a defined unit, and that unit is :py:attr:`~.utilities.MM` """ return self.unit == MM @property def is_inch(self): + """ Return true if this :py:class:`.FileSettings` has a defined unit, and that unit is :py:attr:`~.utilities.Inch` """ return self.unit == Inch def copy(self): + """ Create a deep copy of this FileSettings """ return deepcopy(self) def __str__(self): @@ -416,6 +423,9 @@ class CamFile: return not self.is_empty class LazyCamFile: + """ Helper class for :py:class:`~.layers.LayerStack` that holds a path to an input file without loading it right + away. This class'es :py:method:`save` method will just copy the input file instead of parsing and re-serializing + it.""" def __init__(self, klass, path, *args, **kwargs): self._class = klass self.original_path = Path(path) @@ -424,6 +434,8 @@ class LazyCamFile: @cached_property def instance(self): + """ Load the input file if necessary, and return the loaded object. Will only load the file once, and cache the + result. """ return self._class.open(self.original_path, *self._args, **self._kwargs) @property @@ -434,23 +446,3 @@ class LazyCamFile: """ Copy this Gerber file to the new path. """ shutil.copy(self.original_path, filename) -class CachedLazyCamFile: - def __init__(self, klass, data, original_path, *args, **kwargs): - self._class = klass - self._data = data - self.original_path = original_path - self._args = args - self._kwargs = kwargs - - @cached_property - def instance(self): - return self._class.from_string(self._data, filename=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. """ - Path(filename).write_text(self._data) - diff --git a/gerbonara/cli.py b/gerbonara/cli.py index 2025726..a27a0b6 100644 --- a/gerbonara/cli.py +++ b/gerbonara/cli.py @@ -33,13 +33,13 @@ from . import layers as lyr from . import __version__ -def print_version(ctx, param, value): +def _print_version(ctx, param, value): if value and not ctx.resilient_parsing: click.echo(f'Version {__version__}') ctx.exit() -def apply_transform(transform, unit, layer_or_stack): +def _apply_transform(transform, unit, layer_or_stack): def translate(x, y): layer_or_stack.offset(x, y, unit) @@ -122,15 +122,17 @@ class NamingScheme(click.Choice): @click.group() -@click.option('--version', is_flag=True, callback=print_version, expose_value=False, is_eager=True) +@click.option('--version', is_flag=True, callback=_print_version, expose_value=False, is_eager=True) def cli(): + """ The gerbonara CLI allows you to analyze, render, modify and merge both individual Gerber or Excellon files as + well as sets of those files """ pass @cli.command() @click.option('--warnings', 'format_warnings', type=click.Choice(['default', 'ignore', 'once']), default='default', help='''Enable or disable file format warnings during parsing (default: on)''') -@click.option('--version', is_flag=True, callback=print_version, expose_value=False, is_eager=True) +@click.option('--version', is_flag=True, callback=_print_version, expose_value=False, is_eager=True) @click.option('-m', '--input-map', type=click.Path(exists=True, path_type=Path), help='''Extend or override layer name mapping with name map from JSON file. The JSON file must contain a single JSON dict with an arbitrary number of string: string entries. The keys are interpreted as regexes applied to the filenames via @@ -178,7 +180,7 @@ def render(inpath, outfile, format_warnings, input_map, use_builtin_name_rules, @cli.command() -@click.option('--version', is_flag=True, callback=print_version, expose_value=False, is_eager=True) +@click.option('--version', is_flag=True, callback=_print_version, expose_value=False, is_eager=True) @click.option('--warnings', 'format_warnings', type=click.Choice(['default', 'ignore', 'once']), default='default', help='''Enable or disable file format warnings during parsing (default: on)''') @click.option('-t', '--transform', help='''Execute python transformation script on input. You have access to the @@ -230,7 +232,7 @@ def rewrite(transform, command_line_units, number_format, units, zero_suppressio f = GerberFile.open(infile, override_settings=input_settings) if transform: - apply_transform(transform, command_line_units or MM, f) + _apply_transform(transform, command_line_units or MM, f) output_format = f.import_settings if output_format == 'reuse' else FileSettings.defaults() if number_format: @@ -247,7 +249,7 @@ def rewrite(transform, command_line_units, number_format, units, zero_suppressio @cli.command() -@click.option('--version', is_flag=True, callback=print_version, expose_value=False, is_eager=True) +@click.option('--version', is_flag=True, callback=_print_version, expose_value=False, is_eager=True) @click.option('-m', '--input-map', type=click.Path(exists=True, path_type=Path), help='''Extend or override layer name mapping with name map from JSON file. The JSON file must contain a single JSON dict with an arbitrary number of string: string entries. The keys are interpreted as regexes applied to the filenames via @@ -291,7 +293,7 @@ def transform(transform, units, output_format, inpath, outpath, else: stack = lyr.LayerStack.open(path, overrides=overrides, autoguess=use_builtin_name_rules) - apply_transform(transform, units, stack) + _apply_transform(transform, units, stack) output_format = None if output_format == 'reuse' else FileSettings.defaults() stack.save_to_directory(outpath, naming_scheme=output_naming_scheme or {}, @@ -300,7 +302,7 @@ def transform(transform, units, output_format, inpath, outpath, @cli.command() -@click.option('--version', is_flag=True, callback=print_version, expose_value=False, is_eager=True) +@click.option('--version', is_flag=True, callback=_print_version, expose_value=False, is_eager=True) @click.option('--command-line-units', type=Unit(), help='''Units for values given in --transform. Default: millimeter''') @click.option('--warnings', 'format_warnings', type=click.Choice(['default', 'ignore', 'once']), default='default', @@ -374,7 +376,7 @@ def merge(inpath, outpath, offset, rotation, input_map, command_line_units, outp @cli.command() -@click.option('--version', is_flag=True, callback=print_version, expose_value=False, is_eager=True) +@click.option('--version', is_flag=True, callback=_print_version, expose_value=False, is_eager=True) @click.option('--warnings', 'format_warnings', type=click.Choice(['default', 'ignore', 'once']), default='default', help='''Enable or disable file format warnings during parsing (default: on)''') @click.option('--units', type=Unit(), help='Output bounding box in this unit (default: millimeter)') @@ -407,7 +409,7 @@ def bounding_box(infile, format_warnings, input_number_format, input_units, inpu @cli.command() -@click.option('--version', is_flag=True, callback=print_version, expose_value=False, is_eager=True) +@click.option('--version', is_flag=True, callback=_print_version, expose_value=False, is_eager=True) @click.option('--warnings', 'format_warnings', type=click.Choice(['default', 'ignore', 'once']), default='default', help='''Enable or disable file format warnings during parsing (default: on)''') @click.option('--force-zip', is_flag=True, help='Force treating input path as zip file (default: guess file type from extension and contents)') @@ -444,7 +446,7 @@ def layers(path, force_zip, format_warnings): @cli.command() -@click.option('--version', is_flag=True, callback=print_version, expose_value=False, is_eager=True) +@click.option('--version', is_flag=True, callback=_print_version, expose_value=False, is_eager=True) @click.option('--warnings', 'format_warnings', type=click.Choice(['default', 'ignore', 'once']), help='''Enable or disable file format warnings during parsing (default: on)''') @click.option('--force-zip', is_flag=True, help='Force treating input path as zip file (default: guess file type from extension and contents)') diff --git a/gerbonara/excellon.py b/gerbonara/excellon.py index 2653a37..657012b 100755 --- a/gerbonara/excellon.py +++ b/gerbonara/excellon.py @@ -162,6 +162,8 @@ def parse_allegro_logfile(data): return found_tools def parse_zuken_logfile(data): + """ Internal function to parse Excellon format information out of Zuken's nonstandard textual log files that their + tools generate along with the Excellon file. """ lines = [ line.strip() for line in data.splitlines() ] if '***** DRILL LIST *****' not in lines: return # likely not a Zuken CR-8000 logfile @@ -251,9 +253,11 @@ class ExcellonFile(CamFile): self.objects.append(obj_or_comment) def to_excellon(self): + """ Counterpart to :py:meth:`~.rs274x.GerberFile.to_excellon`. Does nothing and returns :py:obj:`self`. """ return self def to_gerber(self): + """ Convert this excellon file into a :py:class:`~.rs274x.GerberFile`. """ apertures = {} out = GerberFile() out.comments = self.comments diff --git a/gerbonara/layers.py b/gerbonara/layers.py index f5455a2..34d3f0c 100644 --- a/gerbonara/layers.py +++ b/gerbonara/layers.py @@ -98,7 +98,7 @@ class NamingScheme: -def match_files(filenames): +def _match_files(filenames): matches = {} for generator, rules in MATCH_RULES.items(): gen = {} @@ -114,14 +114,21 @@ def match_files(filenames): return matches -def best_match(filenames): - matches = match_files(filenames) +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): + """ Identify file type from file contents. Returns either of the string constants :py:obj:`excellon`, + :py:obj:`gerber`, or :py:obj:`ipc356`, or returns :py:obj:`None` if the file format is unclear. + + :param data: Contents of file as :py:obj:`str` + :rtype: :py:obj:`str` + """ + if 'M48' in data: return 'excellon' @@ -137,7 +144,7 @@ def identify_file(data): return None -def common_prefix(l): +def _common_prefix(l): out = [] for cand in l: score = lambda n: sum(elem.startswith(cand[:n]) for elem in l) @@ -154,12 +161,12 @@ def common_prefix(l): return sorted(out, key=len)[-1] -def do_autoguess(filenames): - prefix = common_prefix([f.name for f in filenames]) +def _do_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) + 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] @@ -174,7 +181,7 @@ def do_autoguess(filenames): return matches -def layername_autoguesser(fn): +def _layername_autoguesser(fn): fn, _, ext = fn.lower().rpartition('.') if ext in ('log', 'err', 'fdl', 'py', 'sh', 'md', 'rst', 'zip', 'pdf', 'svg', 'ps', 'png', 'jpg', 'bmp'): @@ -237,6 +244,22 @@ def layername_autoguesser(fn): class LayerStack: + """ :py:class:`LayerStack` represents a set of Gerber files that describe different layers of the same board. + + :ivar graphic_layers: :py:obj:`dict` mapping :py:obj:`(side, use)` tuples to the Gerber layers of the board. + :py:obj:`side` can be one of :py:obj:`"top"` or :py:obj:`"bottom"`, or a numbered internal + layer such as :py:obj:`"inner2"`. :py:obj:`use` can be one of :py:obj:`"silk", :py:obj:`mask`, + :py:obj:`paste` or :py:obj:`copper`. For internal layers, only :py:obj:`copper` is valid. + :ivar board_name: Name of this board as parse from the input filenames, as a :py:obj:`str`. You can overwrite this + attribute with a different name, which will then be used during saving with the built-in file + naming rules. + :ivar netlist: The :py:class:`~.ipc356.Netlist` of this board, or :py:obj:`None` + :ivar original_path: The path to the directory or zip file that this board was loaded from. + :ivar was_zipped: True if this board was loaded from a zip file. + :ivar generator: A string containing an educated guess on which EDA tool generated this file. Example: + :py:obj:`"altium"` + """ + def __init__(self, graphic_layers, drill_layers, netlist=None, board_name=None, original_path=None, was_zipped=False, generator=None): self.graphic_layers = graphic_layers self.drill_layers = drill_layers @@ -248,6 +271,30 @@ class LayerStack: @classmethod def open(kls, path, board_name=None, lazy=False, overrides=None, autoguess=True): + """ Load a board from the given path. + + * The path can be a single file, in which case a :py:class:`LayerStack` containing only that file on a custom + layer is returned. + * The path can point to a directory, in which case the content's of that directory are analyzed for their file + type and function. + * The path can point to a zip file, in which case that zip file's contents are analyzed for their file type and + function. + * Finally, the path can be the string :py:obj:`"-"`, in which case this function will attempt to read a zip file + from standard input. + + :param path: Path to a gerber file, directory or zip file, or the string :py:obj:`"-"` + :param board_name: Override board name for the returned :py:class:`LayerStack` instance instead of guessing the + board name from the found file names. + :param lazy: Do not parse files right away, instead return a :py:class:`LayerStack` containing + :py:class:~.cam.LazyCamFile` instances. + :param overrides: :py:obj:`dict` containing a filename regex to layer type mapping that will override + gerbonara's built-in automatic rules. Each key must be a :py:obj:`str` containing a regex, and + each value must be a :py:obj:`(side, use)` :py:obj:`tuple` of :py:obj:`str`. + :param autoguess: :py:obj:`bool` to enable or disable gerbonara's built-in automatic filename-based layer + function guessing. When :py:obj:`False`, layer functions are deduced only from + :py:obj:`overrides`. + :rtype: :py:class:`LayerStack` + """ if str(path) == '-': data_io = io.BytesIO(sys.stdin.buffer.read()) return kls.from_zip_data(data_io, original_path='', board_name=board_name, lazy=lazy) @@ -262,6 +309,14 @@ class LayerStack: @classmethod def open_zip(kls, file, original_path=None, board_name=None, lazy=False, overrides=None, autoguess=True): + """ Load a board from a ZIP file. Refer to :py:meth:`~.layers.LayerStack.open` for the meaning of the other + options. + + :param file: file-like object + :param original_path: Override the :py:obj:`original_path` of the resulting :py:class:`LayerStack` with the + given value. + :rtype: :py:class:`LayerStack` + """ tmpdir = tempfile.TemporaryDirectory() tmp_indir = Path(tmpdir.name) / 'input' tmp_indir.mkdir() @@ -277,6 +332,11 @@ class LayerStack: @classmethod def open_dir(kls, directory, board_name=None, lazy=False, overrides=None, autoguess=True): + """ Load a board from a directory. Refer to :py:meth:`~.layers.LayerStack.open` for the meaning of the options. + + :param directory: Path of the directory to process. + :rtype: :py:class:`LayerStack` + """ directory = Path(directory) if not directory.is_dir(): @@ -291,8 +351,18 @@ class LayerStack: @classmethod def from_files(kls, files, board_name=None, lazy=False, original_path=None, was_zipped=False, overrides=None, autoguess=True): + """ Load a board from a directory. Refer to :py:meth:`~.layers.LayerStack.open` for the meaning of the options. + + :param files: List of paths of the files to load. + :param original_path: Override the :py:obj:`original_path` of the resulting :py:class:`LayerStack` with the + given value. + :param was_zipped: Override the :py:obj:`was_zipped` attribute of the resulting :py:class:`LayerStack` with the + given value. + :rtype: :py:class:`LayerStack` + """ + if autoguess: - generator, filemap = best_match(files) + generator, filemap = _best_match(files) else: generator = 'custom' if overrides: @@ -317,7 +387,7 @@ class LayerStack: if sum(len(files) for files in filemap.values()) < 6 and autoguess: warnings.warn('Ambiguous gerber filenames. Trying last-resort autoguesser.') generator = None - filemap = do_autoguess(files) + filemap = _do_autoguess(files) if len(filemap) < 6: raise ValueError('Cannot figure out gerber file mapping. Partial map is: ', filemap) @@ -342,13 +412,13 @@ class LayerStack: # 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 = do_autoguess([ f for files in filemap.values() for f in files ]) + 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 ]) + 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 @@ -433,20 +503,33 @@ class LayerStack: 'gerbonara tracker and if possible please provide these input files for reference.') if not board_name: - board_name = common_prefix([l.original_path.name for l in layers.values() if l is not None]) + 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, original_path=original_path, was_zipped=was_zipped, generator=[*all_generator_hints, None][0]) - def save_to_zipfile(self, path, naming_scheme={}, overwrite_existing=True, prefix=''): + def save_to_zipfile(self, path, prefix='', overwrite_existing=True, naming_scheme={}, + gerber_settings=None, excellon_settings=None): + """ Save this board into a zip file at the given path. For other options, see + :py:meth:`~.layers.LayerStack.save_to_directory`. + + :param path: Path of output zip file + :param overwrite_existing: Bool specifying whether override an existing zip file. If :py:obj:`False` and + :py:obj:`path` exists, a :py:obj:`ValueError` is raised. + + :param prefix: Store output files under the given prefix inside the zip file + """ if path.is_file(): if overwrite_existing: path.unlink() else: raise ValueError('output zip file already exists and overwrite_existing is False') + if gerber_settings and not excellon_settings: + excellon_settings = gerber_settings + with ZipFile(path, 'w') as le_zip: for path, layer in self._save_files_iter(naming_scheme=naming_scheme): with le_zip.open(prefix + str(path), 'w') as out: @@ -454,9 +537,30 @@ class LayerStack: def save_to_directory(self, path, naming_scheme={}, overwrite_existing=True, gerber_settings=None, excellon_settings=None): + """ Save this board into a directory at the given path. If the given path does not exist, a new directory is + created in its place. + + :param path: Output directory + :param naming_scheme: :py:obj:`dict` specifying the naming scheme to use for the individual layer files. When + not specified, the original filenames are kept where available, and a default naming + scheme is used. You can provide your own :py:obj:`dict` here, mapping :py:obj:`"side use"` + strings to filenames, or use one of :py:attr:`~.layers.NamingScheme.kicad` or + :py:attr:`~.layers.NamingScheme.kicad`. + :param overwrite_existing: Bool specifying whether override an existing directory. If :py:obj:`False` and + :py:obj:`path` exists, a :py:obj:`ValueError` is raised. Note that a + :py:obj:`ValueError` will still be raised if the target exists and is not a + directory. + :param gerber_settings: :py:class:`~.cam.FileSettings` to use for Gerber file export. When not given, the input + file's original settings are re-used if available. If those can't be found anymore, sane + defaults are used. We recommend you set this to the result of + :py:meth:`~.cam.FileSettings.defaults`. + """ outdir = Path(path) outdir.mkdir(parents=True, exist_ok=overwrite_existing) + if gerber_settings and not excellon_settings: + excellon_settings = gerber_settings + for path, layer in self._save_files_iter(naming_scheme=naming_scheme): out = outdir / path if out.exists() and not overwrite_existing: @@ -504,7 +608,23 @@ class LayerStack: def __repr__(self): return str(self) - def to_svg(self, margin=0, arg_unit=MM, svg_unit=MM, force_bounds=None, tag=Tag, page_bg="white"): + def to_svg(self, margin=0, arg_unit=MM, svg_unit=MM, force_bounds=None, tag=Tag): + """ Convert this layer stack to a plain SVG string. This is intended for use cases where the resulting SVG will + be processed by other tools, and thus styling with colors or extra markup like Inkscape layer information are + unwanted. If you want to instead generate a nice-looking preview image for display or graphical editing in tools + such as Inkscape, use :py:meth:`~.layers.LayerStack.to_pretty_svg` instead. + + :param margin: Export SVG file with given margin around the board's bounding box. + :param arg_unit: :py:class:`.LengthUnit` or str (``'mm'`` or ``'inch'``). Which unit ``margin`` and + ``force_bounds`` are specified in. Default: mm + :param svg_unit: :py:class:`.LengthUnit` or str (``'mm'`` or ``'inch'``). Which unit to use inside the SVG file. + Default: mm + :param force_bounds: Use bounds given as :py:obj:`((min_x, min_y), (max_x, max_y))` tuple for the output SVG + file instead of deriving them from this board's bounding box and ``margin``. Note that this + will not scale or move the board, but instead will only crop the viewport. + :param tag: Extension point to support alternative XML serializers in addition to the built-in one. + :rtype: :py:obj:`str` + """ if force_bounds: bounds = svg_unit.convert_bounds_from(arg_unit, force_bounds) else: @@ -521,7 +641,35 @@ class LayerStack: return setup_svg(tags, bounds, margin=margin, arg_unit=arg_unit, svg_unit=svg_unit, pagecolor=page_bg, tag=tag) - def to_pretty_svg(self, side='top', margin=0, arg_unit=MM, svg_unit=MM, force_bounds=None, tag=Tag, inkscape=False, colors=None): + def to_pretty_svg(self, side='top', margin=0, arg_unit=MM, svg_unit=MM, force_bounds=None, tag=Tag, inkscape=False, + colors=None): + """ Convert this layer stack to a pretty SVG string that is suitable for display or for editing in tools such as + Inkscape. If you want to process the resulting SVG in other tools, consider using + :py:meth:`~layers.LayerStack.to_svg` instead, which produces output without color styling or blending based on + SVG filter effects. + + :param side: One of the strings :py:obj:`"top"` or :py:obj:`"bottom"` specifying which side of the board to + render. + :param margin: Export SVG file with given margin around the board's bounding box. + :param arg_unit: :py:class:`.LengthUnit` or str (``'mm'`` or ``'inch'``). Which unit ``margin`` and + ``force_bounds`` are specified in. Default: mm + :param svg_unit: :py:class:`.LengthUnit` or str (``'mm'`` or ``'inch'``). Which unit to use inside the SVG file. + Default: mm + :param force_bounds: Use bounds given as :py:obj:`((min_x, min_y), (max_x, max_y))` tuple for the output SVG + file instead of deriving them from this board's bounding box and ``margin``. Note that this + will not scale or move the board, but instead will only crop the viewport. + :param tag: Extension point to support alternative XML serializers in addition to the built-in one. + :param inkscape: :py:obj:`bool` enabling Inkscape-specific markup such as Inkscape-native layers + :param colors: Colorscheme to use, or :py:obj:`None` for the built-in pseudo-realistic green solder mask default + color scheme. When given, must be a dict mapping semantic :py:obj:`"side use"` layer names such + as :py:obj:`"top copper"` to a HTML-like hex color code such as :py:obj:`#ff00ea`. Transparency + is supported through 8-digit color codes. When 8 digits are given, the last two digits are used + as the layer's alpha channel. Valid side values in the layer name strings are :py:obj:`"top"` and + :py:obj:`"bottom"` as well as :py:obj:`"inner1"`, :py:obj:`"inner2"` etc. for internal layers. + Valid use values are :py:obj:`"mask"`, :py:obj:`"silk"`, :py:obj:`"paste"`, and + :py:obj:`"copper"`. For internal layers, only :py:obj:`"copper"` is valid. + :rtype: :py:obj:`str` + """ if colors is None: colors = DEFAULT_COLORS @@ -584,25 +732,68 @@ class LayerStack: return setup_svg(tags, bounds, margin=margin, arg_unit=arg_unit, svg_unit=svg_unit, pagecolor="white", tag=tag, inkscape=inkscape) def bounding_box(self, unit=MM, default=None): + """ Calculate and return the bounding box of this layer stack. This bounding box will include all graphical + objects on all layers and drill files. Consider using :py:meth:`~.layers.LayerStack.board_bounds` instead if you + are interested in the actual board's bounding box, which usually will be smaller since there could be graphical + objects sticking out of the board's outline, especially on drawing or silkscreen layers. + + :param unit: :py:class:`.LengthUnit` or str (``'mm'`` or ``'inch'``). Which unit to return results in. Default: mm + :param default: Default value to return if there are no objects on any layer. + :returns: ``((x_min, y_min), (x_max, y_max))`` tuple of floats. + :rtype: tuple + """ return sum_bounds(( layer.bounding_box(unit, default=default) for layer in itertools.chain(self.graphic_layers.values(), self.drill_layers) ), default=default) - def board_bounds(self, unit=MM, default=None): + """ Calculate and return the bounding box of this board's outline. If this board has no outline, this function + falls back to :py:meth:`~.layers.LayerStack.bounding_box`, returning the bounding box of all objects on all + layers and drill files instead. + + :param unit: :py:class:`.LengthUnit` or str (``'mm'`` or ``'inch'``). Which unit to return results in. Default: mm + :param default: Default value to return if there are no objects on any layer. + :returns: ``((x_min, y_min), (x_max, y_max))`` tuple of floats. + :rtype: tuple + """ if self.outline: return self.outline.instance.bounding_box(unit=unit, default=default) else: return self.bounding_box(unit=unit, default=default) def offset(self, x=0, y=0, unit=MM): + """ Move all objects on all layers and drill files by the given amount in X and Y direction. + + :param x: :py:obj:`float` with length to move objects along X axis. + :param y: :py:obj:`float` with length to move objects along Y axis. + :param unit: :py:class:`.LengthUnit` or str (``'mm'`` or ``'inch'``). Which unit ``x`` and ``y`` are specified + in. Default: mm + """ for layer in itertools.chain(self.graphic_layers.values(), self.drill_layers): layer.offset(x, y, unit=unit) def rotate(self, angle, cx=0, cy=0, unit=MM): + """ Rotate all objects on all layers and drill files by the given angle around the given center of rotation + (default: coordinate origin (0, 0)). + + :param angle: Rotation angle in radians. + :param cx: :py:obj:`float` with X coordinate of center of rotation. Default: :py:obj:`0`. + :param cy: :py:obj:`float` with Y coordinate of center of rotation. Default: :py:obj:`0`. + :param unit: :py:class:`.LengthUnit` or str (``'mm'`` or ``'inch'``). Which unit ``cx`` and ``cy`` are specified + in. Default: mm + """ for layer in itertools.chain(self.graphic_layers.values(), self.drill_layers): layer.rotate(angle, cx, cy, unit=unit) def scale(self, factor, unit=MM): + """ Scale all objects on all layers and drill files by the given scaling factor. Only uniform scaling with one + common factor for both X and Y is supported since non-uniform scaling would not work with either arcs or + apertures in Gerber or Excellon files. + + :param factor: Scale factor. :py:obj:`1.0` for no scaling, :py:obj:`2.0` for doubling in both directions. + :param unit: :py:class:`.LengthUnit` or str (``'mm'`` or ``'inch'``) for compatibility with other transform + methods. Default: mm + """ + for layer in itertools.chain(self.graphic_layers.values(), self.drill_layers): layer.scale(factor) diff --git a/gerbonara/rs274x.py b/gerbonara/rs274x.py index 062f246..7bfd524 100644 --- a/gerbonara/rs274x.py +++ b/gerbonara/rs274x.py @@ -60,10 +60,14 @@ class GerberFile(CamFile): self.file_attrs = file_attrs or {} def to_excellon(self, plated=None): + """ Convert this excellon file into a :py:class:`~.excellon.ExcellonFile`. This will convert interpolated lines + into slots, and circular aperture flashes into holes. Other features such as ``G36`` polygons or flashes with + non-circular apertures will result in a :py:obj:`ValueError`. You can, of course, programmatically remove such + features from a :py:class:`GerberFile` before conversion. """ new_objs = [] new_tools = {} for obj in self.objects: - if (not isinstance(obj, go.Line) and isinstance(obj, go.Arc) and isinstance(obj, go.Flash)) or \ + if not (isinstance(obj, go.Line) or isinstance(obj, go.Arc) or isinstance(obj, go.Flash)) or \ not isinstance(obj.aperture, apertures.CircleAperture): raise ValueError(f'Cannot convert {obj} to excellon!') @@ -75,6 +79,7 @@ class GerberFile(CamFile): return ExcellonFile(objects=new_objs, comments=self.comments) def to_gerber(self): + """ Counterpart to :py:meth:`~.excellon.ExcellonFile.to_gerber`. Does nothing and returns :py:obj:`self`. """ return def merge(self, other, mode='above', keep_settings=False): -- cgit