From a93d118773818fbd47af4965d7b37e1b10bdf9b6 Mon Sep 17 00:00:00 2001 From: jaseg Date: Sat, 22 Apr 2023 17:16:20 +0200 Subject: kicad unit tests WIP --- gerbonara/tests/image_support.py | 29 +++++++- gerbonara/tests/test_kicad_footprints.py | 122 ++++++++++++++++++++++++++++++- 2 files changed, 145 insertions(+), 6 deletions(-) (limited to 'gerbonara/tests') diff --git a/gerbonara/tests/image_support.py b/gerbonara/tests/image_support.py index b5e06cf..9902863 100644 --- a/gerbonara/tests/image_support.py +++ b/gerbonara/tests/image_support.py @@ -143,6 +143,29 @@ def gerbv_export(in_gbr, out_svg, export_format='svg', origin=(0, 0), size=(6, 6 print(f'Re-using cache for {Path(in_gbr).name}') shutil.copy(cachefile, out_svg) +def kicad_fp_export(mod_file, out_svg): + mod_file = Path(mod_file) + if mod_file.suffix.lower() != '.kicad_mod': + raise ValueError("KiCad footprint file must have .kicad_mod extension for kicad-cli to do it's thing") + + params = f'(noparams)'.encode() + digest = hashlib.blake2b(mod_file.read_bytes() + params).hexdigest() + cachefile = cachedir / f'{digest}.svg' + + if not cachefile.is_file(): + print(f'Building cache for {mod_file.name}') + + with tempfile.TemporaryDirectory() as tmpdir: + pretty_dir = mod_file.parent + fp_name = mod_file.name[:-len('.kicad_mod')] + cmd = ['kicad-cli', 'fp', 'export', 'svg', '--output', tmpdir, '--footprint', fp_name, pretty_dir] + subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + out_file = Path(tmpdir) / f'{fp_name}.svg' + shutil.copy(out_file, cachefile) + else: + print(f'Re-using cache for {mod_file.name}') + shutil.copy(cachefile, out_svg) + @contextmanager def svg_soup(filename): with open(filename, 'r') as f: @@ -258,12 +281,12 @@ def gerber_difference_merge(ref1, ref2, actual, diff_out=None, composite_out=Non return svg_difference(ref1_svg.name, act_svg.name, diff_out=diff_out) -def svg_difference(reference, actual, diff_out=None, background=None): +def svg_difference(reference, actual, diff_out=None, background=None, dpi=100): with tempfile.NamedTemporaryFile(suffix='-ref.png') as ref_png,\ tempfile.NamedTemporaryFile(suffix='-act.png') as act_png: - svg_to_png(reference, ref_png.name, bg=background) - svg_to_png(actual, act_png.name, bg=background) + svg_to_png(reference, ref_png.name, bg=background, dpi=dpi) + svg_to_png(actual, act_png.name, bg=background, dpi=dpi) return image_difference(ref_png.name, act_png.name, diff_out=diff_out) diff --git a/gerbonara/tests/test_kicad_footprints.py b/gerbonara/tests/test_kicad_footprints.py index a238e1c..d5e7085 100644 --- a/gerbonara/tests/test_kicad_footprints.py +++ b/gerbonara/tests/test_kicad_footprints.py @@ -1,17 +1,27 @@ from itertools import zip_longest +import subprocess import re +from .utils import tmpfile, print_on_error +from .image_support import kicad_fp_export, svg_difference, svg_soup, svg_to_png, run_cargo_cmd + +from .. import graphic_objects as go +from ..utils import MM +from ..layers import LayerStack from ..cad.kicad.sexp import build_sexp from ..cad.kicad.sexp_mapper import sexp -from ..cad.kicad.footprints import Footprint +from ..cad.kicad.footprints import Footprint, FootprintInstance, LAYER_MAP_G2K +from ..cad.kicad.layer_colors import KICAD_LAYER_COLORS, KICAD_DRILL_COLORS + def test_parse(kicad_mod_file): - Footprint.open(kicad_mod_file) + Footprint.open_mod(kicad_mod_file) + def test_round_trip(kicad_mod_file): print('========== Stage 1 load ==========') - orig_fp = Footprint.open(kicad_mod_file) + orig_fp = Footprint.open_mod(kicad_mod_file) print('========== Stage 1 save ==========') stage1_sexp = build_sexp(orig_fp.sexp()) with open('/tmp/foo.sexp', 'w') as f: @@ -55,3 +65,109 @@ def test_round_trip(kicad_mod_file): assert original == stage1 +def _parse_path_d(path): + path_d = path.get('d') + if not path_d: + return + + for match in re.finditer(r'[ML] ?([0-9.]+) *,? *([0-9.]+)', path_d): + x, y = match.groups() + x, y = float(x), float(y) + yield x, y + +def test_render(kicad_mod_file, tmpfile, print_on_error): + # Hide text and remove text from KiCad's renders. Our text rendering is alright, but KiCad has some weird issue + # where it seems to mis-calculate the bounding box of stroke font text, leading to a wonky viewport not matching the + # actual content, and text that is slightly off from where it should be. The difference is only a few hundred + # micrometers, but it's enough to really throw off our error calculation, so we just ignore text. + fp = FootprintInstance(0, 0, sexp=Footprint.open_mod(kicad_mod_file), hide_text=True) + stack = LayerStack(courtyard=True, fabrication=True) + fp.render(stack) + color_map = {gn_id: KICAD_LAYER_COLORS[kicad_id] for gn_id, kicad_id in LAYER_MAP_G2K.items()} + color_map[('drill', 'pth')] = (255, 255, 255, 1) + color_map[('drill', 'npth')] = (255, 255, 255, 1) + color_map = {key: (f'#{r:02x}{g:02x}{b:02x}', str(a)) for key, (r, g, b, a) in color_map.items()} + + margin = 10 # mm + + layer = stack[('top', 'courtyard')] + points = [] + for obj in layer.objects: + if isinstance(obj, (go.Line, go.Arc)): + points.append((obj.x1, obj.y1)) + points.append((obj.x2, obj.y2)) + + if not points: + print('Footprint has no paths on courtyard layer') + return + + min_x = min(x for x, y in points) + min_y = min(y for x, y in points) + max_x = max(x for x, y in points) + max_y = max(y for x, y in points) + w, h = max_x-min_x, max_y-min_y + bounds = ((min_x, min_y), (max_x, max_y)) + print_on_error('Gerbonara bounds:', bounds, f'w={w:.6f}', f'h={h:.6f}') + + out_svg = tmpfile('Output', '.svg') + out_svg.write_text(str(stack.to_svg(color_map=color_map, force_bounds=bounds, margin=margin))) + + print_on_error('Input footprint:', kicad_mod_file) + ref_svg = tmpfile('Reference render', '.svg') + kicad_fp_export(kicad_mod_file, ref_svg) + + # KiCad's bounding box calculation for SVG output looks broken, and the resulting files have viewports that are too + # large. We align our output and KiCad's output using the footprint's courtyard layer. + points = [] + with svg_soup(ref_svg) as soup: + for group in soup.find_all('g'): + style = group.get('style', '').lower().replace(' ', '') + if 'fill:#ff26e2' not in style or 'stroke:#ff26e2' not in style: + continue + + # This group contains courtyard layer items. + for path in group.find_all('path'): + points += _parse_path_d(path) + + if not points: + print('Footprint has no paths on courtyard layer') + return + + min_x = min(x for x, y in points) + min_y = min(y for x, y in points) + max_x = max(x for x, y in points) + max_y = max(y for x, y in points) + print_on_error('KiCad bounds:', ((min_x, min_y), (max_x, max_y)), f'w={max_x-min_x:.6f}', f'h={max_y-min_y:.6f}') + min_x -= margin + min_y -= margin + max_x += margin + max_y += margin + w, h = max_x-min_x, max_y-min_y + + root = soup.find('svg') + root_w = root['width'] = f'{w:.6f}mm' + root_h = root['height'] = f'{h:.6f}mm' + root['viewBox'] = f'{min_x:.6f} {min_y:.6f} {w:.6f} {h:.6f}' + + for group in soup.find_all('g', attrs={'class': 'stroked-text'}): + group.decompose() + + # Currently, there is a bug in resvg leading to mis-rendering. On the file below from the KiCad standard lib, resvg + # renders all round pads in a wrong color (?). Interestingly, passing the file through usvg before rendering fixes + # this. + # Sample footprint: Connector_PinSocket_2.00mm.pretty/PinSocket_2x11_P2.00mm_Vertical.kicad_mod + run_cargo_cmd('usvg', [str(ref_svg), str(ref_svg)]) + + # fix up usvg width/height + with svg_soup(ref_svg) as soup: + root = soup.find('svg') + root['width'] = root_w + root['height'] = root_h + + svg_to_png(ref_svg, tmpfile('Reference render', '.png'), bg=None, dpi=600) + svg_to_png(out_svg, tmpfile('Output render', '.png'), bg=None, dpi=600) + mean, _max, hist = svg_difference(ref_svg, out_svg, dpi=600, diff_out=tmpfile('Difference', '.png')) + assert mean < 1e-3 + assert hist[9] < 100 + assert hist[3:].sum() < 1e-3*hist.size + -- cgit