WIP move project to uv
|
|
@ -1,89 +0,0 @@
|
|||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tqdm
|
||||
import multiprocessing.pool
|
||||
import subprocess
|
||||
from itertools import chain
|
||||
|
||||
import pytest
|
||||
|
||||
from .image_support import ImageDifference, run_cargo_cmd, bulk_populate_kicad_fp_export_cache, KICAD_CONTAINER
|
||||
|
||||
def pytest_assertrepr_compare(op, left, right):
|
||||
if isinstance(left, ImageDifference) or isinstance(right, ImageDifference):
|
||||
diff = left if isinstance(left, ImageDifference) else right
|
||||
return [
|
||||
f'Image difference assertion failed.',
|
||||
f' Calculated difference: {diff}',
|
||||
f' Histogram: {diff.histogram}', ]
|
||||
|
||||
|
||||
# store report in node object so tmp_gbr can determine if the test failed.
|
||||
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
|
||||
def pytest_runtest_makereport(item, call):
|
||||
outcome = yield
|
||||
rep = outcome.get_result()
|
||||
setattr(item, f'rep_{rep.when}', rep)
|
||||
|
||||
|
||||
fail_dir = Path('gerbonara_test_failures')
|
||||
def pytest_sessionstart(session):
|
||||
if 'PYTEST_XDIST_WORKER' in os.environ: # only run this on the controller
|
||||
return
|
||||
|
||||
for f in chain(fail_dir.glob('*.gbr'), fail_dir.glob('*.png')):
|
||||
f.unlink()
|
||||
|
||||
try:
|
||||
run_cargo_cmd('resvg', '--help', stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
except FileNotFoundError:
|
||||
pytest.exit('resvg binary not found, aborting test.', 2)
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
os.nice(20)
|
||||
# Resvg can sometimes consume a lot of memory. Make sure we don't kill the user's session.
|
||||
if (oom_adj := Path('/proc/self/oom_adj')).is_file():
|
||||
oom_adj.write_text('15\n')
|
||||
|
||||
if 'PYTEST_XDIST_WORKER' in os.environ: # only run this on the controller
|
||||
return
|
||||
|
||||
if (lib_dir := os.environ.get('KICAD_FOOTPRINTS')):
|
||||
lib_dir = Path(lib_dir).expanduser()
|
||||
if not lib_dir.is_dir():
|
||||
raise ValueError(f'Path "{lib_dir}" given by KICAD_FOOTPRINTS environment variable does not exist or is not a directory.')
|
||||
|
||||
print('Updating podman image')
|
||||
subprocess.run(['podman', 'pull', KICAD_CONTAINER], check=True)
|
||||
|
||||
print('Checking and bulk re-building KiCad footprint library cache')
|
||||
with multiprocessing.pool.ThreadPool() as pool: # use thread pool here since we're only monitoring podman processes
|
||||
lib_dirs = list(lib_dir.glob('*.pretty'))
|
||||
res = list(tqdm.tqdm(pool.imap(lambda path: bulk_populate_kicad_fp_export_cache(path), lib_dirs), total=len(lib_dirs)))
|
||||
|
||||
|
||||
def pytest_addoption(parser):
|
||||
parser.addoption('--kicad-symbol-library', nargs='*', help='Run symbol library tests on given symbol libraries. May be given multiple times.')
|
||||
parser.addoption('--kicad-footprint-files', nargs='*', help='Run footprint library tests on given footprint files. May be given multiple times.')
|
||||
|
||||
|
||||
def pytest_generate_tests(metafunc):
|
||||
if 'kicad_library_file' in metafunc.fixturenames:
|
||||
if not (library_files := metafunc.config.getoption('symbol_library', None)):
|
||||
if (lib_dir := os.environ.get('KICAD_SYMBOLS')):
|
||||
lib_dir = Path(lib_dir).expanduser()
|
||||
library_files = list(lib_dir.glob('*.kicad_sym'))
|
||||
else:
|
||||
raise ValueError('Either --kicad-symbol-library command line parameter or KICAD_SYMBOLS environment variable must be given to run kicad symbol tests.')
|
||||
metafunc.parametrize('kicad_library_file', library_files, ids=list(map(str, library_files)))
|
||||
|
||||
if 'kicad_mod_file' in metafunc.fixturenames:
|
||||
if not (mod_files := metafunc.config.getoption('footprint_files', None)):
|
||||
if (lib_dir := os.environ.get('KICAD_FOOTPRINTS')):
|
||||
lib_dir = Path(lib_dir).expanduser()
|
||||
mod_files = list(lib_dir.glob('*.pretty/*.kicad_mod'))
|
||||
else:
|
||||
raise ValueError('Either --kicad-footprint-files command line parameter or KICAD_FOOTPRINTS environment variable must be given to run kicad footprint tests.')
|
||||
metafunc.parametrize('kicad_mod_file', mod_files, ids=list(map(str, mod_files)))
|
||||
|
|
@ -1,360 +0,0 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright 2022 Jan Sebastian Götte <gerbonara@jaseg.de>
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# Based on https://github.com/tracespace/tracespace
|
||||
#
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import textwrap
|
||||
import os
|
||||
import sys
|
||||
import stat
|
||||
import random
|
||||
import statistics
|
||||
from functools import total_ordering
|
||||
import shutil
|
||||
import bs4
|
||||
from contextlib import contextmanager
|
||||
import hashlib
|
||||
|
||||
import tqdm
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
cachedir = Path(__file__).parent / 'image_cache'
|
||||
cachedir.mkdir(exist_ok=True)
|
||||
|
||||
KICAD_CONTAINER = os.environ.get('KICAD_CONTAINER', 'registry.hub.docker.com/kicad/kicad:nightly')
|
||||
|
||||
@total_ordering
|
||||
class ImageDifference:
|
||||
def __init__(self, value, histogram):
|
||||
self.value = value
|
||||
self.histogram = histogram
|
||||
|
||||
def __float__(self):
|
||||
return float(self.value)
|
||||
|
||||
def __eq__(self, other):
|
||||
return float(self) == float(other)
|
||||
|
||||
def __lt__(self, other):
|
||||
return float(self) < float(other)
|
||||
|
||||
def __str__(self):
|
||||
return str(float(self))
|
||||
|
||||
@total_ordering
|
||||
class Histogram:
|
||||
def __init__(self, value, size):
|
||||
self.value, self.size = value, size
|
||||
|
||||
def __eq__(self, other):
|
||||
other = np.array(other)
|
||||
other[other == None] = self.value[other == None]
|
||||
return (self.value == other).all()
|
||||
|
||||
def __lt__(self, other):
|
||||
other = np.array(other)
|
||||
other[other == None] = self.value[other == None]
|
||||
return (self.value <= other).all()
|
||||
|
||||
def __getitem__(self, index):
|
||||
return self.value[index]
|
||||
|
||||
def __str__(self):
|
||||
return f'{list(self.value)} size={self.size}'
|
||||
|
||||
|
||||
def run_cargo_cmd(cmd, args, **kwargs):
|
||||
if cmd.upper() in os.environ:
|
||||
return subprocess.run([os.environ[cmd.upper()], *args], **kwargs)
|
||||
|
||||
try:
|
||||
return subprocess.run([cmd, *args], **kwargs)
|
||||
|
||||
except FileNotFoundError:
|
||||
return subprocess.run([str(Path.home() / '.cargo' / 'bin' / cmd), *args], **kwargs)
|
||||
|
||||
def svg_to_png(in_svg, out_png, dpi=100, bg=None):
|
||||
params = f'{dpi}{bg}'.encode()
|
||||
digest = hashlib.blake2b(Path(in_svg).read_bytes() + params).hexdigest()
|
||||
cachefile = cachedir / f'{digest}.png'
|
||||
|
||||
if not cachefile.is_file():
|
||||
bg = 'black' if bg is None else bg
|
||||
run_cargo_cmd('resvg', ['--background', bg, '--dpi', str(dpi), in_svg, cachefile], check=True, stdout=subprocess.DEVNULL)
|
||||
|
||||
shutil.copy(cachefile, out_png)
|
||||
|
||||
to_gerbv_svg_units = lambda val, unit='mm': val*72 if unit == 'inch' else val/25.4*72
|
||||
|
||||
def gerbv_export(in_gbr, out_svg, export_format='svg', origin=(0, 0), size=(6, 6), fg='#ffffff', bg='#000000', override_unit_spec=None):
|
||||
params = f'{origin}{size}{fg}{bg}'.encode()
|
||||
digest = hashlib.blake2b(Path(in_gbr).read_bytes() + params).hexdigest()
|
||||
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.
|
||||
with tempfile.NamedTemporaryFile('w') as f:
|
||||
if override_unit_spec:
|
||||
units, zeros, digits = override_unit_spec
|
||||
print(f'{Path(in_gbr).name}: overriding excellon unit spec to {units=} {zeros=} {digits=}')
|
||||
units = 0 if units == 'inch' else 1
|
||||
zeros = {None: 0, 'leading': 1, 'trailing': 2}[zeros]
|
||||
unit_spec = textwrap.dedent(f'''(cons 'attribs (list
|
||||
(list 'autodetect 'Boolean 0)
|
||||
(list 'zero_suppression 'Enum {zeros})
|
||||
(list 'units 'Enum {units})
|
||||
(list 'digits 'Integer {digits})
|
||||
))''')
|
||||
else:
|
||||
unit_spec = ''
|
||||
|
||||
r, g, b = int(fg[1:3], 16), int(fg[3:5], 16), int(fg[5:], 16)
|
||||
color = f"(cons 'color #({r*257} {g*257} {b*257}))"
|
||||
f.write(f'''(gerbv-file-version! "2.0A")(define-layer! 0 (cons 'filename "{in_gbr}"){unit_spec}{color})''')
|
||||
f.flush()
|
||||
if override_unit_spec:
|
||||
shutil.copy(f.name, '/tmp/foo.gbv')
|
||||
|
||||
x, y = origin
|
||||
w, h = size
|
||||
cmd = ['gerbv', '-x', export_format,
|
||||
'--border=0',
|
||||
f'--origin={x:.6f}x{y:.6f}', f'--window_inch={w:.6f}x{h:.6f}',
|
||||
f'--background={bg}',
|
||||
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)
|
||||
|
||||
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:
|
||||
os.chmod(tmpdir, 0o1777)
|
||||
pretty_dir = mod_file.parent
|
||||
fp_name = mod_file.name[:-len('.kicad_mod')]
|
||||
cmd = ['podman', 'run',
|
||||
'--rm', # Clean up volumes after exit
|
||||
'--userns=keep-id', # To allow container to read from bind mount
|
||||
'--mount', f'type=bind,src={pretty_dir},dst=/{pretty_dir.name}',
|
||||
'--mount', f'type=bind,src={tmpdir},dst=/out',
|
||||
KICAD_CONTAINER,
|
||||
'kicad-cli', 'fp', 'export', 'svg', '--output', '/out', '--footprint', fp_name, f'/{pretty_dir.name}']
|
||||
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)
|
||||
|
||||
|
||||
def bulk_populate_kicad_fp_export_cache(pretty_dir):
|
||||
def cachefile(mod_file):
|
||||
params = f'(noparams)'.encode()
|
||||
digest = hashlib.blake2b(mod_file.read_bytes() + params).hexdigest()
|
||||
return cachedir / f'{digest}.svg'
|
||||
|
||||
mod_files = list(pretty_dir.glob('*.kicad_mod'))
|
||||
hit_rate = statistics.mean([int(cachefile(fn).is_file())
|
||||
for fn in random.sample(mod_files, min(len(mod_files), 50))])
|
||||
|
||||
if hit_rate < 0.9:
|
||||
#tqdm.tqdm.write(f'Modfile cache is out of date (hit rate {hit_rate*100:.0f}%), re-building entire cache in bulk')
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
os.chmod(tmpdir, 0o1777)
|
||||
cmd = ['podman', 'run',
|
||||
'--rm', # Clean up volumes after exit
|
||||
'--userns=keep-id', # To allow container to read from bind mount
|
||||
'--mount', f'type=bind,src={pretty_dir},dst=/{pretty_dir.name}',
|
||||
'--mount', f'type=bind,src={tmpdir},dst=/out',
|
||||
KICAD_CONTAINER,
|
||||
'kicad-cli', 'fp', 'export', 'svg', '--output', '/out', f'/{pretty_dir.name}']
|
||||
|
||||
try:
|
||||
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL)
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
print('Error running command with command line:', ' '.join(e.cmd), file=sys.stderr)
|
||||
raise e
|
||||
|
||||
for fn in mod_files:
|
||||
out_file = Path(tmpdir) / fn.with_suffix('.svg').name
|
||||
if not out_file.is_file():
|
||||
tqdm.tqdm.write(f'Output file {out_file} is missing while bulk re-building cache for {pretty_dir}.')
|
||||
else:
|
||||
shutil.copy(out_file, cachefile(fn))
|
||||
|
||||
@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_gerbv_svg(soup):
|
||||
width = soup.svg["width"]
|
||||
height = soup.svg["height"]
|
||||
width = width[:-2] if width.endswith('pt') else width
|
||||
height = height[:-2] if height.endswith('pt') else height
|
||||
soup.svg['width'] = f'{float(width)/72*25.4:.4f}mm'
|
||||
soup.svg['height'] = f'{float(height)/72*25.4:.4f}mm'
|
||||
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']
|
||||
|
||||
def gerber_difference(reference, actual, diff_out=None, svg_transform=None, size=(10,10), ref_unit_spec=None):
|
||||
with tempfile.NamedTemporaryFile(suffix='.svg') as act_svg,\
|
||||
tempfile.NamedTemporaryFile(suffix='.svg') as ref_svg:
|
||||
|
||||
gerbv_export(reference, ref_svg.name, size=size, export_format='svg', override_unit_spec=ref_unit_spec)
|
||||
gerbv_export(actual, act_svg.name, size=size, export_format='svg')
|
||||
|
||||
with svg_soup(ref_svg.name) as soup:
|
||||
if svg_transform is not None:
|
||||
svg = soup.svg
|
||||
children = list(svg.children)
|
||||
g = soup.new_tag('g', attrs={'transform': svg_transform})
|
||||
for c in children:
|
||||
g.append(c.extract())
|
||||
svg.append(g)
|
||||
|
||||
cleanup_gerbv_svg(soup)
|
||||
|
||||
with svg_soup(act_svg.name) as soup:
|
||||
cleanup_gerbv_svg(soup)
|
||||
|
||||
return svg_difference(ref_svg.name, act_svg.name, diff_out=diff_out)
|
||||
|
||||
def gerber_difference_merge(ref1, ref2, actual, diff_out=None, composite_out=None, svg_transform1=None, svg_transform2=None, size=(10,10)):
|
||||
with tempfile.NamedTemporaryFile(suffix='.svg') as act_svg,\
|
||||
tempfile.NamedTemporaryFile(suffix='.svg') as ref1_svg,\
|
||||
tempfile.NamedTemporaryFile(suffix='.svg') as ref2_svg:
|
||||
|
||||
gerbv_export(ref1, ref1_svg.name, size=size, export_format='svg')
|
||||
gerbv_export(ref2, ref2_svg.name, size=size, export_format='svg')
|
||||
gerbv_export(actual, act_svg.name, size=size, export_format='svg')
|
||||
for var in ['ref1_svg', 'ref2_svg', 'act_svg']:
|
||||
print(f'=== {var} ===')
|
||||
print(Path(locals()[var].name).read_text().splitlines()[1])
|
||||
|
||||
with svg_soup(ref1_svg.name) as soup1:
|
||||
if svg_transform1 is not None:
|
||||
svg = soup1.svg
|
||||
children = list(svg.children)
|
||||
g = soup1.new_tag('g', attrs={'transform': svg_transform1})
|
||||
for c in children:
|
||||
g.append(c.extract())
|
||||
svg.append(g)
|
||||
cleanup_gerbv_svg(soup1)
|
||||
|
||||
with svg_soup(ref2_svg.name) as soup2:
|
||||
if svg_transform2 is not None:
|
||||
svg = soup2.svg
|
||||
children = list(svg.children)
|
||||
g = soup2.new_tag('g', attrs={'transform': svg_transform2})
|
||||
for c in children:
|
||||
g.append(c.extract())
|
||||
svg.append(g)
|
||||
cleanup_gerbv_svg(soup2)
|
||||
|
||||
defs1 = soup1.find('defs')
|
||||
if not defs1:
|
||||
defs1 = soup1.new_tag('defs')
|
||||
soup1.find('svg').insert(0, defs1)
|
||||
|
||||
defs2 = soup2.find('defs')
|
||||
if defs2:
|
||||
defs2 = defs2.extract()
|
||||
# explicitly convert .contents into list here and below because else bs4 stumbles over itself
|
||||
# iterating because we modify the tree in the loop body.
|
||||
for c in list(defs2.contents):
|
||||
if hasattr(c, 'attrs'):
|
||||
c['id'] = 'gn-merge-b-' + c.attrs.get('id', str(id(c)))
|
||||
defs1.append(c)
|
||||
|
||||
for use in soup2.find_all('use', recursive=True):
|
||||
if (href := use.get('xlink:href', '')).startswith('#'):
|
||||
use['xlink:href'] = f'#gn-merge-b-{href[1:]}'
|
||||
|
||||
svg1 = soup1.find('svg')
|
||||
for c in list(soup2.find('svg').contents):
|
||||
if hasattr(c, 'attrs'):
|
||||
c['id'] = 'gn-merge-b-' + c.attrs.get('id', str(id(c)))
|
||||
svg1.append(c)
|
||||
|
||||
if composite_out:
|
||||
shutil.copyfile(ref1_svg.name, composite_out)
|
||||
|
||||
with svg_soup(act_svg.name) as soup:
|
||||
cleanup_gerbv_svg(soup)
|
||||
|
||||
return svg_difference(ref1_svg.name, act_svg.name, diff_out=diff_out)
|
||||
|
||||
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, 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)
|
||||
|
||||
def image_difference(reference, actual, diff_out=None):
|
||||
ref = np.array(Image.open(reference)).astype(float)
|
||||
out = np.array(Image.open(actual)).astype(float)
|
||||
|
||||
ref, out = ref.mean(axis=2), out.mean(axis=2) # convert to grayscale
|
||||
# TODO blur images here before comparison to mitigate aliasing issue
|
||||
delta = np.abs(out - ref).astype(float) / 255
|
||||
if diff_out:
|
||||
Image.fromarray((delta*255).astype(np.uint8), mode='L').save(diff_out)
|
||||
|
||||
hist, _bins = np.histogram(delta, bins=10, range=(0, 1))
|
||||
return (ImageDifference(delta.mean(), hist),
|
||||
ImageDifference(delta.max(), hist),
|
||||
Histogram(hist, out.size))
|
||||
|
||||
|
||||
69
pyproject.toml
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
[project]
|
||||
name = "gerbonara"
|
||||
version = "1.5.0"
|
||||
description = "Tools to handle Gerber and Excellon files in Python"
|
||||
readme = "README.md"
|
||||
license = "Apache-2.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = ["click", "rtree", "quart"]
|
||||
|
||||
authors = [
|
||||
{ name = "jaseg" },
|
||||
{ name = "XenGi" },
|
||||
]
|
||||
|
||||
maintainers = [
|
||||
{ name = "Gerbonara maintainers", email = "gerbonara@jaseg.de" },
|
||||
]
|
||||
|
||||
keywords = ["gerber", "excellon", "pcb", "RS274x", "EDA"]
|
||||
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Environment :: Console",
|
||||
"Intended Audience :: Developers",
|
||||
"Intended Audience :: Information Technology",
|
||||
"Intended Audience :: Manufacturing",
|
||||
"Intended Audience :: Science/Research",
|
||||
"License :: OSI Approved :: Apache Software License",
|
||||
"Topic :: Artistic Software",
|
||||
"Topic :: Multimedia :: Graphics",
|
||||
"Topic :: Printing",
|
||||
"Topic :: Scientific/Engineering",
|
||||
"Topic :: Scientific/Engineering :: Electronic Design Automation (EDA)",
|
||||
"Topic :: Scientific/Engineering :: Image Processing",
|
||||
"Topic :: Utilities",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://jaseg.de/projects/gerbonara/"
|
||||
Documentation = "https://gerbolyze.gitlab.io/gerbonara/"
|
||||
Source = "https://git.jaseg.de/gerbonara.git"
|
||||
Tracker = "https://gitlab.com/gerbolyze/gerbonara/issues"
|
||||
|
||||
[project.scripts]
|
||||
gerbonara = "gerbonara.cli:cli"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest",
|
||||
"pytest-xdist",
|
||||
"numpy",
|
||||
"scipy",
|
||||
"tqdm",
|
||||
"beautifulsoup4",
|
||||
"pillow"
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["uv-build"]
|
||||
build-backend = "uv_build"
|
||||
|
||||
[tool.pytest]
|
||||
testpaths = ["tests"]
|
||||
norecursedirs = ["*"]
|
||||
kicad_symbols_tag = "9.0.6"
|
||||
kicad_footprints_tag = "9.0.6"
|
||||
# Tag to use for container for footprint svg export
|
||||
# For a list of available tags, see https://hub.docker.com/r/kicad/kicad/tags
|
||||
kicad_container_tag = "9.0.6-full"
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
[pytest]
|
||||
testpaths = gerbonara/tests
|
||||
norecursedirs=*
|
||||
66
setup.py
|
|
@ -1,66 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
from pathlib import Path
|
||||
from setuptools import setup, find_packages
|
||||
import subprocess
|
||||
|
||||
def version():
|
||||
try:
|
||||
res = subprocess.run(['git', 'describe', '--tags', '--match', 'v*'], capture_output=True, check=True, text=True)
|
||||
version, _, _rest = res.stdout.strip()[1:].partition('-')
|
||||
return version
|
||||
except:
|
||||
subprocess.run(['git', 'describe', '--tags', '--match', 'v*'])
|
||||
raise
|
||||
|
||||
setup(
|
||||
name='gerbonara',
|
||||
version=version(),
|
||||
author='jaseg, XenGi',
|
||||
author_email='gerbonara@jaseg.de',
|
||||
description='Tools to handle Gerber and Excellon files in Python',
|
||||
long_description=Path('README.md').read_text(),
|
||||
long_description_content_type='text/markdown',
|
||||
url='https://gitlab.com/gerbolyze/gerbonara',
|
||||
project_urls={
|
||||
'Documentation': 'https://gerbolyze.gitlab.io/gerbonara/',
|
||||
# 'Funding': 'https://donate.pypi.org',
|
||||
# 'Say Thanks!': 'http://saythanks.io/to/example',
|
||||
'Source': 'https://gitlab.com/gerbolyze/gerbonara',
|
||||
'Tracker': 'https://gitlab.com/gerbolyze/gerbonara/issues',
|
||||
},
|
||||
packages=find_packages(exclude=['tests']),
|
||||
include_package_data=True,
|
||||
install_requires=['click', 'rtree', 'quart'],
|
||||
entry_points={
|
||||
'console_scripts': [
|
||||
'gerbonara = gerbonara.cli:cli',
|
||||
],
|
||||
},
|
||||
classifiers=[
|
||||
'Development Status :: 4 - Beta',
|
||||
#'Development Status :: 5 - Production/Stable',
|
||||
'Environment :: Console',
|
||||
'Intended Audience :: Developers',
|
||||
'Intended Audience :: Information Technology',
|
||||
'Intended Audience :: Manufacturing',
|
||||
'Intended Audience :: Science/Research',
|
||||
'License :: OSI Approved :: Apache Software License',
|
||||
'Natural Language :: English',
|
||||
'Operating System :: POSIX :: Linux',
|
||||
'Programming Language :: Python :: 3',
|
||||
'Programming Language :: Python :: 3 :: Only',
|
||||
'Programming Language :: Python :: 3.10',
|
||||
'Programming Language :: Python :: 3.11',
|
||||
'Topic :: Artistic Software',
|
||||
'Topic :: Multimedia :: Graphics',
|
||||
'Topic :: Printing',
|
||||
'Topic :: Scientific/Engineering',
|
||||
'Topic :: Scientific/Engineering :: Electronic Design Automation (EDA)',
|
||||
'Topic :: Scientific/Engineering :: Image Processing',
|
||||
'Topic :: Utilities',
|
||||
'Typing :: Typed',
|
||||
],
|
||||
keywords='gerber excellon pcb',
|
||||
python_requires='>=3.10',
|
||||
)
|
||||
|
|
@ -30,5 +30,6 @@ from .excellon import ExcellonFile
|
|||
from .ipc356 import Netlist
|
||||
from .layers import LayerStack
|
||||
from .utils import MM, Inch
|
||||
from importlib.metadata import version
|
||||
|
||||
__version__ = '1.5.0'
|
||||
__version__ = version('gerbonara')
|
||||
129
tests/conftest.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tqdm
|
||||
import multiprocessing.pool
|
||||
import subprocess
|
||||
from itertools import chain
|
||||
|
||||
import pytest
|
||||
|
||||
from .image_support import ImageDifference, run_cargo_cmd, ImageSupport
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def kicad_container(request):
|
||||
return request.config.kicad_container
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def kicad_footprints_libdir(request):
|
||||
return request.config.kicad_footprints_libdir
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def kicad_symbols_libdir(request):
|
||||
return request.config.kicad_symbols_libdir
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def img_support(request):
|
||||
return request.config.image_support
|
||||
|
||||
|
||||
def pytest_assertrepr_compare(op, left, right):
|
||||
if isinstance(left, ImageDifference) or isinstance(right, ImageDifference):
|
||||
diff = left if isinstance(left, ImageDifference) else right
|
||||
return [
|
||||
f'Image difference assertion failed.',
|
||||
f' Calculated difference: {diff}',
|
||||
f' Histogram: {diff.histogram}', ]
|
||||
|
||||
|
||||
# store report in node object so tmp_gbr can determine if the test failed.
|
||||
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
|
||||
def pytest_runtest_makereport(item, call):
|
||||
outcome = yield
|
||||
rep = outcome.get_result()
|
||||
setattr(item, f'rep_{rep.when}', rep)
|
||||
|
||||
|
||||
fail_dir = Path('gerbonara_test_failures')
|
||||
def pytest_sessionstart(session):
|
||||
if 'PYTEST_XDIST_WORKER' in os.environ: # only run this on the controller
|
||||
return
|
||||
|
||||
for f in chain(fail_dir.glob('*.gbr'), fail_dir.glob('*.png')):
|
||||
f.unlink()
|
||||
|
||||
try:
|
||||
run_cargo_cmd('resvg', '--help', stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
except FileNotFoundError:
|
||||
pytest.exit('resvg binary not found, aborting test.', 2)
|
||||
|
||||
|
||||
def _update_repo_cache(lib_dir, repo_url, tag):
|
||||
if not lib_dir.is_dir():
|
||||
print(f'Checking out KiCad footprint repo tag {tag}')
|
||||
subprocess.run(['git', '-c', 'advice.detachedHead=false', 'clone', '--branch', tag, '--depth', '1', repo_url, str(lib_dir)], check=True)
|
||||
else:
|
||||
print(f'Found cached KiCad footprint checkout, updating to {tag}')
|
||||
subprocess.run(['git', '-C', str(lib_dir), 'fetch', '--depth', '1', 'origin', tag], check=True)
|
||||
subprocess.run(['git', '-c', 'advice.detachedHead=false', '-C', str(lib_dir), 'reset', '--hard', tag], check=True)
|
||||
subprocess.run(['git', '-C', str(lib_dir), 'clean', '--force', '-d', '-x'], check=True)
|
||||
|
||||
|
||||
def pytest_addoption(parser):
|
||||
parser.addini('kicad_footprints_tag', 'git tag or branch for KiCad footprint library repo used as testdata', default='main')
|
||||
parser.addini('kicad_symbols_tag', 'git tag or branch for KiCad symbol library repo used as testdata', default='main')
|
||||
parser.addini('kicad_container_tag', 'docker hub tag for the KiCad container to use for exporting footprint images', default='main')
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
os.nice(20)
|
||||
# Resvg can sometimes consume a lot of memory. Make sure we don't kill the user's session.
|
||||
if (oom_adj := Path('/proc/self/oom_adj')).is_file():
|
||||
oom_adj.write_text('15\n')
|
||||
|
||||
if 'PYTEST_XDIST_WORKER' in os.environ: # only run this on the controller
|
||||
return
|
||||
|
||||
if (lib_dir := os.environ.get('KICAD_FOOTPRINTS')):
|
||||
config.kicad_footprints_libdir = Path(lib_dir).expanduser()
|
||||
else:
|
||||
config.kicad_footprints_libdir = config.cache.mkdir('kicad-footprints') / 'repo'
|
||||
|
||||
if (lib_dir := os.environ.get('KICAD_SYMBOLS')):
|
||||
config.kicad_symbols_libdir = Path(lib_dir).expanduser()
|
||||
else:
|
||||
config.kicad_symbols_libdir = config.cache.mkdir('kicad-symbols') / 'repo'
|
||||
|
||||
# Update cached library repos unless they are overridden from outside.
|
||||
if not os.environ.get('KICAD_FOOTPRINTS'):
|
||||
tag = config.getini('kicad_footprints_tag')
|
||||
_update_repo_cache(config.kicad_footprints_libdir, 'https://gitlab.com/kicad/libraries/kicad-footprints', tag)
|
||||
|
||||
if not os.environ.get('KICAD_SYMBOLS'):
|
||||
tag = config.getini('kicad_symbols_tag')
|
||||
_update_repo_cache(config.kicad_symbols_libdir, 'https://gitlab.com/kicad/libraries/kicad-symbols', tag)
|
||||
|
||||
print('Updating podman image')
|
||||
tag = config.getini("kicad_container_tag")
|
||||
config.kicad_container = os.environ.get('KICAD_CONTAINER', f'registry.hub.docker.com/kicad/kicad:{tag}')
|
||||
subprocess.run(['podman', 'pull', config.kicad_container], check=True)
|
||||
|
||||
config.image_support = ImageSupport(config.cache.mkdir('image_cache'), config.kicad_container)
|
||||
print('Checking KiCad footprint library render cache')
|
||||
with multiprocessing.pool.ThreadPool() as pool: # use thread pool here since we're only monitoring podman processes
|
||||
lib_dirs = list(config.kicad_footprints_libdir.glob('*.pretty'))
|
||||
res = list(tqdm.tqdm(pool.imap(lambda path: config.image_support.bulk_populate_kicad_fp_export_cache(path), lib_dirs), total=len(lib_dirs)))
|
||||
|
||||
|
||||
def pytest_generate_tests(metafunc):
|
||||
if 'kicad_library_file' in metafunc.fixturenames:
|
||||
library_files = list(kicad_symbols_libdir(metafunc.config).glob('*.kicad_sym'))
|
||||
metafunc.parametrize('kicad_library_file', library_files, ids=list(map(str, library_files)))
|
||||
|
||||
if 'kicad_mod_file' in metafunc.fixturenames:
|
||||
mod_files = list(kicad_footprints_libdir(metafunc.config).glob('*.pretty/*.kicad_mod'))
|
||||
metafunc.parametrize('kicad_mod_file', mod_files, ids=list(map(str, mod_files)))
|
||||
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 4 KiB After Width: | Height: | Size: 4 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 556 B After Width: | Height: | Size: 556 B |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
372
tests/image_support.py
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright 2022 Jan Sebastian Götte <gerbonara@jaseg.de>
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# Based on https://github.com/tracespace/tracespace
|
||||
#
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import textwrap
|
||||
import os
|
||||
import sys
|
||||
import stat
|
||||
import random
|
||||
import statistics
|
||||
from functools import total_ordering
|
||||
import shutil
|
||||
import bs4
|
||||
from contextlib import contextmanager
|
||||
import hashlib
|
||||
|
||||
import tqdm
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
@total_ordering
|
||||
class ImageDifference:
|
||||
def __init__(self, value, histogram):
|
||||
self.value = value
|
||||
self.histogram = histogram
|
||||
|
||||
def __float__(self):
|
||||
return float(self.value)
|
||||
|
||||
def __eq__(self, other):
|
||||
return float(self) == float(other)
|
||||
|
||||
def __lt__(self, other):
|
||||
return float(self) < float(other)
|
||||
|
||||
def __str__(self):
|
||||
return str(float(self))
|
||||
|
||||
@total_ordering
|
||||
class Histogram:
|
||||
def __init__(self, value, size):
|
||||
self.value, self.size = value, size
|
||||
|
||||
def __eq__(self, other):
|
||||
other = np.array(other)
|
||||
other[other == None] = self.value[other == None]
|
||||
return (self.value == other).all()
|
||||
|
||||
def __lt__(self, other):
|
||||
other = np.array(other)
|
||||
other[other == None] = self.value[other == None]
|
||||
return (self.value <= other).all()
|
||||
|
||||
def __getitem__(self, index):
|
||||
return self.value[index]
|
||||
|
||||
def __str__(self):
|
||||
return f'{list(self.value)} size={self.size}'
|
||||
|
||||
|
||||
def run_cargo_cmd(cmd, args, **kwargs):
|
||||
if cmd.upper() in os.environ:
|
||||
return subprocess.run([os.environ[cmd.upper()], *args], **kwargs)
|
||||
|
||||
try:
|
||||
return subprocess.run([cmd, *args], **kwargs)
|
||||
|
||||
except FileNotFoundError:
|
||||
return subprocess.run([str(Path.home() / '.cargo' / 'bin' / cmd), *args], **kwargs)
|
||||
|
||||
|
||||
to_gerbv_svg_units = lambda val, unit='mm': val*72 if unit == 'inch' else val/25.4*72
|
||||
|
||||
|
||||
@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))
|
||||
|
||||
|
||||
class ImageSupport:
|
||||
def __init__(self, cachedir, kicad_container):
|
||||
self.cache_root = cachedir
|
||||
self.kicad_container = kicad_container
|
||||
|
||||
|
||||
def cachedir(self, scope, filename, suffix):
|
||||
return self.cache_root / scope / f'{filename}.{suffix}'
|
||||
|
||||
|
||||
def svg_to_png(self, in_svg, out_png, dpi=100, bg=None):
|
||||
params = f'{dpi}{bg}'.encode()
|
||||
digest = hashlib.blake2b(Path(in_svg).read_bytes() + params).hexdigest()
|
||||
cachefile = self.cachedir('svg_render', digest, 'png')
|
||||
|
||||
if not cachefile.is_file():
|
||||
bg = 'black' if bg is None else bg
|
||||
run_cargo_cmd('resvg', ['--background', bg, '--dpi', str(dpi), in_svg, cachefile], check=True, stdout=subprocess.DEVNULL)
|
||||
|
||||
shutil.copy(cachefile, out_png)
|
||||
|
||||
|
||||
def gerbv_export(self, in_gbr, out_svg, export_format='svg', origin=(0, 0), size=(6, 6), fg='#ffffff', bg='#000000', override_unit_spec=None):
|
||||
params = f'{origin}{size}{fg}{bg}'.encode()
|
||||
digest = hashlib.blake2b(Path(in_gbr).read_bytes() + params).hexdigest()
|
||||
cachefile = self.cachedir('gerbv-export', 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.
|
||||
with tempfile.NamedTemporaryFile('w') as f:
|
||||
if override_unit_spec:
|
||||
units, zeros, digits = override_unit_spec
|
||||
print(f'{Path(in_gbr).name}: overriding excellon unit spec to {units=} {zeros=} {digits=}')
|
||||
units = 0 if units == 'inch' else 1
|
||||
zeros = {None: 0, 'leading': 1, 'trailing': 2}[zeros]
|
||||
unit_spec = textwrap.dedent(f'''(cons 'attribs (list
|
||||
(list 'autodetect 'Boolean 0)
|
||||
(list 'zero_suppression 'Enum {zeros})
|
||||
(list 'units 'Enum {units})
|
||||
(list 'digits 'Integer {digits})
|
||||
))''')
|
||||
else:
|
||||
unit_spec = ''
|
||||
|
||||
r, g, b = int(fg[1:3], 16), int(fg[3:5], 16), int(fg[5:], 16)
|
||||
color = f"(cons 'color #({r*257} {g*257} {b*257}))"
|
||||
f.write(f'''(gerbv-file-version! "2.0A")(define-layer! 0 (cons 'filename "{in_gbr}"){unit_spec}{color})''')
|
||||
f.flush()
|
||||
if override_unit_spec:
|
||||
shutil.copy(f.name, '/tmp/foo.gbv')
|
||||
|
||||
x, y = origin
|
||||
w, h = size
|
||||
cmd = ['gerbv', '-x', export_format,
|
||||
'--border=0',
|
||||
f'--origin={x:.6f}x{y:.6f}', f'--window_inch={w:.6f}x{h:.6f}',
|
||||
f'--background={bg}',
|
||||
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)
|
||||
|
||||
|
||||
def kicad_fp_export(self, 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 = self.cachedir('kicad-footprint-export', digest, 'svg')
|
||||
|
||||
if not cachefile.is_file():
|
||||
print(f'Building cache for {mod_file.name}')
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
os.chmod(tmpdir, 0o1777)
|
||||
pretty_dir = mod_file.parent
|
||||
fp_name = mod_file.name[:-len('.kicad_mod')]
|
||||
cmd = ['podman', 'run',
|
||||
'--rm', # Clean up volumes after exit
|
||||
'--userns=keep-id', # To allow container to read from bind mount
|
||||
'--mount', f'type=bind,src={pretty_dir},dst=/{pretty_dir.name}',
|
||||
'--mount', f'type=bind,src={tmpdir},dst=/out',
|
||||
self.kicad_container,
|
||||
'kicad-cli', 'fp', 'export', 'svg', '--output', '/out', '--footprint', fp_name, f'/{pretty_dir.name}']
|
||||
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)
|
||||
|
||||
|
||||
def bulk_populate_kicad_fp_export_cache(self, pretty_dir):
|
||||
def cachefile(mod_file):
|
||||
params = f'(noparams)'.encode()
|
||||
digest = hashlib.blake2b(mod_file.read_bytes() + params).hexdigest()
|
||||
return self.cachedir('kicad-footprint-export', digest, 'svg')
|
||||
|
||||
mod_files = list(pretty_dir.glob('*.kicad_mod'))
|
||||
hit_rate = statistics.mean([int(cachefile(fn).is_file())
|
||||
for fn in random.sample(mod_files, min(len(mod_files), 50))])
|
||||
|
||||
if hit_rate < 0.9:
|
||||
#tqdm.tqdm.write(f'Modfile cache is out of date (hit rate {hit_rate*100:.0f}%), re-building entire cache in bulk')
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
os.chmod(tmpdir, 0o1777)
|
||||
cmd = ['podman', 'run',
|
||||
'--rm', # Clean up volumes after exit
|
||||
'--userns=keep-id', # To allow container to read from bind mount
|
||||
'--mount', f'type=bind,src={pretty_dir},dst=/{pretty_dir.name}',
|
||||
'--mount', f'type=bind,src={tmpdir},dst=/out',
|
||||
self.kicad_container,
|
||||
'kicad-cli', 'fp', 'export', 'svg', '--output', '/out', f'/{pretty_dir.name}']
|
||||
print(f'Running "{" ".join(cmd)}"')
|
||||
|
||||
try:
|
||||
subprocess.run(cmd, check=True) #, stdout=subprocess.DEVNULL)
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
print('Error running command with command line:', ' '.join(e.cmd), file=sys.stderr)
|
||||
raise e
|
||||
|
||||
for fn in mod_files:
|
||||
out_file = Path(tmpdir) / fn.with_suffix('.svg').name
|
||||
if not out_file.is_file():
|
||||
tqdm.tqdm.write(f'Output file {out_file} is missing while bulk re-building cache for {pretty_dir}.')
|
||||
else:
|
||||
shutil.copy(out_file, cachefile(fn))
|
||||
|
||||
|
||||
def cleanup_gerbv_svg(self, soup):
|
||||
width = soup.svg["width"]
|
||||
height = soup.svg["height"]
|
||||
width = width[:-2] if width.endswith('pt') else width
|
||||
height = height[:-2] if height.endswith('pt') else height
|
||||
soup.svg['width'] = f'{float(width)/72*25.4:.4f}mm'
|
||||
soup.svg['height'] = f'{float(height)/72*25.4:.4f}mm'
|
||||
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']
|
||||
|
||||
def gerber_difference(self, reference, actual, diff_out=None, svg_transform=None, size=(10,10), ref_unit_spec=None):
|
||||
with tempfile.NamedTemporaryFile(suffix='.svg') as act_svg,\
|
||||
tempfile.NamedTemporaryFile(suffix='.svg') as ref_svg:
|
||||
|
||||
self.gerbv_export(reference, ref_svg.name, size=size, export_format='svg', override_unit_spec=ref_unit_spec)
|
||||
self.gerbv_export(actual, act_svg.name, size=size, export_format='svg')
|
||||
|
||||
with svg_soup(ref_svg.name) as soup:
|
||||
if svg_transform is not None:
|
||||
svg = soup.svg
|
||||
children = list(svg.children)
|
||||
g = soup.new_tag('g', attrs={'transform': svg_transform})
|
||||
for c in children:
|
||||
g.append(c.extract())
|
||||
svg.append(g)
|
||||
|
||||
self.cleanup_gerbv_svg(soup)
|
||||
|
||||
with svg_soup(act_svg.name) as soup:
|
||||
self.cleanup_gerbv_svg(soup)
|
||||
|
||||
return self.svg_difference(ref_svg.name, act_svg.name, diff_out=diff_out)
|
||||
|
||||
def gerber_difference_merge(self, ref1, ref2, actual, diff_out=None, composite_out=None, svg_transform1=None, svg_transform2=None, size=(10,10)):
|
||||
with tempfile.NamedTemporaryFile(suffix='.svg') as act_svg,\
|
||||
tempfile.NamedTemporaryFile(suffix='.svg') as ref1_svg,\
|
||||
tempfile.NamedTemporaryFile(suffix='.svg') as ref2_svg:
|
||||
|
||||
self.gerbv_export(ref1, ref1_svg.name, size=size, export_format='svg')
|
||||
self.gerbv_export(ref2, ref2_svg.name, size=size, export_format='svg')
|
||||
self.gerbv_export(actual, act_svg.name, size=size, export_format='svg')
|
||||
for var in ['ref1_svg', 'ref2_svg', 'act_svg']:
|
||||
print(f'=== {var} ===')
|
||||
print(Path(locals()[var].name).read_text().splitlines()[1])
|
||||
|
||||
with svg_soup(ref1_svg.name) as soup1:
|
||||
if svg_transform1 is not None:
|
||||
svg = soup1.svg
|
||||
children = list(svg.children)
|
||||
g = soup1.new_tag('g', attrs={'transform': svg_transform1})
|
||||
for c in children:
|
||||
g.append(c.extract())
|
||||
svg.append(g)
|
||||
self.cleanup_gerbv_svg(soup1)
|
||||
|
||||
with svg_soup(ref2_svg.name) as soup2:
|
||||
if svg_transform2 is not None:
|
||||
svg = soup2.svg
|
||||
children = list(svg.children)
|
||||
g = soup2.new_tag('g', attrs={'transform': svg_transform2})
|
||||
for c in children:
|
||||
g.append(c.extract())
|
||||
svg.append(g)
|
||||
self.cleanup_gerbv_svg(soup2)
|
||||
|
||||
defs1 = soup1.find('defs')
|
||||
if not defs1:
|
||||
defs1 = soup1.new_tag('defs')
|
||||
soup1.find('svg').insert(0, defs1)
|
||||
|
||||
defs2 = soup2.find('defs')
|
||||
if defs2:
|
||||
defs2 = defs2.extract()
|
||||
# explicitly convert .contents into list here and below because else bs4 stumbles over itself
|
||||
# iterating because we modify the tree in the loop body.
|
||||
for c in list(defs2.contents):
|
||||
if hasattr(c, 'attrs'):
|
||||
c['id'] = 'gn-merge-b-' + c.attrs.get('id', str(id(c)))
|
||||
defs1.append(c)
|
||||
|
||||
for use in soup2.find_all('use', recursive=True):
|
||||
if (href := use.get('xlink:href', '')).startswith('#'):
|
||||
use['xlink:href'] = f'#gn-merge-b-{href[1:]}'
|
||||
|
||||
svg1 = soup1.find('svg')
|
||||
for c in list(soup2.find('svg').contents):
|
||||
if hasattr(c, 'attrs'):
|
||||
c['id'] = 'gn-merge-b-' + c.attrs.get('id', str(id(c)))
|
||||
svg1.append(c)
|
||||
|
||||
if composite_out:
|
||||
shutil.copyfile(ref1_svg.name, composite_out)
|
||||
|
||||
with svg_soup(act_svg.name) as soup:
|
||||
self.cleanup_gerbv_svg(soup)
|
||||
|
||||
return self.svg_difference(ref1_svg.name, act_svg.name, diff_out=diff_out)
|
||||
|
||||
def svg_difference(self, 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, dpi=dpi)
|
||||
svg_to_png(actual, act_png.name, bg=background, dpi=dpi)
|
||||
|
||||
return self.image_difference(ref_png.name, act_png.name, diff_out=diff_out)
|
||||
|
||||
def image_difference(self, reference, actual, diff_out=None):
|
||||
ref = np.array(Image.open(reference)).astype(float)
|
||||
out = np.array(Image.open(actual)).astype(float)
|
||||
|
||||
ref, out = ref.mean(axis=2), out.mean(axis=2) # convert to grayscale
|
||||
# TODO blur images here before comparison to mitigate aliasing issue
|
||||
delta = np.abs(out - ref).astype(float) / 255
|
||||
if diff_out:
|
||||
Image.fromarray((delta*255).astype(np.uint8), mode='L').save(diff_out)
|
||||
|
||||
hist, _bins = np.histogram(delta, bins=10, range=(0, 1))
|
||||
return (ImageDifference(delta.mean(), hist),
|
||||
ImageDifference(delta.max(), hist),
|
||||
Histogram(hist, out.size))
|
||||
|
||||
|
||||