From 35618179036409c71c87746c32a27238260a02a4 Mon Sep 17 00:00:00 2001 From: jaseg Date: Mon, 12 Jun 2023 18:39:33 +0200 Subject: Add basic KiCad PCB file format support --- gerbonara/cad/kicad/base_types.py | 49 ++++++++++++++++++++++++ gerbonara/cad/kicad/footprints.py | 51 ++++--------------------- gerbonara/cad/kicad/graphical_primitives.py | 53 +++++++++++++++++++++++++- gerbonara/cad/kicad/primitives.py | 5 ++- gerbonara/cad/kicad/sexp_mapper.py | 3 ++ gerbonara/cad/primitives.py | 58 ++++++++++++++++++++++++----- 6 files changed, 162 insertions(+), 57 deletions(-) (limited to 'gerbonara/cad') diff --git a/gerbonara/cad/kicad/base_types.py b/gerbonara/cad/kicad/base_types.py index 8f3036c..6bb5912 100644 --- a/gerbonara/cad/kicad/base_types.py +++ b/gerbonara/cad/kicad/base_types.py @@ -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) diff --git a/gerbonara/cad/kicad/footprints.py b/gerbonara/cad/kicad/footprints.py index ece7b53..1be4d3c 100644 --- a/gerbonara/cad/kicad/footprints.py +++ b/gerbonara/cad/kicad/footprints.py @@ -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 diff --git a/gerbonara/cad/kicad/graphical_primitives.py b/gerbonara/cad/kicad/graphical_primitives.py index 171fa76..f078268 100644 --- a/gerbonara/cad/kicad/graphical_primitives.py +++ b/gerbonara/cad/kicad/graphical_primitives.py @@ -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.') + + diff --git a/gerbonara/cad/kicad/primitives.py b/gerbonara/cad/kicad/primitives.py index 30ae611..3e78467 100644 --- a/gerbonara/cad/kicad/primitives.py +++ b/gerbonara/cad/kicad/primitives.py @@ -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) diff --git a/gerbonara/cad/kicad/sexp_mapper.py b/gerbonara/cad/kicad/sexp_mapper.py index 1d0f942..fa5f702 100644 --- a/gerbonara/cad/kicad/sexp_mapper.py +++ b/gerbonara/cad/kicad/sexp_mapper.py @@ -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') diff --git a/gerbonara/cad/primitives.py b/gerbonara/cad/primitives.py index 472cb32..2b7c209 100644 --- a/gerbonara/cad/primitives.py +++ b/gerbonara/cad/primitives.py @@ -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 -- cgit