kicad unit tests WIP
This commit is contained in:
parent
5ce88e4d1b
commit
a93d118773
8 changed files with 360 additions and 69 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
|
||||
|
|
|
|||
70
gerbonara/cad/kicad/layer_colors.py
Normal file
70
gerbonara/cad/kicad/layer_colors.py
Normal 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),
|
||||
}
|
||||
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue