""" 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 * from .primitives import * 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, rotate_point, offset_bounds, sum_bounds 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 board_only: Flag() = False exclude_from_pos_files: Flag() = False exclude_from_bom: Flag() = False @sexp_type('fp_text') class Text: type: AtomChoice(Atom.reference, Atom.value, Atom.user) = Atom.user text: str = "" at: AtPos = field(default_factory=AtPos) unlocked: Flag() = False layer: Named(str) = None hide: Flag() = False effects: TextEffect = field(default_factory=TextEffect) tstamp: Timestamp = None def render(self, variables={}, cache=None): if self.hide: # why return yield from gr.Text.render(self, variables=variables) @sexp_type('fp_text_box') class TextBox: locked: Flag() = False text: str = None start: Rename(XYCoord) = None end: Named(XYCoord) = None pts: PointList = None angle: Named(float) = 0.0 layer: Named(str) = None tstamp: Timestamp = None effects: TextEffect = field(default_factory=TextEffect) stroke: Stroke = field(default_factory=Stroke) render_cache: RenderCache = None def render(self, variables={}, cache=None): yield from gr.TextBox.render(self, variables=variables) @sexp_type('fp_line') class Line: start: Rename(XYCoord) = None end: Rename(XYCoord) = None layer: Named(str) = None width: Named(float) = None stroke: Stroke = None locked: Flag() = False tstamp: Timestamp = None def render(self, variables=None, cache=None): dasher = Dasher(self) dasher.move(self.start.x, self.start.y) dasher.line(self.end.x, self.end.y) for x1, y1, x2, y2 in dasher: yield go.Line(x1, y1, x2, y2, aperture=ap.CircleAperture(dasher.width, unit=MM), unit=MM) @sexp_type('fp_rect') class Rectangle: start: Rename(XYCoord) = None end: Rename(XYCoord) = None layer: Named(str) = None width: Named(float) = None stroke: Stroke = None fill: Named(AtomChoice(Atom.solid, Atom.none)) = None locked: Flag() = False tstamp: Timestamp = None def render(self, variables=None, cache=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) y1, y2 = min(y1, y2), max(y1, y2) w, h = x2-x1, y2-y1 if self.fill == Atom.solid: yield go.Region.from_rectangle(x1, y1, w, h, unit=MM) dasher = Dasher(self) dasher.move(x1, y1) dasher.line(x1, y2) dasher.line(x2, y2) dasher.line(x2, y1) dasher.close() aperture = ap.CircleAperture(dasher.width, unit=MM) for x1, y1, x2, y2 in dasher: yield go.Line(x1, y1, x2, y2, aperture=aperture, unit=MM) @sexp_type('fp_circle') class Circle: center: Rename(XYCoord) = None end: Rename(XYCoord) = None layer: Named(str) = None width: Named(float) = None stroke: Stroke = None fill: Named(AtomChoice(Atom.solid, Atom.none)) = None locked: Flag() = False tstamp: Timestamp = None def render(self, variables=None, cache=None): x, y = self.center.x, self.center.y r = math.dist((x, y), (self.end.x, self.end.y)) # insane 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() if dasher.solid: yield circle else: # pain for line in circle.approximate(): # TODO precision settings dasher.segments.append((line.x1, line.y1, line.x2, line.y2)) aperture = ap.CircleAperture(dasher.width, unit=MM) 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 mid: Rename(XYCoord) = None end: Rename(XYCoord) = None layer: Named(str) = None width: Named(float) = None stroke: Stroke = None locked: Flag() = False tstamp: Timestamp = None def render(self, variables=None, cache=None): mx, my = self.mid.x, self.mid.y x1, y1 = self.start.x, self.start.y x2, y2 = self.end.x, self.end.y dasher = Dasher(self) aperture = ap.CircleAperture(dasher.width, unit=MM) if math.isclose(x1, x2, abs_tol=1e-6) and math.isclose(y1, y2, abs_tol=1e-6): cx = (x1 + mx) / 2 cy = (y1 + my) / 2 arc = go.Arc(x1, y1, x2, y2, cx-x1, cy-y1, clockwise=True, aperture=aperture, unit=MM) if dasher.solid: yield arc else: # use approximation from graphic object arc class for line in arc.approximate(): dasher.segments.append((line.x1, line.y1, line.x2, line.y2)) for line in dasher: yield go.Line(x1, y1, x2, y2, aperture=ap.CircleAperture(dasher.width, unit=MM), unit=MM) else: # https://stackoverflow.com/questions/56224824/how-do-i-find-the-circumcenter-of-the-triangle-using-python-without-external-lib d = 2 * (x1 * (y2 - my) + x2 * (my - y1) + mx * (y1 - y2)) cx = ((x1 * x1 + y1 * y1) * (y2 - my) + (x2 * x2 + y2 * y2) * (my - y1) + (mx * mx + my * my) * (y1 - y2)) / d cy = ((x1 * x1 + y1 * y1) * (mx - x2) + (x2 * x2 + y2 * y2) * (x1 - mx) + (mx * mx + my * my) * (x2 - x1)) / d # KiCad only has clockwise arcs. arc = go.Arc(x1, y1, x2, y2, cx-x1, cy-y1, clockwise=False, aperture=aperture, unit=MM) if dasher.solid: yield arc else: # use approximation from graphic object arc class for line in arc.approximate(): dasher.segments.append((line.x1, line.y1, line.x2, line.y2)) for line in dasher: yield go.Line(x1, y1, x2, y2, aperture=ap.CircleAperture(dasher.width, unit=MM), unit=MM) @sexp_type('fp_poly') class Polygon: pts: PointList = field(default_factory=PointList) layer: Named(str) = None width: Named(float) = None stroke: Stroke = None fill: Named(AtomChoice(Atom.solid, Atom.none)) = None locked: Flag() = False tstamp: Timestamp = None def render(self, variables=None, cache=None): if len(self.pts.xy) < 2: return dasher = Dasher(self) start = self.pts.xy[0] dasher.move(start.x, start.y) for point in self.pts.xy[1:]: dasher.line(point.x, point.y) aperture = ap.CircleAperture(dasher.width, unit=MM) for x1, y1, x2, y2 in dasher: yield go.Line(x1, y1, x2, y2, aperture=aperture, unit=MM) if self.fill == Atom.solid: yield go.Region([(pt.x, pt.y) for pt in self.pts.xy], unit=MM) @sexp_type('fp_curve') class Curve: pts: PointList = field(default_factory=PointList) layer: Named(str) = None width: Named(float) = None stroke: Stroke = None locked: Flag() = False tstamp: Timestamp = None def render(self, variables=None, cache=None): raise NotImplementedError('Bezier rendering is not yet supported. Please raise an issue and provide an example file.') @sexp_type('format') class DimensionFormat: prefix: Named(str) = None suffix: Named(str) = None units: Named(int) = 3 units_format: Named(int) = 0 precision: Named(int) = 3 override_value: Named(str) = None suppress_zeros: Flag() = False @sexp_type('style') class DimensionStyle: thickness: Named(float) = None arrow_length: Named(float) = None text_position_mode: Named(int) = 0 extension_height: Named(float) = None text_frame: Named(int) = 0 extension_offset: Named(str) = None keep_text_aligned: Flag() = False @sexp_type('dimension') class Dimension: locked: Flag() = False type: AtomChoice(Atom.aligned, Atom.leader, Atom.center, Atom.orthogonal, Atom.radial) = None layer: Named(str) = None tstamp: Timestamp = None pts: PointList = field(default_factory=PointList) height: Named(float) = None orientation: Named(int) = 0 leader_length: Named(float) = None gr_text: Named(Text) = None format: DimensionFormat = field(default_factory=DimensionFormat) style: DimensionStyle = field(default_factory=DimensionStyle) def render(self, variables=None, cache=None): raise NotImplementedError() @sexp_type('drill') class Drill: oval: Flag() = False diameter: float = 0 width: float = None offset: Rename(XYCoord) = None @sexp_type('net') class NetDef: number: int = None name: str = None @sexp_type('options') class CustomPadOptions: clearance: Named(AtomChoice(Atom.outline, Atom.convexhull)) = Atom.outline anchor: Named(AtomChoice(Atom.rect, Atom.circle)) = Atom.rect @sexp_type('primitives') class CustomPadPrimitives: annotation_bboxes: List(gr.AnnotationBBox) = 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) width: Named(float) = None fill: Named(YesNoAtom()) = True def all(self): yield from self.lines yield from self.rectangles yield from self.circles yield from self.arcs yield from self.polygons yield from self.curves @sexp_type('chamfer') class Chamfer: top_left: Flag() = False top_right: Flag() = False bottom_left: Flag() = False bottom_right: Flag() = False @sexp_type('pad') class Pad: number: str = None type: AtomChoice(Atom.thru_hole, Atom.smd, Atom.connect, Atom.np_thru_hole) = None shape: AtomChoice(Atom.circle, Atom.rect, Atom.oval, Atom.trapezoid, Atom.roundrect, Atom.custom) = None at: AtPos = field(default_factory=AtPos) locked: Wrap(Flag()) = False size: Rename(XYCoord) = field(default_factory=XYCoord) drill: Drill = None layers: Named(Array(str)) = field(default_factory=list) properties: List(Property) = field(default_factory=list) remove_unused_layers: Wrap(Flag()) = False keep_end_layers: Wrap(Flag()) = False rect_delta: Rename(XYCoord) = None roundrect_rratio: Named(float) = None thermal_bridge_angle: Named(int) = 45 chamfer_ratio: Named(float) = None chamfer: Chamfer = None net: NetDef = None tstamp: Timestamp = None pin_function: Named(str) = None pintype: Named(str) = None die_length: Named(float) = None solder_mask_margin: Named(float) = None solder_paste_margin: Named(float) = None solder_paste_margin_ratio: Named(float) = None clearance: Named(float) = None zone_connect: Named(int) = None thermal_width: Named(float) = None thermal_gap: Named(float) = None options: OmitDefault(CustomPadOptions) = None primitives: OmitDefault(CustomPadPrimitives) = None def render(self, variables=None, margin=None, cache=None): #if self.type in (Atom.connect, Atom.np_thru_hole): # return if self.drill and self.drill.offset: ox, oy = rotate_point(self.drill.offset.x, self.drill.offset.y, math.radians(self.at.rotation)) else: ox, oy = 0, 0 cache_key = id(self), margin if cache and cache_key in cache: aperture = cache[cache_key] elif cache is not None: aperture = cache[cache_key] = self.aperture(margin) else: aperture = self.aperture(margin) yield go.Flash(self.at.x+ox, self.at.y+oy, aperture, unit=MM) def aperture(self, margin=None): rotation = math.radians(self.at.rotation) margin = margin or 0 if self.shape == Atom.circle: return ap.CircleAperture(self.size.x+2*margin, unit=MM) elif self.shape == Atom.rect: if margin > 0: return ap.ApertureMacroInstance(GenericMacros.rounded_rect, (self.size.x+2*margin, self.size.y+2*margin, margin, 0, 0, # no hole rotation), unit=MM) else: return ap.RectangleAperture(self.size.x+2*margin, self.size.y+2*margin, unit=MM).rotated(rotation) elif self.shape == Atom.oval: return ap.ObroundAperture(self.size.x+2*margin, self.size.y+2*margin, unit=MM).rotated(rotation) elif self.shape == Atom.trapezoid: # KiCad's trapezoid aperture "rect_delta" param is just weird to the point that I think it's probably # bugged. If you have a size of 2mm by 2mm, and set this param to 1mm, the resulting pad extends past the # original bounding box, and the trapezoid's base and tip length are 3mm and 1mm. x, y = self.size.x, self.size.y if self.rect_delta: dx, dy = self.rect_delta.x, self.rect_delta.y else: # RF_Antenna/Pulse_W3011 has trapezoid pads w/o rect_delta, which KiCad renders as plain rects. dx, dy = 0, 0 if dx != 0: x, y = y, x dy = dx rotation += math.pi/2 if margin <= 0: # Note: KiCad already uses MM units, so no conversion needed here. alpha = math.atan(y / dy) if dy > 0 else 0 return ap.ApertureMacroInstance(GenericMacros.isosceles_trapezoid, (x+dy+2*margin*math.cos(alpha), y+2*margin, 2*dy, 0, 0, # no hole rotation), unit=MM) else: return ap.ApertureMacroInstance(GenericMacros.rounded_isosceles_trapezoid, (x+dy, y, 2*dy, margin, 0, 0, # no hole rotation), unit=MM) elif self.shape == Atom.roundrect: x, y = self.size.x, self.size.y r = min(x, y) * self.roundrect_rratio if margin > -r: return ap.ApertureMacroInstance(GenericMacros.rounded_rect, (x+2*margin, y+2*margin, r+margin, 0, 0, # no hole rotation), unit=MM) else: return ap.RectangleAperture(x+margin, y+margin, unit=MM).rotated(rotation) elif self.shape == Atom.custom: primitives = [] # One round trip through the Gerbonara APIs, please! for obj in self.primitives.all(): for gn_obj in obj.render(): if margin and isinstance(gn_obj, (go.Line, go.Arc)): gn_obj = gn_obj.dilated(margin) if isinstance(gn_obj, go.Region) and margin > 0: for line in gn_obj.outline_objects(ap.CircleAperture(2*margin, unit=MM)): primitives += line._aperture_macro_primitives() new_primitives = list(gn_obj._aperture_macro_primitives()) # todo: precision params primitives += new_primitives # inexact, only works with convex shapes. But whatever, the only other way to do this would require # an entire polygon clipping/offsetting library. Probably a bad choice to put something this complex # into a file format. if isinstance(gn_obj, go.Region) and margin < 0: for line in gn_obj.outline_objects(ap.CircleAperture(2*margin, unit=MM)): line.polarity_dark = False primitives += line._aperture_macro_primitives() if self.options: if self.options.anchor == Atom.rect and self.size.x > 0 and self.size.y > 0: if margin <= 0: primitives.append(amp.CenterLine(MM, 1, self.size.x+2*margin, self.size.y+2*margin, 0, 0, 0)) else: # margin > 0 primitives.append(amp.CenterLine(MM, 1, self.size.x+2*margin, self.size.y, 0, 0, 0)) primitives.append(amp.CenterLine(MM, 1, self.size.x, self.size.y+2*margin, 0, 0, 0)) primitives.append(amp.Circle(MM, 1, 2*margin, -self.size.x/2, -self.size.y/2)) primitives.append(amp.Circle(MM, 1, 2*margin, -self.size.x/2, +self.size.y/2)) primitives.append(amp.Circle(MM, 1, 2*margin, +self.size.x/2, -self.size.y/2)) primitives.append(amp.Circle(MM, 1, 2*margin, +self.size.x/2, +self.size.y/2)) elif self.options.anchor == Atom.circle and self.size.x > 0: primitives.append(amp.Circle(MM, 1, self.size.x+2*margin, 0, 0, 0)) macro = ApertureMacro(primitives=tuple(primitives)).rotated(rotation) return ap.ApertureMacroInstance(macro, unit=MM) def render_drill(self): if not self.drill: return plated = self.type != Atom.np_thru_hole if self.drill.oval: dia = self.drill.diameter w = self.drill.width if self.drill.offset: ox, oy = self.drill.offset.x, self.drill.offset.y else: ox, oy = 0, 0 if w > dia: dx = 0 dy = (w-dia)/2 else: dx = (dia-w)/2 dy = 0 aperture = ap.ExcellonTool(min(dia, w), plated=plated, unit=MM) l = go.Line(ox-dx, oy-dy, ox+dx, oy+dy, aperture=aperture, unit=MM) l.rotate(math.radians(self.at.rotation)) l.offset(self.at.x, self.at.y) yield l else: aperture = ap.ExcellonTool(self.drill.diameter, plated=plated, unit=MM) 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 = '' at: Named(XYZCoord) = field(default_factory=XYZCoord) offset: Named(XYZCoord) = field(default_factory=XYZCoord) scale: Named(XYZCoord) = field(default_factory=XYZCoord) rotate: Named(XYZCoord) = field(default_factory=XYZCoord) SUPPORTED_FILE_FORMAT_VERSIONS = [20210108, 20211014, 20221018] @sexp_type('footprint') class Footprint: name: str = None _version: Named(int, name='version') = 20210108 generator: Named(Atom) = Atom.kicad_library_utils locked: Flag() = False placed: Flag() = False layer: Named(str) = 'F.Cu' tedit: EditTime = field(default_factory=EditTime) tstamp: Timestamp = None at: AtPos = field(default_factory=AtPos) descr: Named(str) = None tags: Named(str) = None properties: List(Property) = field(default_factory=list) path: Named(str) = None autoplace_cost90: Named(float) = None autoplace_cost180: Named(float) = None solder_mask_margin: Named(float) = None solder_paste_margin: Named(float) = None solder_paste_ratio: Named(float) = None clearance: Named(float) = None zone_connect: Named(int) = None thermal_width: Named(float) = None thermal_gap: Named(float) = None attributes: List(Attribute) = field(default_factory=list) private_layers: Named(str) = None net_tie_pad_groups: Named(str) = None texts: List(Text) = field(default_factory=list) text_boxes: List(TextBox) = field(default_factory=list) lines: List(Line) = field(default_factory=list) rectangles: List(Rectangle) = field(default_factory=list) circles: List(Circle) = field(default_factory=list) arcs: List(Arc) = field(default_factory=list) polygons: List(Polygon) = field(default_factory=list) curves: List(Curve) = field(default_factory=list) dimensions: List(Dimension) = field(default_factory=list) pads: List(Pad) = field(default_factory=list) zones: List(Zone) = field(default_factory=list) groups: List(Group) = field(default_factory=list) models: List(Model) = 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_pretty(kls, pretty_dir, fp_name, *args, **kwargs): pretty_dir = Path(pretty_dir) / f'{fp_name}.kicad_mod' return kls.open_mod(pretty_dir / mod_name, *args, **kwargs) @classmethod def open_mod(kls, mod_file, *args, **kwargs): return kls.load(Path(mod_file).read_text(), *args, **kwargs, original_filename=mod_file) @classmethod def open_system(kls, fp_path): raise NotImplementedError() @classmethod def open_download(kls, fp_path): raise NotImplementedError() @classmethod def load(kls, data, *args, **kwargs): return kls.parse(data, *args, **kwargs) @property def single_sided(self): raise NotImplementedError() 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, self.arcs, self.polygons, self.curves, (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): 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)): 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.pads: if self.solder_mask_margin is not None: solder_mask_margin = self.solder_mask_margin elif obj.solder_mask_margin is not None: solder_mask_margin = obj.solder_mask_margin else: solder_mask_margin = None if self.solder_paste_margin is not None: solder_paste_margin = self.solder_paste_margin elif obj.solder_paste_margin_ratio is not None: solder_paste_margin = max(obj.size.x, obj.size.y) * obj.solder_paste_margin_ratio elif obj.solder_paste_margin is not None: solder_paste_margin = obj.solder_paste_margin else: solder_paste_margin = None for glob in obj.layers or []: for layer in fnmatch.filter(layer_map, glob): if layer.endswith('.Mask'): margin = solder_mask_margin elif layer.endswith('.Paste'): margin = solder_paste_margin else: margin = None for fe in obj.render(margin=margin, cache=cache): fe.rotate(rotation) fe.offset(x, y, MM) if isinstance(fe, go.Flash) and fe.aperture: fe.aperture = fe.aperture.rotated(rotation) layer_stack[layer_map[layer]].objects.append(fe) for obj in self.pads: for fe in obj.render_drill(): fe.rotate(rotation) fe.offset(x, y, MM) if obj.type == Atom.np_thru_hole: layer_stack.drill_npth.append(fe) else: 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, side='top', 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 hide_text: bool = True reference: str = 'REF**' value: str = None variables: dict = field(default_factory=lambda: {}) def render(self, layer_stack, cache=None): x, y, rotation = self.abs_pos x, y = MM(x, self.unit), MM(y, self.unit) 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) 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, side=self.side, 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 fp = Footprint.open_mod(sys.argv[1]) stack = LayerStack() FootprintInstance(0, 0, fp, unit=MM).render(stack) print(stack.to_pretty_svg()) stack.save_to_directory('/tmp/testdir')