1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
|
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, FootprintInstance, LAYER_MAP_G2K
from ..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):
print('========== Stage 1 load ==========')
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:
f.write(stage1_sexp)
print('========== Stage 2 load ==========')
reparsed_fp = Footprint.parse(stage1_sexp)
print('========== Stage 2 save ==========')
stage2_sexp = build_sexp(reparsed_fp.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
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
|