Add basic KiCad PCB file format support
This commit is contained in:
parent
03f2ec0a30
commit
3561817903
6 changed files with 162 additions and 57 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.')
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue