kopia lustrzana https://gitlab.com/gerbolyze/gerbonara
Moar doc
rodzic
d43eff8b49
commit
8b40d15dab
|
@ -0,0 +1,135 @@
|
|||
.. _cli-doc:
|
||||
|
||||
Gerbonara's Command-Line Interface
|
||||
==================================
|
||||
|
||||
Gerbonara comes with a built-in command-line interface that has functions for analyzing, rendering, modifying, and
|
||||
merging Gerber files.
|
||||
|
||||
Invocation
|
||||
----------
|
||||
|
||||
There are two ways to call gerbonara's command-line interface:
|
||||
|
||||
.. :code:
|
||||
|
||||
$ gerbonara
|
||||
$ python -m gerbonara
|
||||
|
||||
For the first to work, make sure the installation's ``bin`` dir is in your ``$PATH``. If you installed gerbonara
|
||||
system-wide, that should be the case already, since the binary should end up in ``/usr/bin``. If you installed gerbonara
|
||||
using ``pip install --user``, make sure you have your user's ``~/.local/bin`` in your ``$PATH``.
|
||||
|
||||
Commands and their usage
|
||||
------------------------
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
$ gerbonara --help
|
||||
Usage: gerbonara [OPTIONS] COMMAND [ARGS]...
|
||||
|
||||
The gerbonara CLI allows you to analyze, render, modify and merge both
|
||||
individual Gerber or Excellon files as well as sets of those files
|
||||
|
||||
Options:
|
||||
--version
|
||||
--help Show this message and exit.
|
||||
|
||||
Commands:
|
||||
bounding-box Print the bounding box of a gerber file in "[x_min]...
|
||||
layers Read layers from a directory or zip with Gerber files and...
|
||||
merge Merge multiple single Gerber or Excellon files, or...
|
||||
meta Extract layer mapping and print it along with layer...
|
||||
render Render a gerber file, or a directory or zip of gerber...
|
||||
rewrite Parse a single gerber file, apply transformations, and...
|
||||
transform Transform all gerber files in a given directory or zip...
|
||||
|
||||
Rendering
|
||||
~~~~~~~~~
|
||||
|
||||
Gerbonara can render single Gerber (:py:class:`~.rs274x.GerberFile`) or Excellon (:py:class:`~.excellon.ExcellonFile`)
|
||||
layers, or whole board stacks (:py:class:`~.layers.LayerStack`) to SVG.
|
||||
|
||||
``gerbonara render``
|
||||
********************
|
||||
.. program:: gerbonara render
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
$ gerbonara render [OPTIONS] INPATH [OUTFILE]
|
||||
|
||||
``gerbonara render`` renders one or more Gerber or Excellon files as a single SVG file. It can read single files,
|
||||
directorys of files, and ZIP files. To read directories or zips, it applies gerbonara's layer filename matching rules.
|
||||
|
||||
.. option:: --warnings [default|ignore|once]
|
||||
|
||||
Enable or disable file format warnings during parsing (default: on)
|
||||
|
||||
|
||||
.. option:: -m, --input-map <json_file>
|
||||
|
||||
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
|
||||
re.fullmatch, and each value must either be the string ``ignore`` to remove this layer from previous automatic guesses,
|
||||
or a gerbonara layer name such as ``top copper``, ``inner_2 copper`` or ``bottom silk``.
|
||||
|
||||
.. option:: --use-builtin-name-rules / --no-builtin-name-rules
|
||||
|
||||
Disable built-in layer name rules and use only rules given by :option:`--input-map`
|
||||
|
||||
|
||||
.. option:: --force-zip
|
||||
|
||||
Force treating input path as a zip file (default: guess file type from extension and contents)
|
||||
|
||||
.. option:: --top, --bottom
|
||||
|
||||
Which side of the board to render
|
||||
|
||||
.. option:: --command-line-units <metric|us-customary>
|
||||
|
||||
Units for values given in other options. Default: millimeter
|
||||
|
||||
.. option:: --margin <float>
|
||||
|
||||
Add space around the board inside the viewport
|
||||
|
||||
.. option:: --force-bounds <min_x,min_y,max_x,max_y>
|
||||
|
||||
Force SVG bounding box to the given value.
|
||||
|
||||
.. option:: --inkscape, --standard-svg
|
||||
|
||||
Export in Inkscape SVG format with layers and stuff instead of plain SVG.
|
||||
|
||||
.. option:: --colorscheme <json_file>
|
||||
|
||||
Load colorscheme from given JSON file. The JSON file must contain a single dict with keys ``copper``, ``silk``,
|
||||
``mask``, ``paste``, ``drill`` and ``outline``. Each key must map to a string containing either a normal 6-digit hex
|
||||
color with leading hash sign, or an 8-digit hex color with leading hash sign, where the last two digits set the
|
||||
layer's alpha value (opacity), with ``ff`` being completely opaque, and ``00`` being invisibly transparent.
|
||||
|
||||
Modification
|
||||
~~~~~~~~~~~~
|
||||
|
||||
``gerbonara rewrite``
|
||||
*********************
|
||||
|
||||
``gerbonara transform``
|
||||
***********************
|
||||
|
||||
``gerbonara merge``
|
||||
*******************
|
||||
|
||||
File analysis
|
||||
~~~~~~~~~~~~~
|
||||
|
||||
``gerbonara bounding-box``
|
||||
**************************
|
||||
|
||||
``gerbonara meta``
|
||||
******************
|
||||
|
||||
``gerbonara layers``
|
||||
********************
|
||||
|
|
@ -12,10 +12,6 @@ syntactic hints, and can automatically match all files in a folder to their appr
|
|||
|
||||
:py:class:`.CamFile` is the common base class for all layer types.
|
||||
|
||||
|
||||
.. autoclass:: gerbonara.layers.LayerStack
|
||||
:members:
|
||||
|
||||
.. autoclass:: gerbonara.cam.CamFile
|
||||
:members:
|
||||
|
||||
|
@ -28,3 +24,6 @@ syntactic hints, and can automatically match all files in a folder to their appr
|
|||
.. autoclass:: gerbonara.ipc356.Netlist
|
||||
:members:
|
||||
|
||||
.. autoclass:: gerbonara.layers.LayerStack
|
||||
:members:
|
||||
|
||||
|
|
|
@ -46,6 +46,7 @@ Features
|
|||
:maxdepth: 2
|
||||
:caption: Contents:
|
||||
|
||||
cli
|
||||
api-concepts
|
||||
file-api
|
||||
object-api
|
||||
|
@ -73,6 +74,18 @@ Then, you are ready to read and write gerber files:
|
|||
w, h = stack.outline.size('mm')
|
||||
print(f'Board size is {w:.1f} mm x {h:.1f} mm')
|
||||
|
||||
Command-Line Interface
|
||||
======================
|
||||
|
||||
Gerbonara comes with a :ref:`built-in command-line interface<cli-doc>` that has functions for analyzing, rendering,
|
||||
modifying, and merging Gerber files. To access it, use either the ``gerbonara`` command that is part of the python
|
||||
package, or run ``python -m gerbonara``. For a list of functions or help on their usage, you can use:
|
||||
|
||||
.. code:: console
|
||||
|
||||
$ python -m gerbonara --help
|
||||
[...]
|
||||
$ python -m gerbonara render --help
|
||||
|
||||
Development
|
||||
===========
|
||||
|
@ -93,7 +106,7 @@ A copy of this documentation can also be found at gitlab:
|
|||
|
||||
https://gerbolyze.gitlab.io/gerbonara/
|
||||
|
||||
With Gebronara, we aim to support as many different format variants as possible. If you have a file that Gerbonara can't
|
||||
With Gerbonara, we aim to support as many different format variants as possible. If you have a file that Gerbonara can't
|
||||
open, please file an issue on our issue tracker. Even if Gerbonara can open all your files, for regression testing we
|
||||
are very interested in example files generated by any CAD or CAM tool that is not already on the list of supported
|
||||
tools.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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)')
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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='<stdin>', 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)
|
||||
|
||||
|
|
|
@ -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):
|
||||
|
|
Ładowanie…
Reference in New Issue