325 lines
14 KiB
Python
325 lines
14 KiB
Python
|
|
import math
|
|
from itertools import zip_longest
|
|
import subprocess
|
|
import re
|
|
|
|
import bs4
|
|
|
|
from .utils import tmpfile, print_on_error
|
|
from .image_support import run_cargo_cmd, svg_soup
|
|
|
|
from gerbonara import graphic_objects as go
|
|
from gerbonara.utils import MM, arc_bounds, sum_bounds
|
|
from gerbonara.layers import LayerStack
|
|
from gerbonara.cad.kicad.sexp import build_sexp, Atom
|
|
from gerbonara.cad.kicad.sexp_mapper import sexp
|
|
from gerbonara.cad.kicad.footprints import Footprint, FootprintInstance, LAYER_MAP_G2K
|
|
from gerbonara.cad.kicad.layer_colors import KICAD_LAYER_COLORS, KICAD_DRILL_COLORS
|
|
|
|
|
|
def test_parse(kicad_mod_file):
|
|
Footprint.open_mod(kicad_mod_file)
|
|
|
|
|
|
def test_round_trip(kicad_mod_file, tmpfile):
|
|
print('========== Stage 1 load ==========')
|
|
orig_fp = Footprint.open_mod(kicad_mod_file)
|
|
print('========== Stage 1 save ==========')
|
|
stage1_sexp = build_sexp(orig_fp.sexp())
|
|
tmp_fp_gen1 = tmpfile('First generation output', '.kicad_mod')
|
|
tmp_fp_gen1.write_text(stage1_sexp)
|
|
|
|
print('========== Stage 2 load ==========')
|
|
reparsed_fp = Footprint.parse(stage1_sexp)
|
|
print('========== Stage 2 save ==========')
|
|
stage2_sexp = build_sexp(reparsed_fp.sexp())
|
|
tmp_fp_gen2 = tmpfile('Second generation output', '.kicad_mod')
|
|
tmp_fp_gen2.write_text(stage2_sexp)
|
|
print('========== Checks ==========')
|
|
|
|
for stage1, stage2 in zip_longest(stage1_sexp.splitlines(), stage2_sexp.splitlines()):
|
|
assert stage1 == stage2
|
|
|
|
return
|
|
|
|
original = re.sub(r'\(', '\n(', re.sub(r'\s+', ' ', kicad_mod_file.read_text()))
|
|
original = re.sub(r'\) \)', '))', original)
|
|
original = re.sub(r'\) \)', '))', original)
|
|
original = re.sub(r'\) \)', '))', original)
|
|
original = re.sub(r'\) \)', '))', original)
|
|
stage1 = re.sub(r'\(', '\n(', re.sub(r'\s+', ' ', stage1_sexp))
|
|
for original, stage1 in zip_longest(original.splitlines(), stage1.splitlines()):
|
|
if original.startswith('(version'):
|
|
continue
|
|
|
|
original, stage1 = original.strip(), stage1.strip()
|
|
if original != stage1:
|
|
if any(original.startswith(f'({foo}') for foo in ['arc', 'circle', 'rectangle', 'polyline', 'text']):
|
|
# These files have symbols with graphic primitives in non-standard order
|
|
return
|
|
|
|
if original.startswith('(symbol') and stage1.startswith('(symbol'):
|
|
# Re-export can change symbol order. This is ok.
|
|
return
|
|
|
|
if original.startswith('(at') and stage1.startswith('(at'):
|
|
# There is some disagreement as to whether rotation angles are ints or floats, and the spec doesn't say.
|
|
return
|
|
|
|
assert original == stage1
|
|
|
|
|
|
# Regrettably, we have to re-implement a significant part of the SVG spec to fix up the SVGs that kicad-cli produces.
|
|
|
|
def _compute_style(elem):
|
|
current_style = {}
|
|
for elem in [*reversed(list(elem.parents)), elem]:
|
|
attrs = dict(elem.attrs)
|
|
for match in re.finditer(r'([^:;]+):([^:;]+)', attrs.pop('style', '')):
|
|
k, v = match.groups()
|
|
current_style[k.strip().lower()] = v.strip()
|
|
|
|
for k, v in elem.attrs.items():
|
|
current_style[k.lower()] = v
|
|
return current_style
|
|
|
|
|
|
def _parse_path_d(path):
|
|
path_d = path.get('d')
|
|
if not path_d:
|
|
return
|
|
|
|
style = _compute_style(path)
|
|
if style.get('stroke', 'none') != 'none':
|
|
sr = float(style.get('stroke-width', 0)) / 2
|
|
else:
|
|
sr = 0
|
|
|
|
if 'C' in path_d:
|
|
raise ValueError('Path contains cubic beziers')
|
|
|
|
last_x, last_y = None, None
|
|
# NOTE: kicad-cli exports oddly broken svgs. One of the weirder issues is that in some paths, the "L" command is
|
|
# simply ommitted.
|
|
for match in re.finditer(r'([ML]?) ?([0-9.]+) *,? *([0-9.]+)|(A) ?([0-9.]+) *,? *([0-9.]+) *,? *([0-9.]+) *,? * ([01]) *,? *([01]) *,? *([0-9.]+) *,? *([0-9.]+)', path_d):
|
|
ml, x, y, a, rx, ry, angle, large_arc, sweep, ax, ay = match.groups()
|
|
|
|
if ml or not a:
|
|
x, y = float(x), float(y)
|
|
last_x, last_y = x, y
|
|
yield x-sr, y-sr
|
|
yield x-sr, y+sr
|
|
yield x+sr, y-sr
|
|
yield x+sr, y+sr
|
|
|
|
else: # a
|
|
rx, ry = float(rx), float(ry)
|
|
ax, ay = float(ax), float(ay)
|
|
angle = float(angle)
|
|
large_arc = bool(int(large_arc))
|
|
sweep = bool(int(sweep))
|
|
|
|
if not math.isclose(rx, ry, abs_tol=1e-6):
|
|
raise ValueError("Elliptical arcs not supported. How did that end up here? KiCad can't do those either!")
|
|
|
|
mx = (last_x + ax)/2
|
|
my = (last_y + ay)/2
|
|
dx = ax - last_x
|
|
dy = ay - last_y
|
|
l = math.hypot(dx, dy)
|
|
|
|
# clockwise normal
|
|
nx = -dy/l
|
|
ny = dx/l
|
|
arg = rx**2 - (l/2)**2
|
|
if arg < 0 or math.isclose(arg, 0, abs_tol=1e-6):
|
|
cx, cy = mx, my
|
|
else:
|
|
nl = math.sqrt(arg)
|
|
if sweep != large_arc:
|
|
cx = mx + nx*nl
|
|
cy = my + ny*nl
|
|
else:
|
|
cx = mx - nx*nl
|
|
cy = my - ny*nl
|
|
|
|
(min_x, min_y), (max_x, max_y) = arc_bounds(last_x, last_y, ax, ay, cx, cy, clockwise=(not sweep))
|
|
min_x -= sr
|
|
min_y -= sr
|
|
max_x += sr
|
|
max_y += sr
|
|
# dbg_i += 1
|
|
# with open(f'/tmp/dbg-arc-{dbg_i}.svg', 'w') as f:
|
|
# vbx, vby = min(last_x, ax), min(last_y, ay)
|
|
# vbw, vbh = max(last_x, ax), max(last_y, ay)
|
|
# vbw -= vbx
|
|
# vbh -= vby
|
|
# k = 2
|
|
# vbx -= vbw*k
|
|
# vby -= vbh*k
|
|
# vbw *= 2*k+1
|
|
# vbh *= 2*k+1
|
|
# sw = min(vbw, vbh)*1e-3
|
|
# mr = 3*sw
|
|
# f.write('<?xml version="1.0" standalone="no"?>\n')
|
|
# f.write('<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">\n')
|
|
# f.write(f'<svg version="1.1" width="200mm" height="200mm" viewBox="{vbx} {vby} {vbw} {vbh}" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">>\n')
|
|
# f.write(f'<path fill="none" stroke="#ff00ff" stroke-width="{sw}" d="{path_d}"/>\n')
|
|
# f.write(f'<rect fill="none" stroke="#404040" stroke-width="{sw}" x="{min_x}" y="{min_y}" width="{max_x-min_x}" height="{max_y-min_y}"/>\n')
|
|
# f.write(f'<circle fill="none" r="{mr}" stroke="blue" stroke-width="{sw}" cx="{last_x}" cy="{last_y}"/>\n')
|
|
# f.write(f'<circle fill="none" r="{mr}" stroke="blue" stroke-width="{sw}" cx="{ax}" cy="{ay}"/>\n')
|
|
# f.write(f'<circle fill="none" r="{mr}" stroke="red" stroke-width="{sw}" cx="{mx}" cy="{my}"/>\n')
|
|
# f.write(f'<circle fill="none" r="{mr}" stroke="red" stroke-width="{sw}" cx="{cx}" cy="{cy}"/>\n')
|
|
# f.write('</svg>\n')
|
|
yield min_x, min_y
|
|
yield min_x, max_y
|
|
yield max_x, min_y
|
|
yield max_x, max_y
|
|
last_x, last_y = ax, ay
|
|
|
|
def test_render(kicad_mod_file, tmpfile, print_on_error, img_support):
|
|
# These files have a large, mask-only pad that has a large solder mask margin set. Kicad doesn't render the margin
|
|
# at all, which I think it should. We render things exactly as I'd expect.
|
|
if kicad_mod_file.name in ['Fiducial_classic_big_CopperBottom_Type2.kicad_mod',
|
|
'Fiducial_classic_big_CopperTop_Type2.kicad_mod',
|
|
'Fiducial_classic_big_SilkscreenTop_Type2.kicad_mod']:
|
|
return
|
|
|
|
# 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, adhesive=True)
|
|
stack.add_layer('mechanical drawings')
|
|
stack.add_layer('mechanical comments')
|
|
fp.render(stack)
|
|
color_map = {f'{side} {use}': KICAD_LAYER_COLORS[kicad_id] for (side, use), kicad_id in LAYER_MAP_G2K.items()}
|
|
color_map['drill pth'] = (255, 255, 255, 1)
|
|
color_map['drill npth'] = (255, 255, 255, 1)
|
|
# Remove alpha since overlaid shapes won't work correctly with non-1 alpha without complicated svg filter hacks
|
|
color_map = {key: (f'#{r:02x}{g:02x}{b:02x}', '1') for key, (r, g, b, _a) in color_map.items()}
|
|
|
|
margin = 10 # mm
|
|
|
|
layer = stack[('top', 'courtyard')]
|
|
bounds = []
|
|
#print('===== BOUNDS =====')
|
|
for obj in layer.objects:
|
|
if isinstance(obj, (go.Line, go.Arc)):
|
|
bbox = (min_x, min_y), (max_x, max_y) = obj.bounding_box(unit=MM)
|
|
#import textwrap
|
|
#print(f'{min_x: 3.6f} {min_y: 3.6f} {max_x: 3.6f} {max_y: 3.6f}', '\n'.join(textwrap.wrap(str(obj), width=80, subsequent_indent=' '*(3+4*(3+1+6)))))
|
|
bounds.append(bbox)
|
|
#print('===== END =====')
|
|
|
|
if not bounds:
|
|
print('Footprint has no paths on courtyard layer')
|
|
return
|
|
|
|
bounds = sum_bounds(bounds)
|
|
(min_x, min_y), (max_x, max_y) = bounds
|
|
w, h = max_x-min_x, max_y-min_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(colors=color_map, force_bounds=bounds, margin=margin)))
|
|
|
|
print_on_error('Input footprint:', kicad_mod_file)
|
|
ref_svg = tmpfile('Reference render', '.svg')
|
|
img_support.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}'
|
|
|
|
# nuke text since kicad-cli's text positioning looks sligthly wonky and we failed to replicate that wonkyness
|
|
# exactly.
|
|
for elem in soup.find_all('g', attrs={'class': 'stroked-text'}):
|
|
elem.decompose()
|
|
|
|
for elem in soup.find_all('text'):
|
|
elem.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)])
|
|
|
|
with svg_soup(ref_svg) as soup:
|
|
# fix up usvg width/height
|
|
root = soup.find('svg')
|
|
root['width'] = root_w
|
|
root['height'] = root_h
|
|
|
|
#for elem in root.find_all('path'):
|
|
# if elem.attrs.get('fill', '').lower() == '#d864ff' and math.isclose(float(elem.attrs.get('fill-opacity', 0)), 0.4):
|
|
# elem.decompose()
|
|
|
|
# remove alpha to avoid complicated filter hacks
|
|
for elem in root.descendants:
|
|
if not isinstance(elem, bs4.Tag):
|
|
continue
|
|
|
|
if elem.has_attr('opacity'):
|
|
elem['opacity'] = '1'
|
|
|
|
if elem.has_attr('fill-opacity'):
|
|
elem['fill-opacity'] = '1'
|
|
|
|
if elem.has_attr('stroke-opacity'):
|
|
elem['stroke-opacity'] = '1'
|
|
|
|
# kicad-cli incorrectly fills arcs
|
|
for elem in root.find_all('path'):
|
|
if ' C ' in elem.get('d', '') and elem.get('stroke', 'none') != 'none':
|
|
elem['fill'] = 'none'
|
|
|
|
# Move fabrication layers above drills because kicad-cli's svg rendering is wonky.
|
|
with svg_soup(out_svg) as soup:
|
|
root = soup.find('svg')
|
|
root.append(soup.find('g', id='l-bottom-fabrication').extract())
|
|
root.append(soup.find('g', id='l-top-fabrication').extract())
|
|
|
|
img_support.svg_to_png(ref_svg, tmpfile('Reference render', '.png'), bg=None, dpi=600)
|
|
img_support.svg_to_png(out_svg, tmpfile('Output render', '.png'), bg=None, dpi=600)
|
|
mean, _max, hist = img_support.svg_difference(ref_svg, out_svg, dpi=600, diff_out=tmpfile('Difference', '.png'))
|
|
|
|
# compensate for circular pads aliasing badly
|
|
aliasing_artifacts = 1e-3 * len(fp.sexp.pads)/10
|
|
assert mean < 3e-3 + aliasing_artifacts
|
|
assert hist[9] < 100
|
|
assert hist[3:].sum() < (1e-3 + 10*aliasing_artifacts)*hist.size
|
|
|