From c29802c2b941150b92720124aa2512da70aeff3b Mon Sep 17 00:00:00 2001 From: jaseg Date: Wed, 14 Jun 2023 17:29:43 +0200 Subject: Add missing file for initial PCB support --- gerbonara/cad/kicad/pcb.py | 348 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 348 insertions(+) create mode 100644 gerbonara/cad/kicad/pcb.py (limited to 'gerbonara/cad') diff --git a/gerbonara/cad/kicad/pcb.py b/gerbonara/cad/kicad/pcb.py new file mode 100644 index 0000000..bb608bb --- /dev/null +++ b/gerbonara/cad/kicad/pcb.py @@ -0,0 +1,348 @@ +""" +Library for handling KiCad's PCB files (`*.kicad_mod`). +""" + +from pathlib import Path +from dataclasses import field +from itertools import chain +import fnmatch + +from .sexp import * +from .base_types import * +from .primitives import * +from .footprints import Footprint +from . import graphical_primitives as gr + +from ..primitives import Positioned + +from ... import graphic_primitives as gp +from ... import graphic_objects as go +from ... import apertures as ap +from ...layers import LayerStack +from ...newstroke import Newstroke +from ...utils import MM + + +@sexp_type('general') +class GeneralSection: + thickness: Named(float) = 1.60 + + +@sexp_type('paper') +class PageSettings: + page_format: str = 'A4' + width: float = None + height: float = None + portrait: bool = False + + +@sexp_type('layers') +class LayerSettings: + index: int = None + canonical_name: str = None + layer_type: AtomChoice(Atom.jumper, Atom.mixed, Atom.power, Atom.signal, Atom.user) = Atom.signal + custom_name: str = None + + +@sexp_type('layer') +class LayerStackupSettings: + dielectric: bool = False + name: str = None + index: int = None + layer_type: Named(str, name='type') = '' + color: Color = None + thickness: Named(float) = None + material: Named(str) = None + epsilon_r: Named(float) = None + loss_tangent: Named(float) = None + + +@sexp_type('stackup') +class StackupSettings: + layers: List(LayerStackupSettings) = field(default_factory=list) + copper_finish: Named(str) = None + dielectric_constraints: Named(YesNoAtom()) = None + edge_connector: Named(AtomChoice(Atom.yes, Atom.bevelled)) = None + castellated_pads: Named(bool) = None + edge_plating: Named(bool) = None + + +TFBool = YesNoAtom(yes=Atom.true, no=Atom.false) + +@sexp_type('pcbplotparams') +class ExportSettings: + layerselection: Named(Atom) = 0 + plot_on_all_layers_selection: Named(Atom) = 0 + disableapertmacros: Named(TFBool) = False + usegerberextensions: Named(TFBool) = True + usegerberattributes: Named(TFBool) = True + usegerberadvancedattributes: Named(TFBool) = True + creategerberjobfile: Named(TFBool) = True + dashed_line_dash_ratio: Named(float) = 12.0 + dashed_line_gap_ratio: Named(float) = 3.0 + svguseinch: Named(TFBool) = False + svgprecision: Named(float) = 4 + excludeedgelayer: Named(TFBool) = False + plotframeref: Named(TFBool) = False + viasonmask: Named(TFBool) = False + mode: Named(int) = 1 + useauxorigin: Named(TFBool) = False + hpglpennumber: Named(int) = 1 + hpglpenspeed: Named(int) = 20 + hpglpendiameter: Named(float) = 15.0 + pdf_front_fp_property_popups: Named(TFBool) = True + pdf_back_fp_property_popups: Named(TFBool) = True + dxfpolygonmode: Named(TFBool) = True + dxfimperialunits: Named(TFBool) = False + dxfusepcbnewfont: Named(TFBool) = True + psnegative: Named(TFBool) = False + psa4output: Named(TFBool) = False + plotreference: Named(TFBool) = True + plotvalue: Named(TFBool) = True + plotinvisibletext: Named(TFBool) = False + sketchpadsonfab: Named(TFBool) = False + subtractmaskfromsilk: Named(TFBool) = False + outputformat: Named(int) = 1 + mirror: Named(TFBool) = False + drillshape: Named(int) = 0 + scaleselection: Named(int) = 1 + outputdirectory: Named(str) = "gerber" + + +@sexp_type('setup') +class BoardSetup: + stackup: OmitDefault(StackupSettings) = field(default_factory=StackupSettings) + pad_to_mask_clearance: Named(float) = None + solder_mask_min_width: Named(float) = None + pad_to_past_clearance: Named(float) = None + pad_to_paste_clearance_ratio: Named(float) = None + aux_axis_origin: Rename(XYCoord) = None + grid_origin: Rename(XYCoord) = None + export_settings: ExportSettings = field(default_factory=ExportSettings) + + +@sexp_type('net') +class Net: + index: int = 0 + name: str = '' + + +@sexp_type('image') +class Image: + at: AtPos = field(default_factory=AtPos) + scale: Named(float) = None + layer: Named(str) = None + uuid: UUID = field(default_factory=UUID) + data: str = '' + + +@sexp_type('segment') +class TrackSegment: + start: Rename(XYCoord) = field(default_factory=XYCoord) + end: Rename(XYCoord) = field(default_factory=XYCoord) + width: Named(float) = 0.5 + layer: Named(str) = 'F.Cu' + locked: bool = False + net: Named(int) = 0 + tstamp: Timestamp = field(default_factory=Timestamp) + + def render(self, variables=None, cache=None): + if not self.width: + return + + 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('arc') +class TrackArc: + start: Rename(XYCoord) = field(default_factory=XYCoord) + mid: Rename(XYCoord) = field(default_factory=XYCoord) + end: Rename(XYCoord) = field(default_factory=XYCoord) + width: Named(float) = 0.5 + layer: Named(str) = 'F.Cu' + locked: bool = False + net: Named(int) = 0 + tstamp: Timestamp = field(default_factory=Timestamp) + + def render(self, variables=None, cache=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=aperture, clockwise=True, unit=MM) + + +@sexp_type('via') +class Via: + via_type: AtomChoice(Atom.blind, Atom.micro) = None + locked: bool = False + at: AtPos = field(default_factory=AtPos) + size: Named(float) = 0.8 + drill: Named(float) = 0.4 + layers: Named(Array(str)) = field(default_factory=list) + remove_unused_layers: bool = False + keep_end_layers: bool = False + free: Wrap(Flag()) = False + net: Named(int) = 0 + tstamp: Timestamp = field(default_factory=Timestamp) + + def render_drill(self): + aperture = ap.ExcellonTool(self.drill, plated=True, unit=MM) + yield go.Flash(self.at.x, self.at.y, aperture=aperture, unit=MM) + + def render(self, variables=None, cache=None): + aperture = ap.CircleAperture(self.size, unit=MM) + yield go.Flash(self.at.x, self.at.y, aperture, unit=MM) + + +SUPPORTED_FILE_FORMAT_VERSIONS = [20210108, 20211014, 20221018, 20230517] +@sexp_type('kicad_pcb') +class Board: + _version: Named(int, name='version') = 20210108 + generator: Named(Atom) = Atom.gerbonara + general: GeneralSection = field(default_factory=GeneralSection) + page: PageSettings = field(default_factory=PageSettings) + layers: Named(Array(LayerSettings)) = field(default_factory=list) + setup: BoardSetup = field(default_factory=BoardSetup) + properties: List(Property) = field(default_factory=list) + nets: List(Net) = field(default_factory=list) + footprints: List(Footprint) = field(default_factory=list) + # Graphical elements + texts: List(gr.Text) = field(default_factory=list) + text_boxes: List(gr.TextBox) = field(default_factory=list) + lines: List(gr.Line) = field(default_factory=list) + rectangles: List(gr.Rectangle) = field(default_factory=list) + circles: List(gr.Circle) = field(default_factory=list) + arcs: List(gr.Arc) = field(default_factory=list) + polygons: List(gr.Polygon) = field(default_factory=list) + curves: List(gr.Curve) = field(default_factory=list) + dimensions: List(gr.Dimension) = field(default_factory=list) + images: List(Image) = field(default_factory=list) + # Tracks + track_segments: List(TrackSegment) = field(default_factory=list) + vias: List(Via) = field(default_factory=list) + track_arcs: List(TrackArc) = field(default_factory=list) + # Other stuff + zones: List(Zone) = field(default_factory=list) + groups: List(Group) = field(default_factory=list) + + _ : SEXP_END = None + original_filename: str = None + _bounding_box: tuple = None + + @property + def version(self): + return self._version + + @version.setter + def version(self, value): + if value not in SUPPORTED_FILE_FORMAT_VERSIONS: + raise FormatError(f'File format version {value} is not supported. Supported versions are {", ".join(map(str, SUPPORTED_FILE_FORMAT_VERSIONS))}.') + + def write(self, filename=None): + with open(filename or self.original_filename, 'w') as f: + f.write(build_sexp(sexp(self))) + + @classmethod + def open(kls, pcb_file, *args, **kwargs): + return kls.load(Path(pcb_file).read_text(), *args, **kwargs, original_filename=pcb_file) + + @classmethod + def load(kls, data, *args, **kwargs): + return kls.parse(data, *args, **kwargs) + + @property + def single_sided(self): + raise NotImplementedError() + +# FIXME vvv + def graphic_objects(self, text=False, images=False): + return chain( + (self.texts if text else []), + (self.text_boxes if text else []), + self.lines, + self.rectangles, + self.circles, + self.arcs, + self.polygons, + self.curves, + (self.dimensions if text else []), + (self.images if images else [])) + + + def tracks(self, vias=True): + return chain(self.track_segments, self.track_arcs, (self.vias if vias else [])) + + + def objects(self, vias=True, text=False, images=False): + return chain(self.graphic_objects(text=text, images=images), self.tracks(vias=vias)) + + + def render(self, layer_stack, layer_map, x=0, y=0, rotation=0, text=False, flip=False, variables={}, cache=None): + for obj in self.objects(images=False, vias=False, text=text): + if not (layer := layer_map.get(obj.layer)): + continue + + for fe in obj.render(variables=variables): + fe.rotate(rotation) + fe.offset(x, y, MM) + layer_stack[layer].objects.append(fe) + + for obj in self.vias: + for glob in obj.layers or []: + for layer in fnmatch.filter(layer_map, glob): + for fe in obj.render(cache=cache): + fe.rotate(rotation) + fe.offset(x, y, MM) + fe.aperture = fe.aperture.rotated(rotation) + layer_stack[layer_map[layer]].objects.append(fe) + + for fe in obj.render_drill(): + fe.rotate(rotation) + fe.offset(x, y, MM) + layer_stack.drill_pth.append(fe) + + def bounding_box(self, unit=MM): + 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, flip=False, text=False, variables={}) + self._bounding_box = stack.bounding_box(unit) + return self._bounding_box + + +@dataclass +class BoardInstance(Positioned): + sexp: Board = None + variables: dict = field(default_factory=lambda: {}) + + def render(self, layer_stack, cache=None): + x, y, rotation, flip = self.abs_pos + x, y = MM(x, self.unit), MM(y, self.unit) + + variables = dict(self.variables) + + layer_map = {kc_id: gn_id for kc_id, gn_id in LAYER_MAP_K2G.items() if gn_id in layer_stack} + + self.sexp.render(layer_stack, layer_map, + x=x, y=y, rotation=rotation, + flip=flip, + 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 + fp = Board.open(sys.argv[1]) + stack = LayerStack() + BoardInstance(0, 0, fp, unit=MM).render(stack) + print(stack.to_pretty_svg()) + stack.save_to_directory('/tmp/testdir') + -- cgit