Add basic KiCad PCB file format support

This commit is contained in:
jaseg 2023-06-12 18:39:33 +02:00
parent 03f2ec0a30
commit 3561817903
6 changed files with 162 additions and 57 deletions

View file

@ -9,6 +9,42 @@ from contextlib import contextmanager
from itertools import cycle
LAYER_MAP_K2G = {
'F.Cu': ('top', 'copper'),
'B.Cu': ('bottom', 'copper'),
'F.SilkS': ('top', 'silk'),
'B.SilkS': ('bottom', 'silk'),
'F.Paste': ('top', 'paste'),
'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'),
'B.Adhes': ('bottom', 'adhesive'),
'F.Adhes': ('top', 'adhesive'),
'Dwgs.User': ('mechanical', 'drawings'),
'Cmts.User': ('mechanical', 'comments'),
'Edge.Cuts': ('mechanical', 'outline'),
}
LAYER_MAP_G2K = {v: k for k, v in LAYER_MAP_K2G.items()}
@sexp_type('group')
class Group:
name: str = ""
id: Named(str) = ""
members: Named(List(str)) = field(default_factory=list)
@sexp_type('property')
class Property:
key: str = ''
value: str = ''
@sexp_type('color')
class Color:
r: int = None
@ -186,6 +222,19 @@ class Timestamp:
def bump(self):
self.value = uuid.uuid4()
@sexp_type('uuid')
class UUID:
value: str = field(default_factory=uuid.uuid4)
def __after_parse__(self, parent):
self.value = str(self.value)
def before_sexp(self):
self.value = Atom(str(self.value))
def bump(self):
self.value = uuid.uuid4()
@sexp_type('tedit')
class EditTime:
value: str = field(default_factory=time.time)

View file

@ -30,12 +30,6 @@ from ...aperture_macros.parse import GenericMacros, ApertureMacro
from ...aperture_macros import primitive as amp
@sexp_type('property')
class Property:
key: str = ''
value: str = ''
@sexp_type('attr')
class Attribute:
type: AtomChoice(Atom.smd, Atom.through_hole) = None
@ -166,6 +160,7 @@ class Circle:
for x1, y1, x2, y2 in dasher:
yield go.Line(x1, y1, x2, y2, aperture=aperture, unit=MM)
@sexp_type('fp_arc')
class Arc:
start: Rename(XYCoord) = None
@ -372,6 +367,7 @@ class Pad:
tstamp: Timestamp = None
pin_function: Named(str) = None
pintype: Named(str) = None
pinfunction: Named(str) = None
die_length: Named(float) = None
solder_mask_margin: Named(float) = None
solder_paste_margin: Named(float) = None
@ -543,13 +539,6 @@ class Pad:
yield go.Flash(self.at.x, self.at.y, aperture=aperture, unit=MM)
@sexp_type('group')
class Group:
name: str = ""
id: Named(str) = ""
members: Named(List(str)) = field(default_factory=list)
@sexp_type('model')
class Model:
name: str = ''
@ -564,7 +553,7 @@ SUPPORTED_FILE_FORMAT_VERSIONS = [20210108, 20211014, 20221018]
class Footprint:
name: str = None
_version: Named(int, name='version') = 20210108
generator: Named(Atom) = Atom.kicad_library_utils
generator: Named(Atom) = Atom.gerbonara
locked: Flag() = False
placed: Flag() = False
layer: Named(str) = 'F.Cu'
@ -655,11 +644,10 @@ class Footprint:
(self.dimensions if text else []),
(self.pads if pads else []))
def render(self, layer_stack, layer_map, x=0, y=0, rotation=0, text=False, side=None, variables={}, cache=None):
def render(self, layer_stack, layer_map, x=0, y=0, rotation=0, text=False, flip=False, variables={}, cache=None):
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=text):
if not (layer := layer_map.get(obj.layer)):
@ -718,35 +706,11 @@ class Footprint:
if not self._bounding_box:
stack = LayerStack()
layer_map = {kc_id: gn_id for kc_id, gn_id in LAYER_MAP_K2G.items() if gn_id in stack}
self.render(stack, layer_map, x=0, y=0, rotation=0, side='top', text=False, variables={})
self.render(stack, layer_map, x=0, y=0, rotation=0, flip=False, text=False, variables={})
self._bounding_box = stack.bounding_box(unit)
return self._bounding_box
LAYER_MAP_K2G = {
'F.Cu': ('top', 'copper'),
'B.Cu': ('bottom', 'copper'),
'F.SilkS': ('top', 'silk'),
'B.SilkS': ('bottom', 'silk'),
'F.Paste': ('top', 'paste'),
'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'),
'B.Adhes': ('bottom', 'adhesive'),
'F.Adhes': ('top', 'adhesive'),
'Dwgs.User': ('mechanical', 'drawings'),
'Cmts.User': ('mechanical', 'comments'),
'Edge.Cuts': ('mechanical', 'outline'),
}
LAYER_MAP_G2K = {v: k for k, v in LAYER_MAP_K2G.items()}
@dataclass
class FootprintInstance(Positioned):
sexp: Footprint = None
@ -756,7 +720,7 @@ class FootprintInstance(Positioned):
variables: dict = field(default_factory=lambda: {})
def render(self, layer_stack, cache=None):
x, y, rotation = self.abs_pos
x, y, rotation, flip= self.abs_pos
x, y = MM(x, self.unit), MM(y, self.unit)
variables = dict(self.variables)
@ -771,13 +735,14 @@ class FootprintInstance(Positioned):
self.sexp.render(layer_stack, layer_map,
x=x, y=y, rotation=rotation,
side=self.side,
flip=flip,
text=(not self.hide_text),
variables=variables, cache=cache)
def bounding_box(self, unit=MM):
return offset_bounds(self.sexp.bounding_box(unit), unit(self.x, self.unit), unit(self.y, self.unit))
if __name__ == '__main__':
import sys
from ...layers import LayerStack

View file

@ -24,6 +24,7 @@ class Text:
layer: TextLayer = field(default_factory=TextLayer)
tstamp: Timestamp = None
effects: TextEffect = field(default_factory=TextEffect)
render_cache: RenderCache = None
def render(self, variables={}):
if not self.effects or self.effects.hide or not self.effects.font:
@ -107,13 +108,18 @@ class Line:
angle: Named(float) = None # wat
layer: Named(str) = None
width: Named(float) = None
stroke: Stroke = field(default_factory=Stroke)
tstamp: Timestamp = None
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.')
aperture = ap.CircleAperture(self.width, unit=MM)
if self.width:
aperture = ap.CircleAperture(self.width, unit=MM)
else:
aperture = ap.CircleAperture(self.stroke.width, unit=MM)
yield go.Line(self.start.x, self.start.y, self.end.x, self.end.y, aperture=aperture, unit=MM)
@ -179,16 +185,18 @@ class Arc:
end: Rename(XYCoord) = None
layer: Named(str) = None
width: Named(float) = None
stroke: Stroke = field(default_factory=Stroke)
tstamp: Timestamp = None
def render(self, variables=None):
if not self.width:
return
aperture = ap.CircleAperture(self.width, unit=MM),
cx, cy = self.mid.x, self.mid.y
x1, y1 = self.start.x, self.start.y
x2, y2 = self.end.x, self.end.y
yield go.Arc(x1, y1, x2, y2, cx-x1, cy-y1, aperture=ap.CircleAperture(self.width or 0, unit=MM), clockwise=True, unit=MM)
yield go.Arc(x1, y1, x2, y2, cx-x1, cy-y1, aperture=aperture, clockwise=True, unit=MM)
@sexp_type('gr_poly')
@ -228,3 +236,44 @@ class AnnotationBBox:
def render(self, variables=None):
return []
@sexp_type('format')
class DimensionFormat:
prefix: Named(str) = None
suffix: Named(str) = None
units: Named(int) = 2
units_format: Named(int) = 1
precision: Named(int) = 7
override_value: Named(str) = None
suppress_zeros: bool = False
@sexp_type('style')
class DimensionStyle:
thickness: Named(float) = 0.1
arrow_length: Named(float) = 1.27
text_position_mode: Named(int) = 0
extension_height: Named(float) = None
text_frame: Named(float) = None
extension_offset: Named(float) = None
keep_text_aligned: bool = False
@sexp_type('dimension')
class Dimension:
locked: bool = False
dimension_type: Named(AtomChoice(Atom.aligned, Atom.leader, Atom.center, Atom.orthogonal, Atom.radial), name='type') = Atom.aligned
layer: Named(str) = 'Dwgs.User'
tstamp: Timestamp = field(default_factory=Timestamp)
pts: Named(Array(XYCoord)) = field(default_factory=list)
height: Named(float) = None
orientation: Named(int) = None
leader_length: Named(float) = None
gr_text: Text = None
dimension_format: OmitDefault(DimensionFormat) = field(default_factory=DimensionFormat)
dimension_style: OmitDefault(DimensionStyle) = field(default_factory=DimensionStyle)
def render(self, variables=None):
raise NotImplementedError('Dimension rendering is not yet supported. Please raise an issue.')

View file

@ -39,8 +39,8 @@ class ZoneFill:
thermal_gap: Named(float) = 0.508
thermal_bridge_width: Named(float) = 0.508
smoothing: ZoneSmoothing = None
island_removal_node: Named(int) = None
islan_area_min: Named(float) = None
island_removal_mode: Named(int) = None
island_area_min: Named(float) = None
hatch_thickness: Named(float) = None
hatch_gap: Named(float) = None
hatch_orientation: Named(int) = None
@ -53,6 +53,7 @@ class ZoneFill:
@sexp_type('filled_polygon')
class FillPolygon:
layer: Named(str) = ""
island: Wrap(Flag()) = False
pts: PointList = field(default_factory=PointList)

View file

@ -64,6 +64,7 @@ def sexp(t, v):
def map_sexp(t, v, parent=None):
if t is not Atom and hasattr(t, '__map__'):
return t.__map__(v, parent=parent)
elif t in (int, float, str, Atom):
v, = v
if not isinstance(v, t):
@ -73,9 +74,11 @@ def map_sexp(t, v, parent=None):
else:
raise TypeError(f'Cannot map s-expression value {v} of type {type(v)} to Python type {t}')
return v
elif isinstance(t, list):
t, = t
return [map_sexp(t, elem, parent=parent) for elem in v]
else:
raise TypeError(f'Python type {t} has no defined s-expression deserialization')

View file

@ -173,6 +173,44 @@ class Positioned:
return True
# The dataclass API is slightly idiotic here, so we have to duplicate the entire thing.
@dataclass(frozen=True)
class FrozenPositioned:
x: float
y: float
_: KW_ONLY
rotation: float = 0.0
flip: bool = False
unit: LengthUnit = MM
parent: object = None
@property
def abs_pos(self):
if self.parent is None:
px, py, pa, pf = 0, 0, 0, False
else:
px, py, pa, pf = self.parent.abs_pos
return self.x+px, self.y+py, self.rotation+pa, (bool(self.flip) != bool(pf))
def bounding_box(self, unit=MM):
stack = LayerStack()
self.render(stack)
objects = chain(*(l.objects for l in stack.graphic_layers.values()),
stack.drill_pth.objects, stack.drill_npth.objects)
objects = list(objects)
#print('foo', type(self).__name__,
# [(type(obj).__name__, [prim.bounding_box() for prim in obj.to_primitives(unit)]) for obj in objects], file=sys.stderr)
return sum_bounds(prim.bounding_box() for obj in objects for prim in obj.to_primitives(unit))
def overlaps(self, bbox, unit=MM):
return bbox_intersect(self.bounding_box(unit), bbox)
@property
def single_sided(self):
return True
@dataclass
class Graphics(Positioned):
top_copper: list = field(default_factory=list)
@ -336,15 +374,6 @@ class Text(Positioned):
return (self.x+x0, self.y+y0), (self.x+x0+approx_w, self.y+y0+approx_h)
@dataclass
class Pad(Positioned):
pad_stack: PadStack
@property
def single_sided(self):
return self.pad_stack.single_sided
@dataclass(frozen=True, slots=True)
class PadStackAperture:
aperture: Aperture
@ -493,7 +522,7 @@ class ThroughViaStack(PadStack):
@dataclass(frozen=True, slots=True)
class Via(Positioned):
class Via(FrozenPositioned):
pad_stack: PadStack
def render(self, layer_stack, cache=None):
@ -505,6 +534,15 @@ class Via(Positioned):
return kls(x, y, ThroughViaStack(hole, dia, tented, unit=unit), unit=unit)
@dataclass
class Pad(Positioned):
pad_stack: PadStack
@property
def single_sided(self):
return self.pad_stack.single_sided
@dataclass
class Trace:
width: float