kopia lustrzana https://gitlab.com/gerbolyze/gerbonara
1011 wiersze
35 KiB
Python
1011 wiersze
35 KiB
Python
"""
|
|
Library for handling KiCad's footprint files (`*.kicad_mod`).
|
|
"""
|
|
|
|
import re
|
|
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, replace
|
|
|
|
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
|
|
|
|
|
|
class _MISSING:
|
|
pass
|
|
|
|
def angle_difference(a, b):
|
|
return (b - a + math.pi) % (2*math.pi) - math.pi
|
|
|
|
@sexp_type('attr')
|
|
class Attribute:
|
|
type: AtomChoice(Atom.smd, Atom.through_hole) = None
|
|
board_only: Flag() = False
|
|
virtual: Flag() = False # prior to 20208026
|
|
exclude_from_pos_files: Flag() = False
|
|
exclude_from_bom: Flag() = False
|
|
allow_missing_courtyard: Flag() = False
|
|
allow_soldermask_bridges: Flag() = False
|
|
dnp: 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
|
|
width: Named(float) = None
|
|
stroke: Stroke = None
|
|
layer: Named(str) = 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: 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
|
|
thermal_bridge_width: Named(float) = 0.5
|
|
chamfer_ratio: Named(float) = None
|
|
chamfer: Chamfer = None
|
|
net: NetDef = None
|
|
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
|
|
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
|
|
_: SEXP_END = None
|
|
footprint: object = None
|
|
|
|
def __after_parse__(self, parent=None):
|
|
self.layers = unfuck_layers(self.layers)
|
|
|
|
def __before_sexp__(self):
|
|
self.layers = fuck_layers(self.layers)
|
|
|
|
@property
|
|
def abs_pos(self):
|
|
if self.footprint:
|
|
px, py, pr = self.footprint.at.x, self.footprint.at.y, self.footprint.at.rotation
|
|
else:
|
|
px, py, pr = 0, 0, 0
|
|
|
|
x, y = rotate_point(self.at.x, self.at.y, math.radians(pr))
|
|
return x+px, y+py, self.at.rotation, False
|
|
|
|
@property
|
|
def layer_mask(self):
|
|
return layer_mask(self.layers)
|
|
|
|
def offset(self, x=0, y=0):
|
|
self.at = self.at.with_offset(x, y)
|
|
|
|
def find_connected_footprints(self, **filters):
|
|
""" Find footprints connected to the same net as this pad """
|
|
return self.footprint.board.find_footprints(net=self.net.name, **filters)
|
|
|
|
def find_same_net(self, include_vias=True):
|
|
""" Find traces and vias of the same net as this pad. """
|
|
return self.footprint.board.find_traces(self.net.name, include_vias=include_vias)
|
|
|
|
def find_connected_traces(self, consider_candidates=5):
|
|
board = self.footprint.board
|
|
|
|
found = set()
|
|
search_frontier = [(self.at, 0, self.layer_mask)]
|
|
while search_frontier:
|
|
coord, size, layers = search_frontier.pop()
|
|
x, y = coord.x, coord.y
|
|
|
|
for cand, attr, cand_size in self.footprint.board.query_trace_index((x, x, y, y), layers,
|
|
n=consider_candidates):
|
|
if cand in found:
|
|
continue
|
|
|
|
cand_coord = getattr(cand, attr)
|
|
cand_x, cand_y = cand_coord.x, cand_coord.y
|
|
if math.dist((x, y), (cand_x, cand_y)) <= size/2 + cand_size/2:
|
|
found.add(cand)
|
|
yield cand
|
|
|
|
if hasattr(cand, 'at'): # via or pad
|
|
search_frontier.append((cand.at, getattr(cand, 'size', 0), cand.layer_mask))
|
|
else:
|
|
mask = cand.layer_mask
|
|
search_frontier.append((cand.start, cand.width, mask))
|
|
search_frontier.append((cand.end, cand.width, mask))
|
|
|
|
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 = replace(gn_obj, aperture=gn_obj.aperture.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('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, 20230517]
|
|
@sexp_type('footprint')
|
|
class Footprint:
|
|
name: str = None
|
|
_version: Named(int, name='version') = 20221018
|
|
generator: Named(Atom) = Atom.gerbonara
|
|
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(DrawnProperty) = field(default_factory=list)
|
|
path: Named(str) = None
|
|
sheetname: Named(str) = None
|
|
sheetfile: 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(Array(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
|
|
board: object = None
|
|
|
|
def __after_parse__(self, parent):
|
|
for pad in self.pads:
|
|
pad.footprint = self
|
|
|
|
def property_value(self, key, default=_MISSING):
|
|
for prop in self.properties:
|
|
if prop.key == key:
|
|
return prop.value
|
|
|
|
if default is not _MISSING:
|
|
return default
|
|
|
|
raise IndexError(f'Footprint has no property named "{key}"')
|
|
|
|
def set_property(self, key, value, x=0, y=0, rotation=0, layer='F.Fab', hide=True, effects=None):
|
|
for prop in self.properties:
|
|
if prop.key == key:
|
|
old_value, prop.value = prop.value, value
|
|
return old_value
|
|
|
|
if effects is None:
|
|
effects = TextEffect()
|
|
|
|
self.properties.append(DrawnProperty(key, value,
|
|
at=AtPos(x, y, rotation),
|
|
layer=layer,
|
|
hide=hide,
|
|
effects=effects))
|
|
|
|
@property
|
|
def pads_by_number(self):
|
|
return {(int(pad.number) if pad.number.isnumeric() else pad.number): pad for pad in self.pads if pad.number}
|
|
|
|
def find_pads(self, number=None, net=None):
|
|
for pad in self.pads:
|
|
if number is not None and pad.number == str(number):
|
|
yield pad
|
|
elif isinstance(net, str) and fnmatch.fnmatch(pad.net.name, net):
|
|
yield pad
|
|
elif net is not None and pad.net.number == net:
|
|
yield pad
|
|
|
|
def pad(self, number=None, net=None):
|
|
candidates = list(self.find_pads(number=number, net=net))
|
|
if not candidates:
|
|
raise IndexError(f'No such pad "{number or net}"')
|
|
|
|
if len(candidates) > 1:
|
|
raise IndexError(f'Ambiguous pad "{number or net}", {len(candidates)} matching pads.')
|
|
|
|
return candidates[0]
|
|
|
|
def offset(self, x=0, y=0):
|
|
self.at = self.at.with_offset(x, y)
|
|
|
|
@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))}.')
|
|
|
|
@property
|
|
def reference(self):
|
|
return self.property_value('Reference')
|
|
|
|
@reference.setter
|
|
def reference(self, value):
|
|
self.set_property('Reference', value)
|
|
|
|
@property
|
|
def parsed_reference(self):
|
|
ref = self.reference
|
|
if (m := re.match(r'^.*[^0-9]([0-9]+)$', ref)):
|
|
return m.group(0), int(m.group(1))
|
|
else:
|
|
return ref
|
|
|
|
@property
|
|
def value(self):
|
|
return self.property_value('Value')
|
|
|
|
@value.setter
|
|
def value(self, value):
|
|
self.set_property('Value', value)
|
|
|
|
def write(self, filename=None):
|
|
with open(filename or self.original_filename, 'w') as f:
|
|
f.write(self.serialize())
|
|
|
|
def serialize(self):
|
|
return build_sexp(sexp(type(self), self)[0])
|
|
|
|
@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 side(self):
|
|
return 'front' if self.layer == 'F.Cu' else 'back'
|
|
|
|
@side.setter
|
|
def side(self, value):
|
|
if value not in ('front', 'back'):
|
|
raise ValueError(f'side must be either "front" or "back", not {side!r}')
|
|
|
|
if self.side != value:
|
|
self.flip()
|
|
|
|
def flip(self):
|
|
def flip_layer(name):
|
|
if name.startswith('F.'):
|
|
return f'B.{name[2:]}'
|
|
elif name.startswith('B.'):
|
|
return f'F.{name[2:]}'
|
|
else:
|
|
return name
|
|
|
|
self.layer = flip_layer(self.layer)
|
|
for obj in self.objects():
|
|
if hasattr(obj, 'layer'):
|
|
obj.layer = flip_layer(obj.layer)
|
|
|
|
if hasattr(obj, 'layers'):
|
|
obj.layers = [flip_layer(name) for name in obj.layers]
|
|
|
|
for obj in chain(self.texts, self.text_boxes):
|
|
obj.effects.justify.mirror = not obj.effects.justify.mirror
|
|
|
|
for obj in self.properties:
|
|
obj.effects.justify.mirror = not obj.effects.justify.mirror
|
|
obj.layer = flip_layer(obj.layer)
|
|
|
|
@property
|
|
def single_sided(self):
|
|
raise NotImplementedError()
|
|
|
|
def face(self, direction, pad=None, net=None):
|
|
if not net and not pad:
|
|
pad = '1'
|
|
|
|
candidates = list(self.find_pads(net=net, number=pad))
|
|
if len(candidates) == 0:
|
|
raise KeyError(f'Reference pad "{net or pad}" not found.')
|
|
|
|
if len(candidates) > 1:
|
|
raise KeyError(f'Reference pad "{net or pad}" is ambiguous, {len(candidates)} matching pads found.')
|
|
|
|
pad = candidates[0]
|
|
pad_angle = math.atan2(pad.at.y, pad.at.x)
|
|
|
|
target_angle = {
|
|
'right': 0,
|
|
'top right': math.pi/4,
|
|
'top': math.pi/2,
|
|
'top left': 3*math.pi/4,
|
|
'left': math.pi,
|
|
'bottom left': -3*math.pi/4,
|
|
'bottom': -math.pi/2,
|
|
'bottom right': -math.pi/4}.get(direction, direction)
|
|
|
|
delta = angle_difference(target_angle, pad_angle)
|
|
adj = round(delta / (math.pi/2)) * math.pi/2
|
|
self.set_rotation(adj)
|
|
|
|
def rotate(self, angle=None, cx=None, cy=None, **reference_pad):
|
|
""" Rotate this footprint by the given angle in radians, counter-clockwise. When (cx, cy) are given, rotate
|
|
around the given coordinates in the global coordinate space. Otherwise rotate around the footprint's origin. """
|
|
if (cx, cy) != (None, None):
|
|
x, y = self.at.x-cx, self.at.y-cy
|
|
self.at.x = math.cos(angle)*x - math.sin(angle)*y + cx
|
|
self.at.y = math.sin(angle)*x + math.cos(angle)*y + cy
|
|
|
|
self.at.rotation = (self.at.rotation - math.degrees(angle)) % 360
|
|
|
|
for pad in self.pads:
|
|
pad.at.rotation = (pad.at.rotation - math.degrees(angle)) % 360
|
|
|
|
for prop in self.properties:
|
|
prop.at.rotation = (prop.at.rotation - math.degrees(angle)) % 360
|
|
|
|
for text in self.texts:
|
|
text.at.rotation = (text.at.rotation - math.degrees(angle)) % 360
|
|
|
|
def set_rotation(self, angle):
|
|
old_deg = self.at.rotation
|
|
new_deg = self.at.rotation = -math.degrees(angle)
|
|
delta = new_deg - old_deg
|
|
|
|
for pad in self.pads:
|
|
pad.at.rotation = (pad.at.rotation + delta) % 360
|
|
|
|
for prop in self.properties:
|
|
prop.at.rotation = (prop.at.rotation + delta) % 360
|
|
|
|
for text in self.texts:
|
|
text.at.rotation = (text.at.rotation + delta) % 360
|
|
|
|
def objects(self, text=False, pads=True, groups=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 []),
|
|
self.zones,
|
|
self.groups if groups else [])
|
|
|
|
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)
|
|
|
|
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, flip=False, text=False, variables={})
|
|
self._bounding_box = stack.bounding_box(unit)
|
|
return self._bounding_box
|
|
|
|
|
|
@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, flip= 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,
|
|
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
|
|
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')
|
|
|