From f4b2e74923cc95c683cd7f5c4732d92e4aafd3ba Mon Sep 17 00:00:00 2001 From: jaseg Date: Thu, 30 Dec 2021 16:40:14 +0100 Subject: Fix rotation bugs, all tests run through --- gerbonara/gerber/tests/conftest.py | 2 - gerbonara/gerber/tests/image_support.py | 50 ++++++-- gerbonara/gerber/tests/test_rs274x.py | 211 ++++++++++++++++++++++++-------- 3 files changed, 206 insertions(+), 57 deletions(-) (limited to 'gerbonara/gerber/tests') diff --git a/gerbonara/gerber/tests/conftest.py b/gerbonara/gerber/tests/conftest.py index c8fd475..c6a1221 100644 --- a/gerbonara/gerber/tests/conftest.py +++ b/gerbonara/gerber/tests/conftest.py @@ -8,8 +8,6 @@ def pytest_assertrepr_compare(op, left, right): diff = left if isinstance(left, ImageDifference) else right return [ f'Image difference assertion failed.', - f' Reference: {diff.ref_path}', - f' Actual: {diff.act_path}', f' Calculated difference: {diff}', ] # store report in node object so tmp_gbr can determine if the test failed. diff --git a/gerbonara/gerber/tests/image_support.py b/gerbonara/gerber/tests/image_support.py index 8ce0d72..49217c2 100644 --- a/gerbonara/gerber/tests/image_support.py +++ b/gerbonara/gerber/tests/image_support.py @@ -3,6 +3,9 @@ from pathlib import Path import tempfile import os from functools import total_ordering +import shutil +import bs4 +from contextlib import contextmanager import numpy as np from PIL import Image @@ -35,9 +38,9 @@ def run_cargo_cmd(cmd, args, **kwargs): return subprocess.run([str(Path.home() / '.cargo' / 'bin' / cmd), *args], **kwargs) def svg_to_png(in_svg, out_png): - run_cargo_cmd('resvg', [in_svg, out_png], check=True, stdout=subprocess.DEVNULL) + run_cargo_cmd('resvg', ['--dpi', '200', in_svg, out_png], check=True, stdout=subprocess.DEVNULL) -def gbr_to_svg(in_gbr, out_svg, origin=(0, 0), size=(10, 10)): +def gbr_to_svg(in_gbr, out_svg, origin=(0, 0), size=(6, 6)): x, y = origin w, h = size cmd = ['gerbv', '-x', 'svg', @@ -47,18 +50,51 @@ def gbr_to_svg(in_gbr, out_svg, origin=(0, 0), size=(10, 10)): '-o', str(out_svg), str(in_gbr)] subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) -def gerber_difference(reference, actual, diff_out=None): +@contextmanager +def svg_soup(filename): + with open(filename, 'r') as f: + soup = bs4.BeautifulSoup(f.read(), 'xml') + + yield soup + + with open(filename, 'w') as f: + f.write(str(soup)) + +def cleanup_clips(soup): + for group in soup.find_all('g'): + # gerbv uses Cairo's SVG canvas. Cairo's SVG canvas is kind of broken. It has no support for unit + # handling at all, which means the output files just end up being in pixels at 72 dpi. Further, it + # seems gerbv's aperture macro rendering interacts poorly with Cairo's SVG export. gerbv renders + # aperture macros into a new surface, which for some reason gets clipped by Cairo to the given + # canvas size. This is just wrong, so we just nuke the clip path from these SVG groups here. + # + # Apart from being graphically broken, this additionally causes very bad rendering performance. + del group['clip-path'] # remove broken clip + +def gerber_difference(reference, actual, diff_out=None, svg_transform=None, size=(10,10)): with tempfile.NamedTemporaryFile(suffix='.svg') as act_svg,\ tempfile.NamedTemporaryFile(suffix='.svg') as ref_svg: - gbr_to_svg(reference, ref_svg.name) - gbr_to_svg(actual, act_svg.name) + gbr_to_svg(reference, ref_svg.name, size=size) + gbr_to_svg(actual, act_svg.name, size=size) + + with svg_soup(ref_svg.name) as soup: + if svg_transform is not None: + soup.find('g', attrs={'id': 'surface1'})['transform'] = svg_transform + cleanup_clips(soup) + + with svg_soup(act_svg.name) as soup: + cleanup_clips(soup) + + # FIXME DEBUG + shutil.copyfile(act_svg.name, '/tmp/test-act.svg') + shutil.copyfile(ref_svg.name, '/tmp/test-ref.svg') return svg_difference(ref_svg.name, act_svg.name, diff_out=diff_out) def svg_difference(reference, actual, diff_out=None): - with tempfile.NamedTemporaryFile(suffix='.png') as ref_png,\ - tempfile.NamedTemporaryFile(suffix='.png') as act_png: + with tempfile.NamedTemporaryFile(suffix='-ref.png') as ref_png,\ + tempfile.NamedTemporaryFile(suffix='-act.png') as act_png: svg_to_png(reference, ref_png.name) svg_to_png(actual, act_png.name) diff --git a/gerbonara/gerber/tests/test_rs274x.py b/gerbonara/gerber/tests/test_rs274x.py index 28ee891..d91609f 100644 --- a/gerbonara/gerber/tests/test_rs274x.py +++ b/gerbonara/gerber/tests/test_rs274x.py @@ -4,22 +4,28 @@ # Author: Hamilton Kibbe import os import re -import pytest +import math import functools import tempfile import shutil from argparse import Namespace +from itertools import chain from pathlib import Path +import pytest + from ..rs274x import GerberFile +from ..cam import FileSettings from .image_support import gerber_difference +deg_to_rad = lambda a: a/180 * math.pi + fail_dir = Path('gerbonara_test_failures') @pytest.fixture(scope='session', autouse=True) def clear_failure_dir(request): - for f in fail_dir.glob('*.gbr'): + for f in chain(fail_dir.glob('*.gbr'), fail_dir.glob('*.png')): f.unlink() reference_path = lambda reference: Path(__file__).parent / 'resources' / reference @@ -42,61 +48,170 @@ def temp_files(request): shutil.copy(tmp_out_gbr.name, perm_path_gbr) shutil.copy(tmp_out_png.name, perm_path_png) print(f'Failing output saved to {perm_path_gbr}') - print(f'Difference image saved to {perm_path_png}') print(f'Reference file is {reference_path(request.node.funcargs["reference"])}') + print(f'Difference image saved to {perm_path_png}') + print(f'gerbv command line:') + print(f'gerbv {perm_path_gbr} {reference_path(request.node.funcargs["reference"])}') + +to_gerbv_svg_units = lambda val, unit='mm': val*72 if unit == 'inch' else val/25.4*72 + +REFERENCE_FILES = [ l.strip() for l in ''' + board_outline.GKO + example_outline_with_arcs.gbr + example_two_square_boxes.gbr + example_coincident_hole.gbr + example_cutin.gbr + example_cutin_multiple.gbr + example_flash_circle.gbr + example_flash_obround.gbr + example_flash_polygon.gbr + example_flash_rectangle.gbr + example_fully_coincident.gbr + example_guess_by_content.g0 + example_holes_dont_clear.gbr + example_level_holes.gbr + example_not_overlapping_contour.gbr + example_not_overlapping_touching.gbr + example_overlapping_contour.gbr + example_overlapping_touching.gbr + example_simple_contour.gbr + example_single_contour_1.gbr + example_single_contour_2.gbr + example_single_contour_3.gbr + example_am_exposure_modifier.gbr + bottom_copper.GBL + bottom_mask.GBS + bottom_silk.GBO + eagle_files/copper_bottom_l4.gbr + eagle_files/copper_inner_l2.gbr + eagle_files/copper_inner_l3.gbr + eagle_files/copper_top_l1.gbr + eagle_files/profile.gbr + eagle_files/silkscreen_bottom.gbr + eagle_files/silkscreen_top.gbr + eagle_files/soldermask_bottom.gbr + eagle_files/soldermask_top.gbr + eagle_files/solderpaste_bottom.gbr + eagle_files/solderpaste_top.gbr + multiline_read.ger + test_fine_lines_x.gbr + test_fine_lines_y.gbr + top_copper.GTL + top_mask.GTS + top_silk.GTO +'''.splitlines() if l ] + +MIN_REFERENCE_FILES = [ + 'example_two_square_boxes.gbr', + 'example_outline_with_arcs.gbr', + 'example_flash_circle.gbr', + 'example_flash_polygon.gbr', + 'example_flash_rectangle.gbr', + 'example_simple_contour.gbr', + 'example_am_exposure_modifier.gbr', + 'bottom_copper.GBL', + 'bottom_silk.GBO', + 'eagle_files/copper_bottom_l4.gbr' + ] @pytest.mark.filterwarnings('ignore:Deprecated.*statement found.*:DeprecationWarning') @pytest.mark.filterwarnings('ignore::SyntaxWarning') -@pytest.mark.parametrize('reference', [ l.strip() for l in ''' -board_outline.GKO -example_outline_with_arcs.gbr -example_two_square_boxes.gbr -example_coincident_hole.gbr -example_cutin.gbr -example_cutin_multiple.gbr -example_flash_circle.gbr -example_flash_obround.gbr -example_flash_polygon.gbr -example_flash_rectangle.gbr -example_fully_coincident.gbr -example_guess_by_content.g0 -example_holes_dont_clear.gbr -example_level_holes.gbr -example_not_overlapping_contour.gbr -example_not_overlapping_touching.gbr -example_overlapping_contour.gbr -example_overlapping_touching.gbr -example_simple_contour.gbr -example_single_contour_1.gbr -example_single_contour_2.gbr -example_single_contour_3.gbr -example_am_exposure_modifier.gbr -bottom_copper.GBL -bottom_mask.GBS -bottom_silk.GBO -eagle_files/copper_bottom_l4.gbr -eagle_files/copper_inner_l2.gbr -eagle_files/copper_inner_l3.gbr -eagle_files/copper_top_l1.gbr -eagle_files/profile.gbr -eagle_files/silkscreen_bottom.gbr -eagle_files/silkscreen_top.gbr -eagle_files/soldermask_bottom.gbr -eagle_files/soldermask_top.gbr -eagle_files/solderpaste_bottom.gbr -eagle_files/solderpaste_top.gbr -multiline_read.ger -test_fine_lines_x.gbr -test_fine_lines_y.gbr -top_copper.GTL -top_mask.GTS -top_silk.GTO -'''.splitlines() if l ]) +@pytest.mark.parametrize('reference', REFERENCE_FILES) def test_round_trip(temp_files, reference): tmp_gbr, tmp_png = temp_files ref = reference_path(reference) + GerberFile.open(ref).save(tmp_gbr) + mean, max = gerber_difference(ref, tmp_gbr, diff_out=tmp_png) assert mean < 1e-6 assert max < 0.1 +TEST_ANGLES = [90, 180, 270, 30, 1.5, 10, 360, 1024, -30, -90] +TEST_OFFSETS = [(0, 0), (100, 0), (0, 100), (2, 0), (10, 100)] + +@pytest.mark.filterwarnings('ignore:Deprecated.*statement found.*:DeprecationWarning') +@pytest.mark.filterwarnings('ignore::SyntaxWarning') +@pytest.mark.parametrize('reference', MIN_REFERENCE_FILES) +@pytest.mark.parametrize('angle', TEST_ANGLES) +def test_rotation(temp_files, reference, angle): + if 'flash_rectangle' in reference and angle == 1024: + # gerbv's rendering of this is broken, the hole is missing. + return + + tmp_gbr, tmp_png = temp_files + ref = reference_path(reference) + + f = GerberFile.open(ref) + f.rotate(deg_to_rad(angle)) + f.save(tmp_gbr) + + cx, cy = 0, to_gerbv_svg_units(10, unit='inch') + mean, max = gerber_difference(ref, tmp_gbr, diff_out=tmp_png, svg_transform=f'rotate({angle} {cx} {cy})') + assert mean < 1e-3 # relax mean criterion compared to above. + assert max < 0.9 + +@pytest.mark.filterwarnings('ignore:Deprecated.*statement found.*:DeprecationWarning') +@pytest.mark.filterwarnings('ignore::SyntaxWarning') +@pytest.mark.parametrize('reference', MIN_REFERENCE_FILES) +@pytest.mark.parametrize('angle', TEST_ANGLES) +@pytest.mark.parametrize('center', [(0, 0), (-10, -10), (10, 10), (10, 0), (0, -10), (-10, 10), (10, 20)]) +def test_rotation_center(temp_files, reference, angle, center): + tmp_gbr, tmp_png = temp_files + ref = reference_path(reference) + + f = GerberFile.open(ref) + f.rotate(deg_to_rad(angle), center=center) + f.save(tmp_gbr) + + # calculate circle center in SVG coordinates + size = (10, 10) # inches + cx, cy = to_gerbv_svg_units(center[0]), to_gerbv_svg_units(10, 'inch')-to_gerbv_svg_units(center[1], 'mm') + mean, max = gerber_difference(ref, tmp_gbr, diff_out=tmp_png, + svg_transform=f'rotate({angle} {cx} {cy})', + size=size) + assert mean < 1e-3 + assert max < 0.9 + +@pytest.mark.filterwarnings('ignore:Deprecated.*statement found.*:DeprecationWarning') +@pytest.mark.filterwarnings('ignore::SyntaxWarning') +@pytest.mark.parametrize('reference', MIN_REFERENCE_FILES) +@pytest.mark.parametrize('offset', TEST_OFFSETS) +def test_offset(temp_files, reference, offset): + tmp_gbr, tmp_png = temp_files + ref = reference_path(reference) + + f = GerberFile.open(ref) + f.offset(*offset) + f.save(tmp_gbr, settings=FileSettings(unit=f.unit, number_format=(4,7))) + + # flip y offset since svg's y axis is flipped compared to that of gerber + dx, dy = to_gerbv_svg_units(offset[0]), -to_gerbv_svg_units(offset[1]) + mean, max = gerber_difference(ref, tmp_gbr, diff_out=tmp_png, svg_transform=f'translate({dx} {dy})') + assert mean < 1e-4 + assert max < 0.9 + +@pytest.mark.filterwarnings('ignore:Deprecated.*statement found.*:DeprecationWarning') +@pytest.mark.filterwarnings('ignore::SyntaxWarning') +@pytest.mark.parametrize('reference', MIN_REFERENCE_FILES) +@pytest.mark.parametrize('angle', TEST_ANGLES) +@pytest.mark.parametrize('center', [(0, 0), (10, 0), (0, -10), (10, 20)]) +@pytest.mark.parametrize('offset', [(0, 0), (100, 0), (0, 100), (100, 100), (100, 10)]) +def test_combined(temp_files, reference, angle, center, offset): + tmp_gbr, tmp_png = temp_files + ref = reference_path(reference) + + f = GerberFile.open(ref) + f.rotate(deg_to_rad(angle), center=center) + f.offset(*offset) + f.save(tmp_gbr) + + size = (10, 10) # inches + cx, cy = to_gerbv_svg_units(center[0]), to_gerbv_svg_units(10, 'inch')-to_gerbv_svg_units(center[1], 'mm') + dx, dy = to_gerbv_svg_units(offset[0]), -to_gerbv_svg_units(offset[1]) + mean, max = gerber_difference(ref, tmp_gbr, diff_out=tmp_png, + svg_transform=f'rotate({anlge} {cx} {cy}) translate({dx} {dy})', + size=size) + assert mean < 1e-4 + assert max < 0.9 + -- cgit