From a93d118773818fbd47af4965d7b37e1b10bdf9b6 Mon Sep 17 00:00:00 2001 From: jaseg Date: Sat, 22 Apr 2023 17:16:20 +0200 Subject: kicad unit tests WIP --- gerbonara/cad/kicad/footprints.py | 81 ++++++++++++------ gerbonara/cad/kicad/graphical_primitives.py | 65 ++++++++------- gerbonara/cad/kicad/layer_colors.py | 70 ++++++++++++++++ gerbonara/graphic_objects.py | 6 +- gerbonara/layers.py | 52 ++++++++++-- gerbonara/tests/image_support.py | 29 ++++++- gerbonara/tests/test_kicad_footprints.py | 122 +++++++++++++++++++++++++++- gerbonara/utils.py | 4 + 8 files changed, 360 insertions(+), 69 deletions(-) create mode 100644 gerbonara/cad/kicad/layer_colors.py (limited to 'gerbonara') 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 -- cgit