cli: First draft of most of the CLI
This commit is contained in:
parent
f64b03efc7
commit
a374483998
8 changed files with 387 additions and 109 deletions
|
|
@ -254,8 +254,8 @@ class CircleAperture(Aperture):
|
|||
def scaled(self, scale):
|
||||
return replace(self,
|
||||
diameter=self.diameter*scale,
|
||||
hold_dia=None if self.hole_dia is None else self.hole_dia*scale,
|
||||
hold_rect_h=None if self.hole_rect_h is None else self.hole_rect_h*scale)
|
||||
hole_dia=None if self.hole_dia is None else self.hole_dia*scale,
|
||||
hole_rect_h=None if self.hole_rect_h is None else self.hole_rect_h*scale)
|
||||
|
||||
def to_macro(self):
|
||||
return ApertureMacroInstance(GenericMacros.circle, self._params(unit=MM))
|
||||
|
|
@ -310,8 +310,8 @@ class RectangleAperture(Aperture):
|
|||
return replace(self,
|
||||
w=self.w*scale,
|
||||
h=self.h*scale,
|
||||
hold_dia=None if self.hole_dia is None else self.hole_dia*scale,
|
||||
hold_rect_h=None if self.hole_rect_h is None else self.hole_rect_h*scale)
|
||||
hole_dia=None if self.hole_dia is None else self.hole_dia*scale,
|
||||
hole_rect_h=None if self.hole_rect_h is None else self.hole_rect_h*scale)
|
||||
|
||||
def to_macro(self):
|
||||
return ApertureMacroInstance(GenericMacros.rect,
|
||||
|
|
@ -375,15 +375,18 @@ class ObroundAperture(Aperture):
|
|||
return replace(self,
|
||||
w=self.w*scale,
|
||||
h=self.h*scale,
|
||||
hold_dia=None if self.hole_dia is None else self.hole_dia*scale,
|
||||
hold_rect_h=None if self.hole_rect_h is None else self.hole_rect_h*scale)
|
||||
hole_dia=None if self.hole_dia is None else self.hole_dia*scale,
|
||||
hole_rect_h=None if self.hole_rect_h is None else self.hole_rect_h*scale)
|
||||
|
||||
def to_macro(self):
|
||||
# generic macro only supports w > h so flip x/y if h > w
|
||||
inst = self if self.w > self.h else replace(self, w=self.h, h=self.w, **_rotate_hole_90(self), rotation=self.rotation-90)
|
||||
if self.w > self.h:
|
||||
inst = self
|
||||
else:
|
||||
inst = replace(self, w=self.h, h=self.w, **self._rotate_hole_90(), rotation=self.rotation-90)
|
||||
return ApertureMacroInstance(GenericMacros.obround,
|
||||
[MM(inst.w, self.unit),
|
||||
MM(ints.h, self.unit),
|
||||
MM(inst.h, self.unit),
|
||||
MM(inst.hole_dia, self.unit),
|
||||
MM(inst.hole_rect_h, self.unit),
|
||||
inst.rotation])
|
||||
|
|
@ -434,7 +437,7 @@ class PolygonAperture(Aperture):
|
|||
def scaled(self, scale):
|
||||
return replace(self,
|
||||
diameter=self.diameter*scale,
|
||||
hold_dia=None if self.hole_dia is None else self.hole_dia*scale)
|
||||
hole_dia=None if self.hole_dia is None else self.hole_dia*scale)
|
||||
|
||||
def to_macro(self):
|
||||
return ApertureMacroInstance(GenericMacros.polygon, self._params(MM))
|
||||
|
|
|
|||
|
|
@ -76,6 +76,11 @@ class FileSettings:
|
|||
num = self.number_format[1 if self.zeros == 'leading' else 0] or 0
|
||||
self._pad = '0'*num
|
||||
|
||||
@classmethod
|
||||
def defaults(kls):
|
||||
""" Return a set of good default FileSettings that will work for all gerber or excellon files. """
|
||||
return FileSettings(unit=MM, number_format=(4,5), zeros=None)
|
||||
|
||||
def to_radian(self, value):
|
||||
""" Convert a given numeric string or a given float from file units into radians. """
|
||||
value = float(value)
|
||||
|
|
@ -386,6 +391,16 @@ class CamFile:
|
|||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def scale(self, factor, unit=MM):
|
||||
""" Scale all objects in this file by the given factor. Only uniform scaling using a single factor in both
|
||||
directions is supported as for both Gerber and Excellon files, nonuniform scaling would distort circular
|
||||
flashes, which would lead to garbage results.
|
||||
|
||||
:param float factor: Scale factor
|
||||
:param unit: :py:class:`.LengthUnit` or str (``'mm'`` or ``'inch'``). Unit ``cx`` and ``cy`` are passed in. Default: mm
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
def is_empty(self):
|
||||
""" Check if there are any objects in this file. """
|
||||
|
|
|
|||
305
gerbonara/cli.py
305
gerbonara/cli.py
|
|
@ -1,16 +1,54 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
import math
|
||||
import click
|
||||
import re
|
||||
import warnings
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from .utils import MM, Inch
|
||||
from .cam import FileSettings
|
||||
from .rs274x import GerberFile
|
||||
from .layers import LayerStack, NamingScheme
|
||||
from . import __version__
|
||||
|
||||
|
||||
NAMING_SCHEMES = [n for n in dir(NamingScheme) if not n.startswith('_')]
|
||||
|
||||
def print_version(ctx, param, value):
|
||||
click.echo(f'Version {__version__}')
|
||||
if value and not ctx.resilient_parsing:
|
||||
click.echo(f'Version {__version__}')
|
||||
ctx.exit()
|
||||
|
||||
|
||||
def apply_transform(transform, unit, layer_or_stack):
|
||||
def translate(x, y):
|
||||
layer_or_stack.offset(x, y, unit)
|
||||
|
||||
def scale(factor):
|
||||
""" Scale layer by a given factor, e.g. 1.0 for no change, 2.0 to double all coordinates in both axes. Note that
|
||||
we only offer uniform scaling with a single factor applied along both coordinate axes because anything else
|
||||
would not be possible with arbitrary Gerber apertures, and definitely mess up holes. We could still do this, but
|
||||
the result would almost certainly not be what the user is looking for.
|
||||
|
||||
The main reason why this function might make sense is to fix up boards exported as G-code by programs that
|
||||
aren't EDA tools and that for whatever reason ended up exporting in a weird unit."""
|
||||
layer_or_stack.scale(factor)
|
||||
|
||||
def rotate(angle, cx=0, cy=0):
|
||||
layer_or_stack.rotate(math.radians(angle), (cx, cy), unit)
|
||||
|
||||
(x_min, y_min), (x_max, y_max) = layer_or_stack.bounding_box(unit, default=((0, 0), (0, 0)))
|
||||
width, height = x_max - x_min, y_max - y_min
|
||||
|
||||
def origin():
|
||||
translate(-x_min, -y_min)
|
||||
|
||||
def center():
|
||||
translate(-x_min-width/2, -y_min-height/2)
|
||||
|
||||
exec(transform, {key: value for key, value in math.__dict__.items() if not key.startswith('_')}, locals())
|
||||
|
||||
|
||||
@click.group()
|
||||
|
|
@ -20,52 +58,65 @@ def cli():
|
|||
|
||||
|
||||
@cli.command()
|
||||
@click.option('--format-warnings/--no-warnings', ' /-s', default=False, help='''Enable or disable file format warnings
|
||||
during parsing (default: off)''')
|
||||
@click.option('--version', is_flag=True, callback=print_version, expose_value=False, is_eager=True)
|
||||
@click.argument('infiles', nargs=-1, required=True)
|
||||
@click.argument('outfile', required=False)
|
||||
def render(infiles, outfile):
|
||||
""" Render one or more gerber files into an SVG file. Can process entire folders or zip files of gerber files, and
|
||||
can render individual files from zips using "[zip file]:[member]" syntax. To specify a layer mapping, use
|
||||
"[layer]=[file]" syntax, e.g. "top-silk=something.zip:foo/bar.gbr". Layers get merged in the same order that they
|
||||
appear on the command line, and for each logical layer only the last given file is rendered."""
|
||||
@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
|
||||
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".''')
|
||||
@click.option('--use-builtin-name-rules/--no-builtin-name-rules', default=True, help='''Disable built-in layer name
|
||||
rules and use only rules given by --input-map''')
|
||||
@click.option('--force-zip', is_flag=True, help='''Force treating input path as a zip file (default: guess file type
|
||||
from extension and contents)''')
|
||||
@click.option('--top/--bottom', help='Which side of the board to render')
|
||||
@click.option('--command-line-units', type=click.Choice(['metric', 'us-customary']), default='metric', help='Units for values given in --transform. Default: millimeter')
|
||||
@click.option('--margin', type=float, default=0.0, help='Add space around the board inside the viewport')
|
||||
@click.option('--force-bounds', help='Force SVG bounding box to value given as "min_x,min_y,max_x,max_y"')
|
||||
@click.option('--inkscape/--standard-svg', default=True, help='Export in Inkscape SVG format with layers and stuff.')
|
||||
@click.option('--colorscheme', type=click.Path(exists=True, path_type=Path), help='''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.''')
|
||||
@click.argument('inpath', type=click.Path(exists=True))
|
||||
@click.argument('outfile', type=click.File('w'), default='-')
|
||||
def render(inpath, outfile, format_warnings, input_map, use_builtin_name_rules, force_zip, top, command_line_units,
|
||||
margin, force_bounds, inkscape, colorscheme):
|
||||
""" Render a gerber file, or a directory or zip of gerber files into an SVG file. """
|
||||
|
||||
overrides = json.loads(input_map.read_bytes()) if input_map else None
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter('default' if format_warnings else 'ignore')
|
||||
if force_zip:
|
||||
stack = LayerStack.open_zip(inpath, overrides=overrides, autoguess=use_builtin_name_rules)
|
||||
else:
|
||||
stack = LayerStack.open(inpath, overrides=overrides, autoguess=use_builtin_name_rules)
|
||||
|
||||
def apply_transform(transform, unit, layer):
|
||||
for name, args, garbage in re.finditer(r'\s*([a-z]+)\s*\([\s-.0-9]*\)\s*|.*'):
|
||||
if name not in ('translate', 'scale', 'rotate'):
|
||||
raise ValueError(f'Unsupported transform {name}. Supported transforms are "translate", "scale" and "rotate".')
|
||||
unit = MM if command_line_units == 'metric' else Inch
|
||||
|
||||
args = [float(args) for arg in args.split()]
|
||||
if not args:
|
||||
raise ValueError('No transform arguments given')
|
||||
if force_bounds:
|
||||
min_x, min_y, max_x, max_y = list(map(float, force_bounds.split(',')))
|
||||
force_bounds = (min_x, min_y), (max_x, max_y)
|
||||
|
||||
if name == 'translate':
|
||||
if len(args) != 2:
|
||||
raise ValueError(f'transform "translate" requires exactly two coordinates (x, and y), not {len(args)}')
|
||||
if colorscheme:
|
||||
colorscheme = json.loads(colorscheme.read_text())
|
||||
|
||||
x, y = args
|
||||
layer.offset(x, y, unit)
|
||||
|
||||
elif name == 'scale':
|
||||
if len(args) > 1:
|
||||
# We don't support non-uniform scaling with scale_x != scale_y since that isn't possible with straight
|
||||
# Gerber polygon or circular apertures, or holes.
|
||||
raise ValueError(f'transform "scale" requires exactly one argument, not {len(args)}')
|
||||
|
||||
layer.scale(*args)
|
||||
|
||||
elif name == 'rotate':
|
||||
if len(args) not in (1, 3):
|
||||
raise ValueError(f'transform "rotate" requires either one or three coordinates (angle, origin x, and origin y), not {len(args)}')
|
||||
|
||||
angle = args[0]
|
||||
cx, cy = args[1:] or (0, 0)
|
||||
layer.rotate(angle, cx, cy, unit)
|
||||
outfile.write(str(stack.to_pretty_svg(side='top' if top else 'bottom', margin=margin, arg_unit=unit, svg_unit=MM,
|
||||
force_bounds=force_bounds, inkscape=inkscape, colors=colorscheme)))
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option('--version', is_flag=True, callback=print_version, expose_value=False, is_eager=True)
|
||||
@click.option('-t', '--transform', help='Apply transform given in pseudo-SVG syntax. Supported are "translate", "scale" and "rotate". Example: "translate(-10 0) rotate(45 0 5)"')
|
||||
@click.option('--format-warnings/--no-warnings', ' /-s', default=True, 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 functions
|
||||
translate(x, y), scale(factor) and rotate(angle, center_x?, center_y?), the bounding box variables x_min,
|
||||
y_min, x_max, y_max, width and height, and everything from python\'s built-in math module (e.g. pi, sqrt,
|
||||
sin). As convenience methods, center() and origin() are provided to center the board resp. move its
|
||||
bottom-left corner to the origin. Coordinates are given in --command-line-units, angles in degrees, and
|
||||
scale as a scale factor (as opposed to a percentage). Example: "translate(-10, 0); rotate(45, 0, 5)"''')
|
||||
@click.option('--command-line-units', type=click.Choice(['metric', 'us-customary']), default='metric', help='Units for values given in --transform. Default: millimeter')
|
||||
@click.option('-n', '--number-format', help='Override number format to use during export in "[integer digits].[decimal digits]" notation, e.g. "2.6".')
|
||||
@click.option('-u', '--units', type=click.Choice(['metric', 'us-customary']), help='Override export file units')
|
||||
|
|
@ -78,7 +129,7 @@ def apply_transform(transform, unit, layer):
|
|||
@click.argument('infile')
|
||||
@click.argument('outfile')
|
||||
def rewrite(transform, command_line_units, number_format, units, zero_suppression, keep_comments, reuse_input_settings,
|
||||
input_number_format, input_units, input_zero_suppression, infile, outfile):
|
||||
input_number_format, input_units, input_zero_suppression, infile, outfile, format_warnings):
|
||||
""" Parse a gerber file, apply transformations, and re-serialize it into a new gerber file. Without transformations,
|
||||
this command can be used to convert a gerber file to use different settings (e.g. units, precision), but can also be
|
||||
used to "normalize" gerber files in a weird format into a more standards-compatible one as gerbonara's gerber parser
|
||||
|
|
@ -95,7 +146,9 @@ def rewrite(transform, command_line_units, number_format, units, zero_suppressio
|
|||
if input_units:
|
||||
input_settings.unit = MM if input_units == 'metric' else Inch
|
||||
|
||||
f = GerberFile.open(infile, override_settings=input_settings)
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter('default' if format_warnings else 'ignore')
|
||||
f = GerberFile.open(infile, override_settings=input_settings)
|
||||
|
||||
if transform:
|
||||
command_line_units = MM if command_line_units == 'metric' else Inch
|
||||
|
|
@ -104,10 +157,11 @@ def rewrite(transform, command_line_units, number_format, units, zero_suppressio
|
|||
if reuse_input_settings:
|
||||
output_settings = FileSettings()
|
||||
else:
|
||||
output_settings = FileSettings(unit=MM, number_format=(4,5), zeros=None)
|
||||
output_settings = FileSettings.defaults()
|
||||
|
||||
if number_format:
|
||||
output_settings = number_format
|
||||
a, _, b = number_format.partition('.')
|
||||
output_settings.number_format = (int(a), int(b))
|
||||
|
||||
if units:
|
||||
output_settings.unit = MM if units == 'metric' else Inch
|
||||
|
|
@ -120,12 +174,84 @@ 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('-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
|
||||
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".''')
|
||||
@click.option('--use-builtin-name-rules/--no-builtin-name-rules', default=True, help='''Disable built-in layer name
|
||||
rules and use only rules given by --input-map''')
|
||||
@click.option('--format-warnings/--no-warnings', ' /-s', default=True, help='''Enable or disable file format warnings
|
||||
during parsing (default: on)''')
|
||||
@click.option('--units', type=click.Choice(['metric', 'us-customary']), default='metric', help='''Units for values given
|
||||
in transform script. Default: millimeter''')
|
||||
@click.option('-n', '--number-format', help='''Override number format to use during export in
|
||||
"[integer digits].[decimal digits]" notation, e.g. "2.6".''')
|
||||
@click.option('-u', '--units', type=click.Choice(['metric', 'us-customary']), help='Override export file units')
|
||||
@click.option('-z', '--zero-suppression', type=click.Choice(['off', 'leading', 'trailing']), help='''Override export zero
|
||||
suppression setting for exported Gerber files. Note: This does not affect Excellon output, which *always*
|
||||
uses explicit decimal points to avoid mismatches between output format and metadata in job files untouched
|
||||
by gerbonara.''')
|
||||
@click.option('--reuse-input-settings/--default-settings,', default=False, help='''Use the same export settings as the
|
||||
input file instead of sensible defaults.''')
|
||||
@click.option('--force-zip', is_flag=True, help='''Force treating input path as a zip file (default: guess file type
|
||||
from extension and contents)''')
|
||||
@click.option('--output-naming-scheme', type=click.Choice(NAMING_SCHEMES), help=f'''Name output files according to the
|
||||
selected naming scheme instead of keeping the old file names. Supported values are:
|
||||
{", ".join(NAMING_SCHEMES)}''')
|
||||
@click.argument('transform')
|
||||
@click.argument('inpath')
|
||||
@click.argument('outpath')
|
||||
def transform(transform, units, number_format, zero_suppression, reuse_input_settings, inpath, outpath,
|
||||
format_warnings, input_map, use_builtin_name_rules):
|
||||
""" Transform all gerber files in a given directory or zip file using the given python transformation script.
|
||||
|
||||
In the python transformation script you have access to the functions translate(x, y), scale(factor) and
|
||||
rotate(angle, center_x?, center_y?), the bounding box variables x_min, y_min, x_max, y_max, width and height,
|
||||
and everything from python\'s built-in math module (e.g. pi, sqrt, sin). As convenience methods, center() and
|
||||
origin() are provided to center the board resp. move its bottom-left corner to the origin. Coordinates are given
|
||||
in --command-line-units, angles in degrees, and scale as a scale factor (as opposed to a percentage). Example:
|
||||
"translate(-10, 0); rotate(45, 0, 5)"''')
|
||||
"""
|
||||
|
||||
overrides = json.loads(input_map.read_bytes()) if input_map else None
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter('default' if format_warnings else 'ignore')
|
||||
if force_zip:
|
||||
stack = LayerStack.open_zip(path, overrides=overrides, autoguess=use_builtin_name_rules)
|
||||
else:
|
||||
stack = LayerStack.open(path, overrides=overrides, autoguess=use_builtin_name_rules)
|
||||
|
||||
units = MM if units == 'metric' else Inch
|
||||
apply_transform(transform, units, stack)
|
||||
|
||||
output_settings = FileSettings() if reuse_input_settings else FileSettings.defaults()
|
||||
|
||||
if number_format:
|
||||
a, _, b = number_format.partition('.')
|
||||
output_settings.number_format = (int(a), int(b))
|
||||
|
||||
if units:
|
||||
output_settings.unit = MM if units == 'metric' else Inch
|
||||
|
||||
if zero_suppression:
|
||||
output_settings.zeros = None if zero_suppression == 'off' else zero_suppression
|
||||
|
||||
stack.save_to_directory(outpath, naming_scheme=naming_scheme,
|
||||
gerber_settings=output_settings,
|
||||
excellon_settings=output_settings.replace(zeros=None))
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option('--version', is_flag=True, callback=print_version, expose_value=False, is_eager=True)
|
||||
@click.option('--format-warnings/--no-warnings', ' /-s', default=True, help='''Enable or disable file format warnings
|
||||
during parsing (default: on)''')
|
||||
@click.option('--units', type=click.Choice(['us-customary', 'metric']), default='metric', help='Output bounding box in this unit (default: millimeter)')
|
||||
@click.option('--input-number-format', help='Override number format of input file (mostly useful for Excellon files)')
|
||||
@click.option('--input-units', type=click.Choice(['us-customary', 'metric']), help='Override units of input file')
|
||||
@click.option('--input-zero-suppression', type=click.Choice(['off', 'leading', 'trailing']), help='Override zero suppression setting of input file')
|
||||
@click.argument('infile')
|
||||
def bounding_box(infile, input_number_format, input_units, input_zero_suppression, units):
|
||||
def bounding_box(infile, format_warnings, input_number_format, input_units, input_zero_suppression, units):
|
||||
""" Print the bounding box of a gerber file in "[x_min] [y_min] [x_max] [y_max]" format. The bounding box contains
|
||||
all graphic objects in this file, so e.g. a 100 mm by 100 mm square drawn with a 1mm width circular aperture will
|
||||
result in an 101 mm by 101 mm bounding box.
|
||||
|
|
@ -142,12 +268,105 @@ def bounding_box(infile, input_number_format, input_units, input_zero_suppressio
|
|||
if input_units:
|
||||
input_settings.unit = MM if input_units == 'metric' else Inch
|
||||
|
||||
f = GerberFile.open(infile, override_settings=input_settings)
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter('default' if format_warnings else 'ignore')
|
||||
f = GerberFile.open(infile, override_settings=input_settings)
|
||||
|
||||
units = MM if units == 'metric' else Inch
|
||||
(x_min, y_min), (x_max, y_max) = f.bounding_box(unit=units)
|
||||
print(f'{x_min:.6f} {y_min:.6f} {x_max:.6f} {y_max:.6f} [{units}]')
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option('--format-warnings/--no-warnings', ' /-s', default=True, 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)')
|
||||
@click.argument('path', type=click.Path(exists=True))
|
||||
def layers(path, force_zip, format_warnings):
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter('default' if format_warnings else 'ignore')
|
||||
if force_zip:
|
||||
stack = LayerStack.open_zip(path)
|
||||
else:
|
||||
stack = LayerStack.open(path)
|
||||
|
||||
print(f'Detected board name: {stack.board_name}')
|
||||
print(f'Probably exported by: {stack.generator or "Unknown"}')
|
||||
print(f'Board bounding box: {stack.bounding_box()} [mm]')
|
||||
|
||||
if stack.netlist:
|
||||
print(f'Found netlist at {stack.netlist.original_path}')
|
||||
else:
|
||||
print('No netlist found')
|
||||
|
||||
print('Graphical layers:')
|
||||
for (side, function), layer in stack.graphic_layers.items():
|
||||
print(f'{side} {function}: {layer}')
|
||||
if not stack.graphic_layers:
|
||||
print('(no graphical layers)')
|
||||
|
||||
print('Drill layers:')
|
||||
for layer in stack.drill_layers:
|
||||
print(layer)
|
||||
if not stack.drill_layers:
|
||||
print('(no drill layers)')
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option('--format-warnings/--no-warnings', ' /-s', default=False, help='''Enable or disable file format warnings
|
||||
during parsing (default: off)''')
|
||||
@click.option('--force-zip', is_flag=True, help='Force treating input path as zip file (default: guess file type from extension and contents)')
|
||||
@click.argument('path', type=click.Path(exists=True))
|
||||
def meta(path, force_zip, format_warnings):
|
||||
""" Extract layer mapping and print it along with layer metadata as JSON to stdout. A machine-readable variant of
|
||||
the "layers" command. All lengths in the JSON are given in millimeter. """
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter('default' if format_warnings else 'ignore')
|
||||
if force_zip:
|
||||
stack = LayerStack.open_zip(path)
|
||||
else:
|
||||
stack = LayerStack.open(path)
|
||||
|
||||
out = {}
|
||||
out['board_name'] = stack.board_name
|
||||
out['generator'] = stack.generator
|
||||
(min_x, min_y), (max_x, max_y) = stack.bounding_box(default=((None, None), (None, None)))
|
||||
out['bounding_box'] = {'min_x': min_x, 'min_y': min_y, 'max_x': max_x, 'max_y': max_y}
|
||||
out['path'] = str(stack.original_path)
|
||||
|
||||
if stack.netlist:
|
||||
out['netlist'] = {
|
||||
'format': 'IPC-356',
|
||||
'path': str(stack.netlist.original_path),
|
||||
'records': len(stack.netlist.test_records),
|
||||
'conductors': len(stack.netlist.conductors),
|
||||
'outlines': len(stack.netlist.outlines),
|
||||
}
|
||||
|
||||
out['graphical_layers'] = {}
|
||||
for (side, function), layer in stack.graphic_layers.items():
|
||||
d = out['graphical_layers'][side] = out['graphical_layers'].get(side, {})
|
||||
(min_x, min_y), (max_x, max_y) = layer.bounding_box(default=((None, None), (None, None)))
|
||||
d[function] = {
|
||||
'format': 'Gerber',
|
||||
'path': str(layer.original_path),
|
||||
'apertures': len(layer.apertures),
|
||||
'objects': len(layer.objects),
|
||||
'bounding_box': {'min_x': min_x, 'min_y': min_y, 'max_x': max_x, 'max_y': max_y},
|
||||
}
|
||||
|
||||
out['drill_layers'] = []
|
||||
for layer in stack.drill_layers:
|
||||
out['drill_layers'].append({
|
||||
'format': 'Excellon',
|
||||
'path': str(layer.original_path),
|
||||
'plating': layer.plating_type,
|
||||
})
|
||||
|
||||
print(json.dumps(out))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
cli()
|
||||
|
||||
|
|
|
|||
|
|
@ -207,19 +207,22 @@ class ExcellonFile(CamFile):
|
|||
|
||||
def __str__(self):
|
||||
name = f'{self.original_path.name} ' if self.original_path else ''
|
||||
if self.is_plated:
|
||||
plating = 'plated'
|
||||
elif self.is_nonplated:
|
||||
plating = 'nonplated'
|
||||
elif self.is_mixed_plating:
|
||||
plating = 'mixed plating'
|
||||
else:
|
||||
plating = 'unknown plating'
|
||||
return f'<ExcellonFile {name}{plating} with {len(list(self.drills()))} drills, {len(list(self.slots()))} slots using {len(self.drill_sizes())} tools>'
|
||||
return f'<ExcellonFile {name}{self.plating_type} with {len(list(self.drills()))} drills, {len(list(self.slots()))} slots using {len(self.drill_sizes())} tools>'
|
||||
|
||||
def __repr__(self):
|
||||
return str(self)
|
||||
|
||||
@property
|
||||
def plating_type(self):
|
||||
if self.is_plated:
|
||||
return 'plated'
|
||||
elif self.is_nonplated:
|
||||
return 'nonplated'
|
||||
elif self.is_mixed_plating:
|
||||
return 'mixed plating'
|
||||
else:
|
||||
return 'unknown plating'
|
||||
|
||||
@property
|
||||
def is_plated(self):
|
||||
""" Test if *all* holes or slots in this file are plated. """
|
||||
|
|
|
|||
|
|
@ -105,20 +105,19 @@ class GraphicObject:
|
|||
dx, dy = self.unit(dx, unit), self.unit(dy, unit)
|
||||
self._offset(dx, dy)
|
||||
|
||||
def scale(self, sx, sy, unit=MM):
|
||||
def scale(self, factor, unit=MM):
|
||||
""" Scale this feature in both its dimensions and location.
|
||||
|
||||
.. note:: The scale values are scalars, and the unit argument is irrelevant, but is kept for API consistency.
|
||||
.. note:: The scale factor is a scalar, and the unit argument is irrelevant, but is kept for API consistency.
|
||||
|
||||
.. note:: If this object references an aperture, this aperture is not modified. You will have to transform this
|
||||
aperture yourself.
|
||||
|
||||
:param float sx: X scale, 1 to keep the object as is, larger values to enlarge, smaller values to shrink.
|
||||
Negative values are permitted.
|
||||
:param float sy: Y scale as above.
|
||||
:param float factor: Scale factor, 1 to keep the object as is, larger values to enlarge, smaller values to
|
||||
shrink. Negative values are permitted.
|
||||
"""
|
||||
|
||||
self._scale(sx, sy)
|
||||
self._scale(factor)
|
||||
|
||||
def rotate(self, rotation, cx=0, cy=0, unit=MM):
|
||||
""" Rotate this object. The center of rotation can be given in either unit, and is automatically converted into
|
||||
|
|
@ -232,9 +231,9 @@ class Flash(GraphicObject):
|
|||
def _rotate(self, rotation, cx=0, cy=0):
|
||||
self.x, self.y = gp.rotate_point(self.x, self.y, rotation, cx, cy)
|
||||
|
||||
def _scale(self, sx, sy):
|
||||
self.x *= sx
|
||||
self.y *= sy
|
||||
def _scale(self, factor):
|
||||
self.x *= factor
|
||||
self.y *= factor
|
||||
|
||||
def to_primitives(self, unit=None):
|
||||
conv = self.converted(unit)
|
||||
|
|
@ -303,10 +302,10 @@ class Region(GraphicObject):
|
|||
(arc[0], gp.rotate_point(*arc[1], angle, cx-p[0], cy-p[1])) if arc else None
|
||||
for p, arc in zip(self.outline, self.arc_centers) ]
|
||||
|
||||
def _scale(self, sx, sy):
|
||||
self.outline = [ (x*sx, y*sy) for x, y in self.outline ]
|
||||
def _scale(self, factor):
|
||||
self.outline = [ (x*factor, y*factor) for x, y in self.outline ]
|
||||
self.arc_centers = [
|
||||
(arc[0], (arc[1][0]*sx, arc[1][1]*sy)) if arc else None
|
||||
(arc[0], (arc[1][0]*factor, arc[1][1]*factor)) if arc else None
|
||||
for p, arc in zip(self.outline, self.arc_centers) ]
|
||||
|
||||
def append(self, obj):
|
||||
|
|
@ -407,11 +406,11 @@ class Line(GraphicObject):
|
|||
self.x1, self.y1 = gp.rotate_point(self.x1, self.y1, rotation, cx, cy)
|
||||
self.x2, self.y2 = gp.rotate_point(self.x2, self.y2, rotation, cx, cy)
|
||||
|
||||
def _scale(self, sx=1, sy=1):
|
||||
self.x1 *= sx
|
||||
self.y1 *= sy
|
||||
self.x2 *= sx
|
||||
self.y2 *= sy
|
||||
def _scale(self, factor):
|
||||
self.x1 *= factor
|
||||
self.y1 *= factor
|
||||
self.x2 *= factor
|
||||
self.y2 *= factor
|
||||
|
||||
@property
|
||||
def p1(self):
|
||||
|
|
@ -645,13 +644,13 @@ class Arc(GraphicObject):
|
|||
self.x2, self.y2 = gp.rotate_point(self.x2, self.y2, rotation, cx, cy)
|
||||
self.cx, self.cy = new_cx - self.x1, new_cy - self.y1
|
||||
|
||||
def _scale(self, sx=1, sy=1):
|
||||
self.x1 *= sx
|
||||
self.y1 *= sy
|
||||
self.x2 *= sx
|
||||
self.y2 *= sy
|
||||
self.cx *= sx
|
||||
self.cy *= sy
|
||||
def _scale(self, factor):
|
||||
self.x1 *= factor
|
||||
self.y1 *= factor
|
||||
self.x2 *= factor
|
||||
self.y2 *= factor
|
||||
self.cx *= factor
|
||||
self.cy *= factor
|
||||
|
||||
def as_primitive(self, unit=None):
|
||||
conv = self.converted(unit)
|
||||
|
|
|
|||
|
|
@ -144,8 +144,8 @@ def common_prefix(l):
|
|||
baseline = score(1)
|
||||
if len(l) - baseline > 5:
|
||||
continue
|
||||
for n in range(2, len(cand)):
|
||||
if len(l) - score(n) > 5:
|
||||
for n in range(len(cand) if '.' not in cand else cand.index('.')+1, 2, -1):
|
||||
if len(l) - score(n) < 5:
|
||||
break
|
||||
out.append(cand[:n-1])
|
||||
|
||||
|
|
@ -237,31 +237,31 @@ def layername_autoguesser(fn):
|
|||
|
||||
|
||||
class LayerStack:
|
||||
|
||||
def __init__(self, graphic_layers, drill_layers, netlist=None, board_name=None, original_path=None, was_zipped=False):
|
||||
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
|
||||
self.board_name = board_name
|
||||
self.netlist = netlist
|
||||
self.original_path = original_path
|
||||
self.was_zipped = was_zipped
|
||||
self.generator = generator
|
||||
|
||||
@classmethod
|
||||
def open(kls, path, board_name=None, lazy=False):
|
||||
def open(kls, path, board_name=None, lazy=False, overrides=None, autoguess=True):
|
||||
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)
|
||||
|
||||
path = Path(path)
|
||||
if path.is_dir():
|
||||
return kls.open_dir(path, board_name=board_name, lazy=lazy)
|
||||
return kls.open_dir(path, board_name=board_name, lazy=lazy, overrides=overrides, autoguess=autoguess)
|
||||
elif path.suffix.lower() == '.zip' or is_zipfile(path):
|
||||
return kls.open_zip(path, board_name=board_name, lazy=lazy)
|
||||
return kls.open_zip(path, board_name=board_name, lazy=lazy, overrides=overrides, autoguess=autoguess)
|
||||
else:
|
||||
return kls.from_files([path], board_name=board_name, lazy=lazy)
|
||||
return kls.from_files([path], board_name=board_name, lazy=lazy, overrides=overrides, autoguess=autoguess)
|
||||
|
||||
@classmethod
|
||||
def open_zip(kls, file, original_path=None, board_name=None, lazy=False):
|
||||
def open_zip(kls, file, original_path=None, board_name=None, lazy=False, overrides=None, autoguess=True):
|
||||
tmpdir = tempfile.TemporaryDirectory()
|
||||
tmp_indir = Path(tmpdir.name) / 'input'
|
||||
tmp_indir.mkdir()
|
||||
|
|
@ -276,20 +276,39 @@ class LayerStack:
|
|||
return inst
|
||||
|
||||
@classmethod
|
||||
def open_dir(kls, directory, board_name=None, lazy=False):
|
||||
def open_dir(kls, directory, board_name=None, lazy=False, overrides=None, autoguess=True):
|
||||
|
||||
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, original_path=directory)
|
||||
return kls.from_files(files, board_name=board_name, lazy=lazy, original_path=directory, overrides=overrides,
|
||||
autoguess=autoguess)
|
||||
inst.original_path = directory
|
||||
return inst
|
||||
|
||||
@classmethod
|
||||
def from_files(kls, files, board_name=None, lazy=False, original_path=None, was_zipped=False):
|
||||
generator, filemap = best_match(files)
|
||||
def from_files(kls, files, board_name=None, lazy=False, original_path=None, was_zipped=False, overrides=None,
|
||||
autoguess=True):
|
||||
if autoguess:
|
||||
generator, filemap = best_match(files)
|
||||
else:
|
||||
generator, filemap = 'custom', {}
|
||||
all_generator_hints = set()
|
||||
|
||||
if overrides:
|
||||
for fn in files:
|
||||
for expr, layer in overrides.items():
|
||||
if re.fullmatch(expr, fn.name):
|
||||
if layer == 'ignore':
|
||||
for entries in filemap.values():
|
||||
if fn in entries:
|
||||
entries.remove(fn)
|
||||
else:
|
||||
if layer in filemap and fn in filemap[layer]:
|
||||
filemap[layer].remove(fn)
|
||||
filemap[layer] = filemap.get(layer, []) + [fn]
|
||||
|
||||
if sum(len(files) for files in filemap.values()) < 6:
|
||||
warnings.warn('Ambiguous gerber filenames. Trying last-resort autoguesser.')
|
||||
|
|
@ -404,6 +423,7 @@ class LayerStack:
|
|||
|
||||
if not lazy:
|
||||
hints = set(layer.generator_hints) | { generator }
|
||||
all_generator_hints |= hints
|
||||
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.')
|
||||
|
|
@ -414,7 +434,7 @@ class LayerStack:
|
|||
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)
|
||||
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=''):
|
||||
if path.is_file():
|
||||
|
|
@ -428,7 +448,8 @@ class LayerStack:
|
|||
with le_zip.open(prefix + str(path), 'w') as out:
|
||||
out.write(layer.instance.write_to_bytes())
|
||||
|
||||
def save_to_directory(self, path, naming_scheme={}, overwrite_existing=True):
|
||||
def save_to_directory(self, path, naming_scheme={}, overwrite_existing=True,
|
||||
gerber_settings=None, excellon_settings=None):
|
||||
outdir = Path(path)
|
||||
outdir.mkdir(parents=True, exist_ok=overwrite_existing)
|
||||
|
||||
|
|
@ -442,7 +463,7 @@ class LayerStack:
|
|||
def get_name(layer_type, layer):
|
||||
nonlocal naming_scheme
|
||||
|
||||
if (m := re.match('inner_([0-9]*) copper', layer_type)):
|
||||
if (m := re.match('inner_([0-9]+) copper', layer_type)):
|
||||
layer_type = 'inner copper'
|
||||
num = int(m[1])
|
||||
else:
|
||||
|
|
@ -569,6 +590,18 @@ class LayerStack:
|
|||
else:
|
||||
return self.bounding_box(unit=unit, default=default)
|
||||
|
||||
def offset(self, x=0, y=0, unit=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):
|
||||
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):
|
||||
for layer in itertools.chain(self.graphic_layers.values(), self.drill_layers):
|
||||
layer.scale(factor)
|
||||
|
||||
def merge_drill_layers(self):
|
||||
target = ExcellonFile(comments=['Drill files merged by gerbonara'])
|
||||
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ class GerberFile(CamFile):
|
|||
not isinstance(obj.aperture, apertures.CircleAperture):
|
||||
raise ValueError(f'Cannot convert {obj} to excellon!')
|
||||
|
||||
if not (new_tool := new_tools.get(id(obj.aperture))):
|
||||
if not (new_tool := new_tools.get(obj.aperture)):
|
||||
# TODO plating?
|
||||
new_tool = new_tools[id(obj.aperture)] = apertures.ExcellonTool(obj.aperture.diameter, plated=plated, unit=obj.aperture.unit)
|
||||
new_objs.append(dataclasses.replace(obj, aperture=new_tool))
|
||||
|
|
@ -271,16 +271,19 @@ class GerberFile(CamFile):
|
|||
def __len__(self):
|
||||
return len(self.objects)
|
||||
|
||||
def scale(self, scale, unit=MM):
|
||||
def scale(self, factor, unit=MM):
|
||||
scaled_apertures = {}
|
||||
|
||||
for obj in self.objects:
|
||||
obj.scale(sx, sy)
|
||||
for ap in self.apertures:
|
||||
scaled_apertures[id(ap)] = ap.scaled(factor)
|
||||
|
||||
if (aperture := getattr(obj, 'aperture', None)):
|
||||
if not (scaled := scaled_apertures.get(aperture)):
|
||||
scaled = scaled_apertures[aperture] = aperture.scaled(scale)
|
||||
obj.aperture = scaled
|
||||
for obj in self.objects:
|
||||
obj.scale(factor)
|
||||
|
||||
if (obj_ap := getattr(obj, 'aperture', None)):
|
||||
obj.aperture = scaled_apertures[id(obj_ap)]
|
||||
|
||||
self.apertures = list(scaled_apertures.values())
|
||||
|
||||
def offset(self, dx=0, dy=0, unit=MM):
|
||||
# TODO round offset to file resolution
|
||||
|
|
|
|||
|
|
@ -68,6 +68,9 @@ class LengthUnit:
|
|||
self.shorthand = shorthand
|
||||
self.factor = this_in_mm
|
||||
|
||||
def __hash__(self):
|
||||
return hash((self.name, self.shorthand, self.factor))
|
||||
|
||||
def convert_from(self, unit, value):
|
||||
""" Convert ``value`` from ``unit`` into this unit.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue