From a374483998baff2fab4c43027c83f8bf97e5fdf5 Mon Sep 17 00:00:00 2001 From: jaseg Date: Sun, 19 Feb 2023 23:42:17 +0100 Subject: cli: First draft of most of the CLI --- gerbonara/cli.py | 309 +++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 264 insertions(+), 45 deletions(-) (limited to 'gerbonara/cli.py') diff --git a/gerbonara/cli.py b/gerbonara/cli.py index 906997b..1cdfd42 100644 --- a/gerbonara/cli.py +++ b/gerbonara/cli.py @@ -1,71 +1,122 @@ #!/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() -@click.group() -@click.option('--version', is_flag=True, callback=print_version, expose_value=False, is_eager=True) -def cli(): - pass +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. -@cli.command() -@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.""" + 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) -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".') + (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 - args = [float(args) for arg in args.split()] - if not args: - raise ValueError('No transform arguments given') + def origin(): + translate(-x_min, -y_min) - if name == 'translate': - if len(args) != 2: - raise ValueError(f'transform "translate" requires exactly two coordinates (x, and y), not {len(args)}') + def center(): + translate(-x_min-width/2, -y_min-height/2) - x, y = args - layer.offset(x, y, unit) + exec(transform, {key: value for key, value in math.__dict__.items() if not key.startswith('_')}, locals()) - 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) +@click.group() +@click.option('--version', is_flag=True, callback=print_version, expose_value=False, is_eager=True) +def cli(): + pass - 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) +@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.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) + + unit = MM if command_line_units == 'metric' else Inch + + 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 colorscheme: + colorscheme = json.loads(colorscheme.read_text()) + + 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() -- cgit