summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorjaseg <git@jaseg.de>2023-04-22 17:16:20 +0200
committerjaseg <git@jaseg.de>2023-04-22 17:16:20 +0200
commita93d118773818fbd47af4965d7b37e1b10bdf9b6 (patch)
tree528a06ac8154dfa70caa8714e7aa5ea62a51dd9f
parent5ce88e4d1b06dcc846c94ec614fb00f64e85c125 (diff)
downloadgerbonara-a93d118773818fbd47af4965d7b37e1b10bdf9b6.tar.gz
gerbonara-a93d118773818fbd47af4965d7b37e1b10bdf9b6.tar.bz2
gerbonara-a93d118773818fbd47af4965d7b37e1b10bdf9b6.zip
kicad unit tests WIP
-rw-r--r--gerbonara/cad/kicad/footprints.py81
-rw-r--r--gerbonara/cad/kicad/graphical_primitives.py65
-rw-r--r--gerbonara/cad/kicad/layer_colors.py70
-rw-r--r--gerbonara/graphic_objects.py6
-rw-r--r--gerbonara/layers.py52
-rw-r--r--gerbonara/tests/image_support.py29
-rw-r--r--gerbonara/tests/test_kicad_footprints.py122
-rw-r--r--gerbonara/utils.py4
8 files changed, 360 insertions, 69 deletions
diff --git a/gerbonara/cad/kicad/footprints.py b/gerbonara/cad/kicad/footprints.py
index 8377961..1178c79 100644
--- a/gerbonara/cad/kicad/footprints.py
+++ b/gerbonara/cad/kicad/footprints.py
@@ -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
diff --git a/gerbonara/cad/kicad/graphical_primitives.py b/gerbonara/cad/kicad/graphical_primitives.py
index 0760342..bc1bfe7 100644
--- a/gerbonara/cad/kicad/graphical_primitives.py
+++ b/gerbonara/cad/kicad/graphical_primitives.py
@@ -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 []
diff --git a/gerbonara/cad/kicad/layer_colors.py b/gerbonara/cad/kicad/layer_colors.py
new file mode 100644
index 0000000..b4caa2d
--- /dev/null
+++ b/gerbonara/cad/kicad/layer_colors.py
@@ -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),
+}
+
diff --git a/gerbonara/graphic_objects.py b/gerbonara/graphic_objects.py
index 0d28045..bcf94ce 100644
--- a/gerbonara/graphic_objects.py
+++ b/gerbonara/graphic_objects.py
@@ -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):
diff --git a/gerbonara/layers.py b/gerbonara/layers.py
index bb2d635..76ae12e 100644
--- a/gerbonara/layers.py
+++ b/gerbonara/layers.py
@@ -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
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
+
diff --git a/gerbonara/utils.py b/gerbonara/utils.py
index e33a7bf..933b3ca 100644
--- a/gerbonara/utils.py
+++ b/gerbonara/utils.py
@@ -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