diff options
-rw-r--r-- | gerbonara/cli.py | 65 | ||||
-rw-r--r-- | gerbonara/ipc356.py | 6 | ||||
-rw-r--r-- | gerbonara/layers.py | 8 | ||||
-rw-r--r-- | gerbonara/rs274x.py | 2 | ||||
-rw-r--r-- | gerbonara/tests/conftest.py | 7 | ||||
-rw-r--r-- | gerbonara/tests/image_support.py | 3 | ||||
-rw-r--r-- | gerbonara/tests/resources/example_coincident_hole.gbr | 3 | ||||
-rw-r--r-- | gerbonara/tests/test_excellon.py | 6 | ||||
-rw-r--r-- | gerbonara/tests/test_rs274x.py | 2 |
9 files changed, 68 insertions, 34 deletions
diff --git a/gerbonara/cli.py b/gerbonara/cli.py index 09d265f..c40fe31 100644 --- a/gerbonara/cli.py +++ b/gerbonara/cli.py @@ -67,6 +67,21 @@ class Coordinate(click.ParamType): except ValueError: self.fail(f'{value!r} is not a valid coordinate. A coordinate consists of exactly {self.dimension} comma-separate floating-point numbers.') +class Rotation(click.ParamType): + name = 'rotation' + + def convert(self, value, param, ctx): + try: + coords = map(float, value.split(',')) + if len(coords) not in (1, 3): + raise ValueError() + + theta, x, y, *_rest = *coords, 0, 0 + return theta, x, y + + except ValueError: + self.fail(f'{value!r} is not a valid rotation. A rotation is either a floating point angle ("[theta]"), or the same angle followed by comma-separated X and Y coordinates of the rotation center ("[theta],[cx],[cy]").') + class Unit(click.Choice): name = 'unit' @@ -109,7 +124,8 @@ def cli(): @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', default=True, help='Which side of the board to render') -@click.option('--command-line-units', type=Unit(), default=MM, help='Units for values given in other options. Default: millimeter') +@click.option('--command-line-units', type=Unit(), default=MM, help='''Units for values given in other options. 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.') @@ -147,24 +163,31 @@ def render(inpath, outfile, format_warnings, input_map, use_builtin_name_rules, @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 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=Unit(), default=MM, help='Units for values given in other options. 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('-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=Unit(), default=MM, help='''Units for values given in other options. 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=Unit(), help='Override export file units') -@click.option('-z', '--zero-suppression', type=click.Choice(['off', 'leading', 'trailing']), help='Override export zero suppression setting. Note: The meaning of this value is like in the Gerber spec for both Gerber and Excellon files!') -@click.option('--keep-comments/--drop-comments', help='Keep gerber comments. Note: Comments will be prepended to the start of file, and will not occur in their old position.') +@click.option('-z', '--zero-suppression', type=click.Choice(['off', 'leading', 'trailing']), help='''Override export + zero suppression setting. Note: The meaning of this value is like in the Gerber spec for both Gerber and + Excellon files!''') +@click.option('--keep-comments/--drop-comments', help='''Keep gerber comments. Note: Comments will be prepended to the + start of file, and will not occur in their old position.''') @click.option('--reuse-input-settings', 'output_format', flag_value='reuse', help='''Use the same export settings as the input file instead of sensible defaults.''') @click.option('--default-settings', 'output_format', default=True, flag_value='defaults', help='''Use sensible defaults for the output file format settings (default).''') @click.option('--input-number-format', help='Override number format of input file (mostly useful for Excellon files)') @click.option('--input-units', type=Unit(), 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.option('--input-zero-suppression', type=click.Choice(['off', 'leading', 'trailing']), help='''Override zero + suppression setting of input file''') @click.argument('infile') @click.argument('outfile') def rewrite(transform, command_line_units, number_format, units, zero_suppression, keep_comments, output_format, @@ -258,15 +281,16 @@ def transform(transform, units, output_format, inpath, outpath, @cli.command() -@click.option('--command-line-units', type=click.Choice(['metric', 'us-customary']), default='metric', help='Units for values given in --transform. Default: millimeter') +@click.option('--command-line-units', type=Unit(), default=MM, help='Units for values given in --transform. Default: + millimeter') @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('--offset', multiple=True, type=Coordinate(), help="""Offset for the n'th file as a "x,y" string in unit given by --command-line-units (default: millimeter). Can be given multiple times, and the first option affects the first input, the second option affects the second input, and so on.""") -@click.option('--rotation', multiple=True, type=int, help="""Rotation for the n'th file in degrees clockwise. Can be - given multiple times, and the first option affects the first input, the second option affects the second - input, and so on.""") +@click.option('--rotation', multiple=True, type=Rotation(), help="""Rotation for the n'th file in degrees clockwise, + optionally followed by comma-separated rotation center X and Y coordinates. Can be given multiple times, + and the first option affects the first input, the second option affects the second input, and so on.""") @click.option('-m', '--input-map', type=click.Path(exists=True, path_type=Path), multiple=True, help='''Extend or override layer name mapping with name map from JSON file. This option can be given multiple times, in which case the n'th option affects only the n'th input, like with --offset and --rotation. The JSON file @@ -300,13 +324,20 @@ def merge(inpath, outpath, offset, rotation, input_map, command_line_units, outp raise click.UsageError('More --offset, --rotation or --input-map options than input files') offset = offset or (0, 0) - rotation = rotation or 0 + theta, cx, cy = rotation or 0, 0, 0 overrides = json.loads(input_map.read_bytes()) if input_map else None with warnings.catch_warnings(): warnings.simplefilter(format_warnings) stack = LayerStack.open(p, overrides=overrides, autoguess=use_builtin_name_rules) + + if not math.isclose(offset[0], 0, abs_tol=1e-3) and math.isclose(offset[1], 0, abs_tol=1e-3): + stack.offset(*offset, command_line_units) + + if not math.isclose(theta, 0, abs_tol=1e-2): + stack.rotate(theta, cx, cy) + if target is None: target = stack else: diff --git a/gerbonara/ipc356.py b/gerbonara/ipc356.py index 435a805..9da129a 100644 --- a/gerbonara/ipc356.py +++ b/gerbonara/ipc356.py @@ -326,17 +326,17 @@ class NetlistParser(object): if name == 'UNITS': if value in ('CUST', 'CUST 0'): - self.settings.units = Inch + self.settings.unit = Inch self.settings.angle_unit = 'degree' self.has_unit = True elif value == 'CUST 1': - self.settings.units = MM + self.settings.unit = MM self.settings.angle_unit = 'degree' self.has_unit = True elif value == 'CUST 2': - self.settings.units = Inch + self.settings.unit = Inch self.settings.angle_unit = 'radian' self.has_unit = True diff --git a/gerbonara/layers.py b/gerbonara/layers.py index a1fafc6..a1c854a 100644 --- a/gerbonara/layers.py +++ b/gerbonara/layers.py @@ -154,7 +154,7 @@ def common_prefix(l): return sorted(out, key=len)[-1]
-def autoguess(filenames):
+def do_autoguess(filenames):
prefix = common_prefix([f.name for f in filenames])
matches = {}
@@ -313,7 +313,7 @@ class LayerStack: if sum(len(files) for files in filemap.values()) < 6:
warnings.warn('Ambiguous gerber filenames. Trying last-resort autoguesser.')
generator = None
- filemap = autoguess(files)
+ filemap = do_autoguess(files)
if len(filemap) < 6:
raise ValueError('Cannot figure out gerber file mapping. Partial map is: ', filemap)
@@ -338,13 +338,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 = 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 = 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
diff --git a/gerbonara/rs274x.py b/gerbonara/rs274x.py index 3d28cc6..3efb825 100644 --- a/gerbonara/rs274x.py +++ b/gerbonara/rs274x.py @@ -88,7 +88,7 @@ class GerberFile(CamFile): # dedup apertures new_apertures = {} replace_apertures = {} - mock_settings = FileSettings() + mock_settings = FileSettings.defaults() for ap in self.apertures + other.apertures: gbr = ap.to_gerber(mock_settings) if gbr not in new_apertures: diff --git a/gerbonara/tests/conftest.py b/gerbonara/tests/conftest.py index 131ca28..b999027 100644 --- a/gerbonara/tests/conftest.py +++ b/gerbonara/tests/conftest.py @@ -3,7 +3,7 @@ from pathlib import Path import pytest -from .image_support import ImageDifference +from .image_support import ImageDifference, run_cargo_cmd def pytest_assertrepr_compare(op, left, right): if isinstance(left, ImageDifference) or isinstance(right, ImageDifference): @@ -28,3 +28,8 @@ def pytest_sessionstart(session): # on coordinator for f in chain(fail_dir.glob('*.gbr'), fail_dir.glob('*.png')): f.unlink() + + try: + run_cargo_cmd('resvg', '--help') + except FileNotFoundError: + pytest.exit('resvg binary not found, aborting test.', 2) diff --git a/gerbonara/tests/image_support.py b/gerbonara/tests/image_support.py index 8941f82..862baf5 100644 --- a/gerbonara/tests/image_support.py +++ b/gerbonara/tests/image_support.py @@ -104,6 +104,7 @@ def gerbv_export(in_gbr, out_svg, export_format='svg', origin=(0, 0), size=(6, 6 cachefile = cachedir / f'{digest}.svg' if not cachefile.is_file(): + print(f'Building cache for {Path(in_gbr).name}') # NOTE: gerbv seems to always export 'clear' polarity apertures as white, irrespective of --foreground, --background # and project file color settings. # TODO: File issue upstream. @@ -138,6 +139,8 @@ def gerbv_export(in_gbr, out_svg, export_format='svg', origin=(0, 0), size=(6, 6 f'--foreground={fg}', '-o', str(cachefile), '-p', f.name] subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + else: + print(f'Re-using cache for {Path(in_gbr).name}') shutil.copy(cachefile, out_svg) @contextmanager diff --git a/gerbonara/tests/resources/example_coincident_hole.gbr b/gerbonara/tests/resources/example_coincident_hole.gbr index 4f896ea..3adef36 100644 --- a/gerbonara/tests/resources/example_coincident_hole.gbr +++ b/gerbonara/tests/resources/example_coincident_hole.gbr @@ -1,7 +1,6 @@ G04 ex2: overlapping* %FSLAX24Y24*% %MOMM*% -%SRX1Y1I0.000J0.000*% %ADD10C,1.00000*% G01* %LPD*% @@ -21,4 +20,4 @@ X10000Y50000D01* G04 second fully coincident linear segment* X0D01* G37* -M02*
\ No newline at end of file +M02* diff --git a/gerbonara/tests/test_excellon.py b/gerbonara/tests/test_excellon.py index 3221439..addf89e 100644 --- a/gerbonara/tests/test_excellon.py +++ b/gerbonara/tests/test_excellon.py @@ -31,8 +31,8 @@ from .utils import * from ..utils import Inch, MM REFERENCE_FILES = { - 'easyeda/Gerber_Drill_NPTH.DRL': (None, None), - 'easyeda/Gerber_Drill_PTH.DRL': (None, 'easyeda/Gerber_TopLayer.GTL'), + 'easyeda/Gerber_Drill_NPTH.DRL': (('inch', 'leading', 4), None), + 'easyeda/Gerber_Drill_PTH.DRL': (('inch', 'leading', 4), 'easyeda/Gerber_TopLayer.GTL'), # Altium uses an excellon format specification format that gerbv doesn't understand, so we have to fix that. 'altium-composite-drill/NC Drill/LimeSDR-QPCIe_1v2-SlotHoles.TXT': (('mm', 'trailing', 4), None), 'altium-composite-drill/NC Drill/LimeSDR-QPCIe_1v2-RoundHoles.TXT': (('mm', 'trailing', 4), 'altium-composite-drill/Gerber/LimeSDR-QPCIe_1v2.GTL'), @@ -57,7 +57,6 @@ REFERENCE_FILES = { def test_round_trip(reference, tmpfile): reference, (unit_spec, _) = reference tmp = tmpfile('Output excellon', '.drl') - print('unit spec', unit_spec) f = ExcellonFile.open(reference) f.save(tmp) @@ -79,7 +78,6 @@ def test_first_level_idempotence_svg(reference, tmpfile): tmp = tmpfile('Output excellon', '.drl') ref_svg = tmpfile('Reference SVG render', '.svg') out_svg = tmpfile('Output SVG render', '.svg') - print('unit spec', unit_spec) a = ExcellonFile.open(reference) a.save(tmp) diff --git a/gerbonara/tests/test_rs274x.py b/gerbonara/tests/test_rs274x.py index 04243a3..5111921 100644 --- a/gerbonara/tests/test_rs274x.py +++ b/gerbonara/tests/test_rs274x.py @@ -150,7 +150,6 @@ REFERENCE_FILES = [ l.strip() for l in ''' pcb-rnd/power-art.gko pcb-rnd/power-art.ast pcb-rnd/power-art.gtl - pcb-rnd/power-art.lht pcb-rnd/power-art.gto pcb-rnd/power-art.gtp pcb-rnd/power-art.asb @@ -206,7 +205,6 @@ REFERENCE_FILES = [ l.strip() for l in ''' siemens-2/Gerber/SolderPasteBottom.gdo siemens-2/Gerber/SolderPasteTop.gdo siemens-2/Gerber/EtchLayerBottom.gdo - siemens-2/Gerber/GerberPlot.gpf siemens-2/Gerber/BoardOutlline.gdo upverter/design_export.gko upverter/design_export.gtl |