kicad unit tests WIP

This commit is contained in:
jaseg 2023-04-22 17:16:20 +02:00
parent 5ce88e4d1b
commit a93d118773
8 changed files with 360 additions and 69 deletions

View file

@ -4,12 +4,14 @@ Library for handling KiCad's footprint files (`*.kicad_mod`).
import copy
import enum
import string
import datetime
import math
import time
import fnmatch
from itertools import chain
from pathlib import Path
from dataclasses import field
from .sexp import *
from .base_types import *
@ -21,6 +23,7 @@ from ..primitives import Positioned
from ... import graphic_primitives as gp
from ... import graphic_objects as go
from ... import apertures as ap
from ...newstroke import Newstroke
from ...utils import MM
from ...aperture_macros.parse import GenericMacros, ApertureMacro
@ -50,8 +53,11 @@ class Text:
effects: TextEffect = field(default_factory=TextEffect)
tstamp: Timestamp = None
def render(self):
raise NotImplementedError()
def render(self, variables={}):
if self.hide: # why
return
yield from gr.Text.render(self, variables=variables)
@sexp_type('fp_text_box')
@ -68,8 +74,8 @@ class TextBox:
stroke: Stroke = field(default_factory=Stroke)
render_cache: RenderCache = None
def render(self):
raise NotImplementedError()
def render(self, variables={}):
yield from gr.TextBox.render(self, variables=variables)
@sexp_type('fp_line')
@ -82,7 +88,7 @@ class Line:
locked: Flag() = False
tstamp: Timestamp = None
def render(self):
def render(self, variables=None):
dasher = Dasher(self)
dasher.move(self.start.x, self.start.y)
dasher.line(self.end.x, self.end.y)
@ -102,7 +108,7 @@ class Rectangle:
locked: Flag() = False
tstamp: Timestamp = None
def render(self):
def render(self, variables=None):
x1, y1 = self.start.x, self.start.y
x2, y2 = self.end.x, self.end.y
x1, x2 = min(x1, x2), max(x1, x2)
@ -135,17 +141,19 @@ class Circle:
locked: Flag() = False
tstamp: Timestamp = None
def render(self):
def render(self, variables=None):
x, y = self.center.x, self.center.y
r = math.dist((x, y), (self.end.x, self.end.y)) # insane
circle = go.Arc.from_circle(x, y, r, unit=MM)
dasher = Dasher(self)
aperture = ap.CircleAperture(dasher.width or 0, unit=MM)
circle = go.Arc.from_circle(x, y, r, aperture=aperture, unit=MM)
if self.fill == Atom.solid:
yield circle.to_region()
dasher = Dasher(self)
if dasher.solid:
circle.aperture = CircleAperture(dasher.width, unit=MM)
yield circle
else: # pain
@ -168,7 +176,7 @@ class Arc:
tstamp: Timestamp = None
def render(self):
def render(self, variables=None):
cx, cy = self.mid.x, self.mid.y
x1, y1 = self.start.x, self.start.y
x2, y2 = self.end.x, self.end.y
@ -198,7 +206,7 @@ class Polygon:
locked: Flag() = False
tstamp: Timestamp = None
def render(self):
def render(self, variables=None):
if len(self.pts.xy) < 2:
return
@ -225,7 +233,7 @@ class Curve:
locked: Flag() = False
tstamp: Timestamp = None
def render(self):
def render(self, variables=None):
raise NotImplementedError('Bezier rendering is not yet supported. Please raise an issue and provide an example file.')
@ -265,7 +273,7 @@ class Dimension:
format: DimensionFormat = field(default_factory=DimensionFormat)
style: DimensionStyle = field(default_factory=DimensionStyle)
def render(self):
def render(self, variables=None):
raise NotImplementedError()
@ -351,7 +359,7 @@ class Pad:
options: OmitDefault(CustomPadOptions) = None
primitives: OmitDefault(CustomPadPrimitives) = None
def render(self):
def render(self, variables=None):
if self.type in (Atom.connect, Atom.np_thru_hole):
return
@ -380,7 +388,7 @@ class Pad:
[x+dx, y+dy,
2*max(dx, dy),
0, 0, # no hole
math.radians(self.at.rotation)])
math.radians(self.at.rotation)], unit=MM)
elif self.shape == Atom.roundrect:
x, y = self.size.x, self.size.y
@ -389,7 +397,7 @@ class Pad:
[x, y,
r,
0, 0, # no hole
math.radians(self.at.rotation)])
math.radians(self.at.rotation)], unit=MM)
elif self.shape == Atom.custom:
primitives = []
@ -398,7 +406,7 @@ class Pad:
for gn_obj in obj.render():
primitives += gn_obj._aperture_macro_primitives() # todo: precision params
macro = ApertureMacro(primitives=primitives)
return ap.ApertureMacroInstance(macro)
return ap.ApertureMacroInstance(macro, unit=MM)
def render_drill(self):
if not self.drill:
@ -517,6 +525,7 @@ class Footprint:
def objects(self, text=False, pads=True):
return chain(
(self.texts if text else []),
(self.text_boxes if text else []),
self.lines,
self.rectangles,
self.circles,
@ -524,20 +533,19 @@ class Footprint:
self.polygons,
self.curves,
(self.dimensions if text else []),
(self.pads if pads else []),
self.zones)
(self.pads if pads else []))
def render(self, layer_stack, layer_map, x=0, y=0, rotation=0, side=None):
def render(self, layer_stack, layer_map, x=0, y=0, rotation=0, text=False, side=None, variables={}):
x += self.at.x
y += self.at.y
rotation += math.radians(self.at.rotation)
flip = (side != 'top') if side else (self.layer != 'F.Cu')
for obj in self.objects(pads=False, text=False):
for obj in self.objects(pads=False, text=text):
if not (layer := layer_map.get(obj.layer)):
continue
for fe in obj.render():
for fe in obj.render(variables=variables):
fe.rotate(rotation)
fe.offset(x, y, MM)
layer_stack[layer].objects.append(fe)
@ -562,7 +570,7 @@ class Footprint:
else:
layer_stack.drill_pth.append(fe)
LAYER_MAP = {
LAYER_MAP_K2G = {
'F.Cu': ('top', 'copper'),
'B.Cu': ('bottom', 'copper'),
'F.SilkS': ('top', 'silk'),
@ -571,18 +579,41 @@ LAYER_MAP = {
'B.Paste': ('bottom', 'paste'),
'F.Mask': ('top', 'mask'),
'B.Mask': ('bottom', 'mask'),
'B.CrtYd': ('bottom', 'courtyard'),
'F.CrtYd': ('top', 'courtyard'),
'B.Fab': ('bottom', 'fabrication'),
'F.Fab': ('top', 'fabrication'),
'Edge.Cuts': ('mechanical', 'outline'),
}
LAYER_MAP_G2K = {v: k for k, v in LAYER_MAP_K2G.items()}
@dataclass
class FootprintInstance(Positioned):
sexp: Footprint = None
hide_text: bool = True
reference: str = 'REF**'
value: str = None
variables: dict = field(default_factory=lambda: {})
def render(self, layer_stack):
x, y, rotation = self.abs_pos
x, y = MM(x, self.unit), MM(y, self.unit)
self.sexp.render(layer_stack, LAYER_MAP, x=x, y=y, rotation=rotation, side=self.side)
variables = dict(self.variables)
if self.reference is not None:
variables['REFERENCE'] = str(self.reference)
if self.value is not None:
variables['VALUE'] = str(self.value)
self.sexp.render(layer_stack, LAYER_MAP_K2G,
x=x, y=y, rotation=rotation,
side=self.side,
text=(not self.hide_text),
variables=variables)
if __name__ == '__main__':
import sys

View file

@ -1,4 +1,5 @@
import string
import math
from .sexp import *
@ -24,12 +25,14 @@ class Text:
tstamp: Timestamp = None
effects: TextEffect = field(default_factory=TextEffect)
def render(self):
def render(self, variables={}):
if not self.effects or self.effects.hide or not self.effects.font:
return
font = Newstroke.load()
strokes = list(font.render(self.text, size=self.effects.font.size.y))
line_width = self.effects.font.thickness
text = string.Template(self.text).safe_substitute(variables)
strokes = list(font.render(text, size=self.effects.font.size.y))
min_x = min(x for st in strokes for x, y in st)
min_y = min(y for st in strokes for x, y in st)
max_x = max(x for st in strokes for x, y in st)
@ -42,21 +45,25 @@ class Text:
Atom.right: -w,
Atom.left: 0
}[self.effects.justify.h if self.effects.justify else None]
offy = {
None: -h/2,
Atom.top: -h,
None: self.effects.font.size.y/2,
Atom.top: self.effects.font.size.y,
Atom.bottom: 0
}[self.effects.justify.v if self.effects.justify else None]
aperture = ap.CircleAperture(self.effects.font.width or 0.2, unit=MM)
aperture = ap.CircleAperture(line_width or 0.2, unit=MM)
for stroke in strokes:
out = []
for point in stroke:
x, y = rotate_point(x, y, math.radians(self.at.rotation or 0))
for x, y in stroke:
x, y = x+offx, y+offy
x, y = rotate_point(x, y, math.radians(self.at.rotation or 0))
x, y = x+self.at.x, y+self.at.y
out.append((x, y))
for p1, p2 in zip(out[:-1], out[1:]):
yield go.Line(*p1, *p2, aperture=ap, unit=MM)
yield go.Line(*p1, *p2, aperture=aperture, unit=MM)
@sexp_type('gr_text_box')
@ -73,9 +80,13 @@ class TextBox:
stroke: Stroke = field(default_factory=Stroke)
render_cache: RenderCache = None
def render(self):
def render(self, variables={}):
text = string.Template(self.text).safe_substitute(variables)
if text != self.text:
raise ValueError('Rendering of vector font text with variables not yet supported')
if not render_cache or not render_cache.polygons:
raise ValueError('Text box with empty render cache')
raise ValueError('Vector font text with empty render cache')
for poly in render_cache.polygons:
reg = go.Region([(p.x, p.y) for p in poly.pts.xy], unit=MM)
@ -98,12 +109,12 @@ class Line:
width: Named(float) = None
tstamp: Timestamp = None
def render(self):
def render(self, variables=None):
if self.angle:
raise NotImplementedError('Angles on lines are not implemented. Please raise an issue and provide an example file.')
ap = ap.CircleAperture(self.width, unit=MM)
return go.Line(self.start.x, self.start.y, self.end.x, self.end.y, aperture=ap, unit=MM)
aperture = ap.CircleAperture(self.width, unit=MM)
yield go.Line(self.start.x, self.start.y, self.end.x, self.end.y, aperture=aperture, unit=MM)
@sexp_type('fill')
@ -128,7 +139,7 @@ class Rectangle:
fill: FillMode = False
tstamp: Timestamp = None
def render(self):
def render(self, variables=None):
rect = go.Region.from_rectangle(self.start.x, self.start.y,
self.end.x-self.start.x, self.end.y-self.start.y,
unit=MM)
@ -149,12 +160,12 @@ class Circle:
fill: FillMode = False
tstamp: Timestamp = None
def render(self):
def render(self, variables=None):
r = math.dist((self.center.x, self.center.y), (self.end.x, self.end.y))
arc = go.Arc.from_circle(self.center.x, self.center.y, r, unit=MM)
aperture = ap.CircleAperture(self.width or 0, unit=MM)
arc = go.Arc.from_circle(self.center.x, self.center.y, r, aperture=aperture, unit=MM)
if self.width:
arc.aperture = ap.CircleAperture(self.width, unit=MM)
yield arc
if self.fill:
@ -170,18 +181,14 @@ class Arc:
width: Named(float) = None
tstamp: Timestamp = None
def render(self):
def render(self, variables=None):
if not self.width:
return
cx, cy = self.mid.x, self.mid.y
x1, y1 = self.start.x, self.start.y
x2, y2 = self.end.x, self.end.y
arc = go.Arc(x1, y1, x2, y2, cx-x1, cy-y1, unit=MM)
if self.width:
arc.aperture = ap.CircleAperture(self.width, unit=MM)
yield arc
if self.fill:
yield arc.to_region()
yield go.Arc(x1, y1, x2, y2, cx-x1, cy-y1, aperture=ap.CircleAperture(self.width or 0, unit=MM), clockwise=True, unit=MM)
@sexp_type('gr_poly')
@ -192,7 +199,7 @@ class Polygon:
fill: FillMode = True
tstamp: Timestamp = None
def render(self):
def render(self, variables=None):
reg = go.Region([(pt.x, pt.y) for pt in self.pts.xy], unit=MM)
if self.width and self.width >= 0.005:
@ -209,7 +216,7 @@ class Curve:
width: Named(float) = None
tstamp: Timestamp = None
def render(self):
def render(self, variables=None):
raise NotImplementedError('Bezier rendering is not yet supported. Please raise an issue and provide an example file.')
@ -218,6 +225,6 @@ class AnnotationBBox:
start: Rename(XYCoord) = None
end: Rename(XYCoord) = None
def render(self):
def render(self, variables=None):
return []

View file

@ -0,0 +1,70 @@
# Maps KiCad layer IDs to (r, g, b, a) color tuples. R, G, B are ints in [0...255], a is a float in [0...1]
KICAD_LAYER_COLORS = {
'F.Cu': (200, 52, 52, 1),
'In1.Cu': (127, 200, 127, 1),
'In2.Cu': (206, 125, 44, 1),
'In3.Cu': (79, 203, 203, 1),
'In4.Cu': (219, 98, 139, 1),
'In5.Cu': (167, 165, 198, 1),
'In6.Cu': (40, 204, 217, 1),
'In7.Cu': (232, 178, 167, 1),
'In8.Cu': (242, 237, 161, 1),
'In9.Cu': (141, 203, 129, 1),
'In10.Cu': (237, 124, 51, 1),
'In11.Cu': (91, 195, 235, 1),
'In12.Cu': (247, 111, 142, 1),
'In13.Cu': (167, 165, 198, 1),
'In14.Cu': (40, 204, 217, 1),
'In15.Cu': (232, 178, 167, 1),
'In16.Cu': (242, 237, 161, 1),
'In17.Cu': (237, 124, 51, 1),
'In18.Cu': (91, 195, 235, 1),
'In19.Cu': (247, 111, 142, 1),
'In20.Cu': (167, 165, 198, 1),
'In21.Cu': (40, 204, 217, 1),
'In22.Cu': (232, 178, 167, 1),
'In23.Cu': (242, 237, 161, 1),
'In24.Cu': (237, 124, 51, 1),
'In25.Cu': (91, 195, 235, 1),
'In26.Cu': (247, 111, 142, 1),
'In27.Cu': (167, 165, 198, 1),
'In28.Cu': (40, 204, 217, 1),
'In29.Cu': (232, 178, 167, 1),
'In30.Cu': (242, 237, 161, 1),
'B.Cu': (77, 127, 196, 1),
'B.Adhes': (0, 0, 132, 1),
'F.Adhes': (132, 0, 132, 1),
'B.Paste': (0, 194, 194, 0.9),
'F.Paste': (180, 160, 154, 0.9),
'B.SilkS': (232, 178, 167, 1),
'F.SilkS': (242, 237, 161, 1),
'B.Mask': (2, 255, 238, 0.4),
'F.Mask': (216, 100, 255, 0.4),
'Dwgs.User': (194, 194, 194, 1),
'Cmts.User': (89, 148, 220, 1),
'Eco1.User': (180, 219, 210, 1),
'Eco2.User': (216, 200, 82, 1),
'Edge.Cuts': (208, 210, 205, 1),
'Margin': (255, 38, 226, 1),
'B.CrtYd': (38, 233, 255, 1),
'F.CrtYd': (255, 38, 226, 1),
'B.Fab': (88, 93, 132, 1),
'F.Fab': (175, 175, 175, 1),
'User.1': (194, 194, 194, 1),
'User.2': (89, 148, 220, 1),
'User.3': (180, 219, 210, 1),
'User.4': (216, 200, 82, 1),
'User.5': (194, 194, 194, 1),
'User.6': (89, 148, 220, 1),
'User.7': (180, 219, 210, 1),
'User.8': (216, 200, 82, 1),
'User.9': (232, 178, 167, 1),
}
KICAD_DRILL_COLORS = {
('drill', 'pth'): (194, 194, 0, 1),
('drill', 'npth'): (26, 196, 210, 1),
('drill', 'via'): (227, 183, 46, 1),
}

View file

@ -565,7 +565,7 @@ class Arc(GraphicObject):
@classmethod
def from_circle(kls, cx, cy, r, aperture, unit=MM):
return kls(cx-r, cy, cx-r, cy, r, 0, aperture=aperture, unit=MM)
return kls(cx-r, cy, cx-r, cy, r, 0, aperture=aperture, clockwise=True, unit=MM)
def _offset(self, dx, dy):
self.x1 += dx
@ -681,7 +681,7 @@ class Arc(GraphicObject):
max_error = min(max_error, r*0.4588038998538031)
elif max_error >= r:
return [Line(*self.p1, *self.p2, aperture=self.aperture, polarity_dark=self.polarity_dark)]
return [Line(*self.p1, *self.p2, aperture=self.aperture, polarity_dark=self.polarity_dark, unit=self.unit)]
# see https://www.mathopenref.com/sagitta.html
l = math.sqrt(r**2 - (r - max_error)**2)
@ -696,7 +696,7 @@ class Arc(GraphicObject):
cx, cy = self.center
points = [ rotate_point(self.x1, self.y1, i*angle, cx, cy) for i in range(num_segments + 1) ]
return [ Line(*p1, *p2, aperture=self.aperture, polarity_dark=self.polarity_dark)
return [ Line(*p1, *p2, aperture=self.aperture, polarity_dark=self.polarity_dark, unit=self.unit)
for p1, p2 in zip(points[0::], points[1::]) ]
def _rotate(self, rotation, cx=0, cy=0):

View file

@ -29,6 +29,7 @@ import itertools
from collections import namedtuple
from pathlib import Path
from zipfile import ZipFile, is_zipfile
from collections import defaultdict
import tempfile
from .excellon import ExcellonFile, parse_allegro_ncparam, parse_allegro_logfile
@ -289,13 +290,23 @@ class LayerStack:
:py:obj:`"altium"`
"""
def __init__(self, graphic_layers=None, drill_pth=None, drill_npth=None, drill_layers=(), netlist=None, board_name=None, original_path=None, was_zipped=False, generator=None):
def __init__(self, graphic_layers=None, drill_pth=None, drill_npth=None, drill_layers=(), netlist=None, board_name=None, original_path=None, was_zipped=False, generator=None, courtyard=False, fabrication=False):
if not drill_layers and (graphic_layers, drill_pth, drill_npth) == (None, None, None):
graphic_layers = {tuple(layer.split()): GerberFile()
for layer in ('top paste', 'top silk', 'top mask', 'top copper',
'bottom copper', 'bottom mask', 'bottom silk', 'bottom paste',
'mechanical outline')}
if courtyard:
graphic_layers = {('top', 'courtyard'): GerberFile(),
**graphic_layers,
('bottom', 'courtyard'): GerberFile()}
if fabrication:
graphic_layers = {('top', 'fabrication'): GerberFile(),
**graphic_layers,
('bottom', 'fabrication'): GerberFile()}
drill_pth = ExcellonFile()
drill_npth = ExcellonFile()
@ -679,7 +690,7 @@ class LayerStack:
def __repr__(self):
return str(self)
def to_svg(self, margin=0, arg_unit=MM, svg_unit=MM, force_bounds=None, tag=Tag):
def to_svg(self, margin=0, arg_unit=MM, svg_unit=MM, force_bounds=None, color_map=None, tag=Tag):
""" Convert this layer stack to a plain SVG string. This is intended for use cases where the resulting SVG will
be processed by other tools, and thus styling with colors or extra markup like Inkscape layer information are
unwanted. If you want to instead generate a nice-looking preview image for display or graphical editing in tools
@ -706,13 +717,28 @@ class LayerStack:
stroke_attrs = {'stroke_linejoin': 'round', 'stroke_linecap': 'round'}
if color_map is None:
color_map = default_dict(lambda: 'black')
tags = []
for (side, use), layer in self.graphic_layers.items():
tags.append(tag('g', list(layer.svg_objects(svg_unit=svg_unit, fg='black', bg="white", tag=Tag)),
for (side, use), layer in reversed(self.graphic_layers.items()):
fg = color_map[(side, use)]
tags.append(tag('g', list(layer.svg_objects(svg_unit=svg_unit, fg=fg, bg="white", tag=Tag)),
**stroke_attrs, id=f'l-{side}-{use}'))
for i, layer in enumerate(self.drill_layers):
tags.append(tag('g', list(layer.svg_objects(svg_unit=svg_unit, fg='black', bg="white", tag=Tag)),
if self.drill_pth:
fg = color_map[('drill', 'pth')]
tags.append(tag('g', list(self.drill_pth.svg_objects(svg_unit=svg_unit, fg=fg, bg="white", tag=Tag)),
**stroke_attrs, id=f'l-drill-pth'))
if self.drill_npth:
fg = color_map[('drill', 'npth')]
tags.append(tag('g', list(self.drill_npth.svg_objects(svg_unit=svg_unit, fg=fg, bg="white", tag=Tag)),
**stroke_attrs, id=f'l-drill-npth'))
for i, layer in enumerate(self._drill_layers):
fg = color_map[('drill', 'unknown')]
tags.append(tag('g', list(layer.svg_objects(svg_unit=svg_unit, fg=fg, bg="white", tag=Tag)),
**stroke_attrs, id=f'l-drill-{i}'))
return setup_svg(tags, bounds, margin=margin, arg_unit=arg_unit, svg_unit=svg_unit, tag=tag)
@ -992,6 +1018,20 @@ class LayerStack:
return self.copper_layers[index][1]
def __setitem__(self, index, value):
if isinstance(index, str):
side, _, use = index.partition(' ')
self.graphic_layers[(side, use)] = value
elif isinstance(index, tuple):
self.graphic_layers[index] = value
else:
raise IndexError('Layer {index} not found. Valid layer indices are "{side} {use}" strings or (side, use) tuples.')
def add_layer(self, index):
self[index] = GerberFile()
@property
def copper_layers(self):
""" Return all copper layers of this board as a list of ((side, use), layer) tuples. Returns an empty list if

View file

@ -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)

View file

@ -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

View file

@ -292,6 +292,10 @@ class Tag:
own implementation by passing a ``tag`` parameter. """
def __init__(self, name, children=None, root=False, **attrs):
if (fill := attrs.get('fill')) and isinstance(fill, tuple):
attrs['fill'], attrs['fill-opacity'] = fill
if (stroke := attrs.get('stroke')) and isinstance(stroke, tuple):
attrs['stroke'], attrs['stroke-opacity'] = stroke
self.name, self.attrs = name, attrs
self.children = children or []
self.root = root