cad: Add KiCad symbol/footprint parser
This commit is contained in:
parent
b43e4e2eec
commit
2400ff8e5f
12 changed files with 1703 additions and 0 deletions
122
gerbonara/cad/kicad/base_types.py
Normal file
122
gerbonara/cad/kicad/base_types.py
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
from .sexp import *
|
||||
from .sexp_mapper import *
|
||||
import time
|
||||
|
||||
from dataclasses import field
|
||||
import math
|
||||
import uuid
|
||||
|
||||
|
||||
@sexp_type('color')
|
||||
class Color:
|
||||
r: int = None
|
||||
g: int = None
|
||||
b: int = None
|
||||
a: int = None
|
||||
|
||||
|
||||
@sexp_type('stroke')
|
||||
class Stroke:
|
||||
width: Named(float) = 0.254
|
||||
type: Named(AtomChoice(Atom.dash, Atom.dot, Atom.dash_dot_dot, Atom.dash_dot, Atom.default, Atom.solid)) = Atom.default
|
||||
color: Color = None
|
||||
|
||||
@property
|
||||
def width_mil(self):
|
||||
return mm_to_mil(self.width)
|
||||
|
||||
@width_mil.setter
|
||||
def width_mil(self, value):
|
||||
self.width = mil_to_mm(value)
|
||||
|
||||
|
||||
@sexp_type('xy')
|
||||
class XYCoord:
|
||||
x: float = 0
|
||||
y: float = 0
|
||||
|
||||
def isclose(self, other, tol=1e-6):
|
||||
return math.isclose(self.x, other.x, tol) and math.isclose(self.y, other.y, tol)
|
||||
|
||||
|
||||
@sexp_type('pts')
|
||||
class PointList:
|
||||
xy : List(XYCoord) = field(default_factory=list)
|
||||
|
||||
|
||||
@sexp_type('xyz')
|
||||
class XYZCoord:
|
||||
x: float = 0
|
||||
y: float = 0
|
||||
z: float = 0
|
||||
|
||||
|
||||
@sexp_type('at')
|
||||
class AtPos(XYCoord):
|
||||
x: float = 0 # in millimeter
|
||||
y: float = 0 # in millimeter
|
||||
rotation: int = 0 # in degrees, can only be 0, 90, 180 or 270.
|
||||
unlocked: Flag() = False
|
||||
|
||||
def __before_sexp__(self):
|
||||
self.rotation = int(round(self.rotation % 360))
|
||||
|
||||
@property
|
||||
def rotation_rad(self):
|
||||
return math.radians(self.rotation)
|
||||
|
||||
@rotation_rad.setter
|
||||
def rotation_rad(self, value):
|
||||
self.rotation = math.degrees(value)
|
||||
|
||||
|
||||
@sexp_type('font')
|
||||
class FontSpec:
|
||||
face: Named(str) = None
|
||||
size: Rename(XYCoord) = field(default_factory=lambda: XYCoord(1.27, 1.27))
|
||||
thickness: Named(float) = None
|
||||
bold: Flag() = False
|
||||
italic: Flag() = False
|
||||
line_spacing: Named(float) = None
|
||||
|
||||
|
||||
@sexp_type('justify')
|
||||
class Justify:
|
||||
h: AtomChoice(Atom.left, Atom.right) = None
|
||||
v: AtomChoice(Atom.top, Atom.bottom) = None
|
||||
mirror: Flag() = False
|
||||
|
||||
|
||||
@sexp_type('effects')
|
||||
class TextEffect:
|
||||
font: FontSpec = field(default_factory=FontSpec)
|
||||
justify: OmitDefault(Justify) = field(default_factory=Justify)
|
||||
hide: Flag() = False
|
||||
|
||||
|
||||
@sexp_type('tstamp')
|
||||
class Timestamp:
|
||||
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)
|
||||
|
||||
def __after_parse__(self, parent):
|
||||
self.value = int(str(self.value), 16)
|
||||
|
||||
def __before_sexp__(self):
|
||||
self.value = Atom(f'{int(self.value):08X}')
|
||||
|
||||
def bump(self):
|
||||
self.value = time.time()
|
||||
|
||||
0
gerbonara/cad/kicad/footprint.py
Normal file
0
gerbonara/cad/kicad/footprint.py
Normal file
316
gerbonara/cad/kicad/footprints.py
Normal file
316
gerbonara/cad/kicad/footprints.py
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
"""
|
||||
Library for handling KiCad's footprint files (`*.kicad_mod`).
|
||||
"""
|
||||
|
||||
import copy
|
||||
import enum
|
||||
import datetime
|
||||
import math
|
||||
import time
|
||||
from typing import Any, Dict, Iterable, List, Optional, Tuple, Union
|
||||
|
||||
from .sexp import *
|
||||
from .base_types import *
|
||||
from .primitives import *
|
||||
from . import graphical_primitives as gr
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
@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
|
||||
|
||||
@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))}.')
|
||||
|
||||
@classmethod
|
||||
def open(cls, filename: str) -> 'Library':
|
||||
with open(filename) as f:
|
||||
return cls.parse(f.read())
|
||||
|
||||
def write(self, filename=None) -> None:
|
||||
with open(filename or self.original_filename, 'w') as f:
|
||||
f.write(build_sexp(sexp(self)))
|
||||
|
||||
|
||||
111
gerbonara/cad/kicad/graphical_primitives.py
Normal file
111
gerbonara/cad/kicad/graphical_primitives.py
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
|
||||
from .sexp import *
|
||||
from .base_types import *
|
||||
from .primitives import *
|
||||
|
||||
@sexp_type('layer')
|
||||
class TextLayer:
|
||||
layer: str = ''
|
||||
knockout: Flag() = False
|
||||
|
||||
|
||||
@sexp_type('gr_text')
|
||||
class Text:
|
||||
text: str = ''
|
||||
at: AtPos = field(default_factory=AtPos)
|
||||
layer: TextLayer = field(default_factory=TextLayer)
|
||||
tstamp: Timestamp = None
|
||||
effects: TextEffect = field(default_factory=TextEffect)
|
||||
|
||||
|
||||
@sexp_type('gr_text_box')
|
||||
class TextBox:
|
||||
locked: Flag() = False
|
||||
text: str = ''
|
||||
start: Named(XYCoord) = None
|
||||
end: Named(XYCoord) = None
|
||||
pts: PointList = field(default_factory=PointList)
|
||||
angle: OmitDefault(Named(float)) = 0.0
|
||||
layer: Named(str) = ""
|
||||
tstamp: Timestamp = None
|
||||
effects: TextEffect = field(default_factory=TextEffect)
|
||||
stroke: Stroke = field(default_factory=Stroke)
|
||||
render_cache: RenderCache = None
|
||||
|
||||
|
||||
@sexp_type('gr_line')
|
||||
class Line:
|
||||
start: Rename(XYCoord) = None
|
||||
end: Rename(XYCoord) = None
|
||||
angle: Named(float) = None
|
||||
layer: Named(str) = None
|
||||
width: Named(float) = None
|
||||
tstamp: Timestamp = None
|
||||
|
||||
|
||||
@sexp_type('fill')
|
||||
class FillMode:
|
||||
# Needed for compatibility with weird files
|
||||
fill: AtomChoice(Atom.solid, Atom.yes, Atom.no, Atom.none) = False
|
||||
|
||||
@classmethod
|
||||
def __map__(self, obj, parent=None):
|
||||
return obj[0] in (Atom.solid, Atom.yes)
|
||||
|
||||
@classmethod
|
||||
def __sexp__(self, value):
|
||||
yield [Atom.fill, Atom.solid if value else Atom.none]
|
||||
|
||||
@sexp_type('gr_rect')
|
||||
class Rectangle:
|
||||
start: Rename(XYCoord) = None
|
||||
end: Rename(XYCoord) = None
|
||||
layer: Named(str) = None
|
||||
width: Named(float) = None
|
||||
fill: FillMode = False
|
||||
tstamp: Timestamp = None
|
||||
|
||||
|
||||
@sexp_type('gr_circle')
|
||||
class Circle:
|
||||
center: Rename(XYCoord) = None
|
||||
end: Rename(XYCoord) = None
|
||||
layer: Named(str) = None
|
||||
width: Named(float) = None
|
||||
fill: FillMode = False
|
||||
tstamp: Timestamp = None
|
||||
|
||||
|
||||
@sexp_type('gr_arc')
|
||||
class Arc:
|
||||
start: Rename(XYCoord) = None
|
||||
mid: Rename(XYCoord) = None
|
||||
end: Rename(XYCoord) = None
|
||||
layer: Named(str) = None
|
||||
width: Named(float) = None
|
||||
tstamp: Timestamp = None
|
||||
|
||||
|
||||
@sexp_type('gr_poly')
|
||||
class Polygon:
|
||||
pts: PointList = field(default_factory=PointList)
|
||||
layer: Named(str) = None
|
||||
width: Named(float) = None
|
||||
fill: FillMode= False
|
||||
tstamp: Timestamp = None
|
||||
|
||||
|
||||
@sexp_type('gr_curve')
|
||||
class Curve:
|
||||
pts: PointList = field(default_factory=PointList)
|
||||
layer: Named(str) = None
|
||||
width: Named(float) = None
|
||||
tstamp: Timestamp = None
|
||||
|
||||
|
||||
@sexp_type('gr_bbox')
|
||||
class AnnotationBBox:
|
||||
start: Rename(XYCoord) = None
|
||||
end: Rename(XYCoord) = None
|
||||
|
||||
|
||||
97
gerbonara/cad/kicad/primitives.py
Normal file
97
gerbonara/cad/kicad/primitives.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
|
||||
import enum
|
||||
|
||||
from .sexp import *
|
||||
from .base_types import *
|
||||
|
||||
|
||||
@sexp_type('hatch')
|
||||
class Hatch:
|
||||
style: AtomChoice(Atom.none, Atom.edge, Atom.full) = Atom.edge
|
||||
pitch: float = 0.5
|
||||
|
||||
|
||||
@sexp_type('connect_pads')
|
||||
class PadConnection:
|
||||
type: AtomChoice(Atom.thru_hole_only, Atom.full, Atom.no) = None
|
||||
clearance: Named(float) = 0
|
||||
|
||||
|
||||
@sexp_type('keepout')
|
||||
class ZoneKeepout:
|
||||
tracks_allowed: Named(YesNoAtom(yes=Atom.allowed, no=Atom.not_allowed), name='tracks') = True
|
||||
vias_allowed: Named(YesNoAtom(yes=Atom.allowed, no=Atom.not_allowed), name='vias') = True
|
||||
pads_allowed: Named(YesNoAtom(yes=Atom.allowed, no=Atom.not_allowed), name='pads') = True
|
||||
copperpour_allowed: Named(YesNoAtom(yes=Atom.allowed, no=Atom.not_allowed), name='copperpour') = True
|
||||
footprints_allowed: Named(YesNoAtom(yes=Atom.allowed, no=Atom.not_allowed), name='footprints') = True
|
||||
|
||||
|
||||
@sexp_type('smoothing')
|
||||
class ZoneSmoothing:
|
||||
style: AtomChoice(Atom.chamfer, Atom.fillet) = Atom.chamfer
|
||||
radius: Named(float) = None
|
||||
|
||||
|
||||
@sexp_type('fill')
|
||||
class ZoneFill:
|
||||
yes: Flag() = False
|
||||
mode: Flag(atom=Atom.hatched) = False
|
||||
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
|
||||
hatch_thickness: Named(float) = None
|
||||
hatch_gap: Named(float) = None
|
||||
hatch_orientation: Named(int) = None
|
||||
hatch_smoothing_level: Named(int) = None
|
||||
hatch_smoothing_value: Named(float) = None
|
||||
hatch_border_algorithm: Named(int) = None
|
||||
hatch_min_hole_area: Named(float) = None
|
||||
|
||||
|
||||
@sexp_type('filled_polygon')
|
||||
class FillPolygon:
|
||||
layer: Named(str) = ""
|
||||
pts: PointList = field(default_factory=PointList)
|
||||
|
||||
|
||||
@sexp_type('fill_segments')
|
||||
class FillSegment:
|
||||
layer: Named(str) = ""
|
||||
pts: PointList = field(default_factory=PointList)
|
||||
|
||||
|
||||
@sexp_type('zone')
|
||||
class Zone:
|
||||
net: Named(int) = 0
|
||||
net_name: Named(str) = ""
|
||||
layer: Named(str) = None
|
||||
layers: Named(Array(str)) = None
|
||||
tstamp: Timestamp = None
|
||||
name: Named(str) = None
|
||||
hatch: Hatch = None
|
||||
priority: OmitDefault(Named(int)) = 0
|
||||
connect_pads: PadConnection = field(default_factory=PadConnection)
|
||||
min_thickness: Named(float) = 0.254
|
||||
filled_areas_thickness: Flag() = True
|
||||
keepouts: List(ZoneKeepout) = field(default_factory=list)
|
||||
fill: ZoneFill = field(default_factory=ZoneFill)
|
||||
polygon: Named(PointList) = field(default_factory=PointList)
|
||||
fill_polygons: List(FillPolygon) = field(default_factory=list)
|
||||
fill_segments: List(FillSegment) = field(default_factory=list)
|
||||
|
||||
|
||||
@sexp_type('polygon')
|
||||
class RenderCachePolygon:
|
||||
pts: PointList = field(default_factory=PointList)
|
||||
|
||||
|
||||
@sexp_type('render_cache')
|
||||
class RenderCache:
|
||||
text: str = None
|
||||
rotation: int = 0
|
||||
polygons: List(RenderCachePolygon) = field(default_factory=list)
|
||||
|
||||
|
||||
|
||||
152
gerbonara/cad/kicad/sexp.py
Normal file
152
gerbonara/cad/kicad/sexp.py
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import math
|
||||
import re
|
||||
import functools
|
||||
from typing import Any, Optional
|
||||
import uuid
|
||||
from dataclasses import dataclass, fields, field
|
||||
from copy import deepcopy
|
||||
|
||||
|
||||
class SexpError(ValueError):
|
||||
""" Low-level error parsing S-Expression format """
|
||||
pass
|
||||
|
||||
|
||||
class FormatError(ValueError):
|
||||
""" Semantic error in S-Expression structure """
|
||||
pass
|
||||
|
||||
|
||||
class AtomType(type):
|
||||
def __getattr__(cls, key):
|
||||
return cls(key)
|
||||
|
||||
|
||||
@functools.total_ordering
|
||||
class Atom(metaclass=AtomType):
|
||||
def __init__(self, obj=''):
|
||||
if isinstance(obj, str):
|
||||
self.value = obj
|
||||
elif isinstance(obj, Atom):
|
||||
self.value = obj.value
|
||||
else:
|
||||
raise TypeError(f'Atom argument must be str, not {type(obj)}')
|
||||
|
||||
def __str__(self):
|
||||
return self.value
|
||||
|
||||
def __repr__(self):
|
||||
return f'@{self.value}'
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self.value)
|
||||
|
||||
def __eq__(self, other):
|
||||
if not isinstance(other, (Atom, str)):
|
||||
return self.value == other
|
||||
return self.value == str(other)
|
||||
|
||||
def __lt__(self, other):
|
||||
if not isinstance(other, (Atom, str)):
|
||||
raise TypeError(f'Cannot compare Atom and {type(other)}')
|
||||
return self.value < str(other)
|
||||
|
||||
def __gt__(self, other):
|
||||
if not isinstance(other, (Atom, str)):
|
||||
raise TypeError(f'Cannot compare Atom and {type(other)}')
|
||||
return self.value > str(other)
|
||||
|
||||
|
||||
term_regex = r"""(?mx)
|
||||
\s*(?:
|
||||
"((?:\\\\|\\"|[^"])*)"|
|
||||
(\()|
|
||||
(\))|
|
||||
([+-]?\d+\.\d+(?=[\s\)]))|
|
||||
(\-?\d+(?=[\s\)]))|
|
||||
([^0-9"\s()][^"\s)]*)
|
||||
)"""
|
||||
|
||||
|
||||
def parse_sexp(sexp: str) -> Any:
|
||||
re_iter = re.finditer(term_regex, sexp)
|
||||
rv = list(_parse_sexp_internal(re_iter))
|
||||
|
||||
for leftover in re_iter:
|
||||
quoted_str, lparen, rparen, *rest = leftover.groups()
|
||||
if quoted_str or lparen or any(rest):
|
||||
raise SexpError(f'Leftover garbage after end of expression at position {leftover.start()}') # noqa: E501
|
||||
|
||||
elif rparen:
|
||||
raise SexpError(f'Unbalanced closing parenthesis at position {leftover.start()}')
|
||||
|
||||
if len(rv) == 0:
|
||||
raise SexpError('No or empty expression')
|
||||
|
||||
if len(rv) > 1:
|
||||
print(rv[0])
|
||||
print(rv[1])
|
||||
raise SexpError('Missing initial opening parenthesis')
|
||||
|
||||
return rv[0]
|
||||
|
||||
|
||||
def _parse_sexp_internal(re_iter) -> Any:
|
||||
for match in re_iter:
|
||||
quoted_str, lparen, rparen, float_num, integer_num, bare_str = match.groups()
|
||||
|
||||
if lparen:
|
||||
yield list(_parse_sexp_internal(re_iter))
|
||||
elif rparen:
|
||||
break
|
||||
elif bare_str is not None:
|
||||
yield Atom(bare_str)
|
||||
elif quoted_str is not None:
|
||||
yield quoted_str.replace('\\"', '"')
|
||||
elif float_num:
|
||||
yield float(float_num)
|
||||
elif integer_num:
|
||||
yield int(integer_num)
|
||||
|
||||
|
||||
def build_sexp(exp, indent=' ') -> str:
|
||||
# Special case for multi-values
|
||||
if isinstance(exp, (list, tuple)):
|
||||
joined = '('
|
||||
for i, elem in enumerate(exp):
|
||||
if 1 <= i <= 5 and len(joined) < 120 and not isinstance(elem, (list, tuple)):
|
||||
joined += ' '
|
||||
elif i >= 1:
|
||||
joined += '\n' + indent
|
||||
joined += build_sexp(elem, indent=f'{indent} ')
|
||||
return joined + ')'
|
||||
|
||||
if exp == '':
|
||||
return '""'
|
||||
|
||||
if isinstance(exp, str):
|
||||
exp = exp.replace('"', r'\"')
|
||||
return f'"{exp}"'
|
||||
|
||||
if isinstance(exp, float):
|
||||
# python whyyyy
|
||||
val = f'{exp:.6f}'
|
||||
val = val.rstrip('0')
|
||||
if val[-1] == '.':
|
||||
val += '0'
|
||||
return val
|
||||
else:
|
||||
return str(exp)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sexp = """ ( ( Winson_GM-402B_5x5mm_P1.27mm data "quoted data" 123 4.5)
|
||||
(data "with \\"escaped quotes\\"")
|
||||
(data (123 (4.5) "(more" "data)")))"""
|
||||
|
||||
print("Input S-expression:")
|
||||
print(sexp)
|
||||
parsed = parse_sexp(sexp)
|
||||
print("\nParsed to Python:", parsed)
|
||||
|
||||
print("\nThen back to: '%s'" % build_sexp(parsed))
|
||||
289
gerbonara/cad/kicad/sexp_mapper.py
Normal file
289
gerbonara/cad/kicad/sexp_mapper.py
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
|
||||
from dataclasses import MISSING
|
||||
from .sexp import *
|
||||
|
||||
|
||||
SEXP_END = type('SEXP_END', (), {})
|
||||
|
||||
|
||||
class AtomChoice:
|
||||
def __init__(self, *choices):
|
||||
self.choices = choices
|
||||
|
||||
def __contains__(self, value):
|
||||
return value in self.choices
|
||||
|
||||
def __atoms__(self):
|
||||
return self.choices
|
||||
|
||||
def __map__(self, obj, parent=None):
|
||||
obj, = obj
|
||||
if obj not in self:
|
||||
raise TypeError(f'Invalid atom {obj} for {type(self)}, valid choices are: {", ".join(map(str, self.choices))}')
|
||||
return obj
|
||||
|
||||
def __sexp__(self, value):
|
||||
yield value
|
||||
|
||||
|
||||
class Flag:
|
||||
def __init__(self, atom=None, invert=None):
|
||||
self.atom, self.invert = atom, invert
|
||||
|
||||
def __bind_field__(self, field):
|
||||
if self.atom is None:
|
||||
self.atom = Atom(field.name)
|
||||
if self.invert is None:
|
||||
self.invert = bool(field.default)
|
||||
|
||||
def __atoms__(self):
|
||||
return [self.atom]
|
||||
|
||||
def __map__(self, obj, parent=None):
|
||||
return not self.invert
|
||||
|
||||
def __sexp__(self, value):
|
||||
if bool(value) == (not self.invert):
|
||||
yield self.atom
|
||||
|
||||
|
||||
def sexp(t, v):
|
||||
if v is None:
|
||||
return []
|
||||
elif t in (int, float, str, Atom):
|
||||
return [t(v)]
|
||||
elif hasattr(t, '__sexp__'):
|
||||
return list(t.__sexp__(v))
|
||||
elif isinstance(t, list):
|
||||
t, = t
|
||||
return [sexp(t, elem) for elem in v]
|
||||
else:
|
||||
raise TypeError(f'Python type {t} has no defined s-expression serialization')
|
||||
|
||||
|
||||
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):
|
||||
types = set({type(v), t})
|
||||
if types == {int, float} or types == {str, Atom}:
|
||||
v = t(v)
|
||||
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')
|
||||
|
||||
|
||||
class WrapperType:
|
||||
def __init__(self, next_type):
|
||||
self.next_type = next_type
|
||||
|
||||
def __bind_field__(self, field):
|
||||
self.field = field
|
||||
getattr(self.next_type, '__bind_field__', lambda x: None)(field)
|
||||
|
||||
def __atoms__(self):
|
||||
if hasattr(self, 'name_atom'):
|
||||
return [self.name_atom]
|
||||
elif self.next_type is Atom:
|
||||
return []
|
||||
else:
|
||||
return getattr(self.next_type, '__atoms__', lambda: [])()
|
||||
|
||||
class Named(WrapperType):
|
||||
def __init__(self, next_type, name=None):
|
||||
super().__init__(next_type)
|
||||
self.name_atom = Atom(name) if name else None
|
||||
|
||||
def __bind_field__(self, field):
|
||||
if self.next_type is not Atom:
|
||||
getattr(self.next_type, '__bind_field__', lambda x: None)(field)
|
||||
if self.name_atom is None:
|
||||
self.name_atom = Atom(field.name)
|
||||
|
||||
def __map__(self, obj, parent=None):
|
||||
k, *obj = obj
|
||||
if self.next_type in (int, float, str, Atom) or isinstance(self.next_type, AtomChoice):
|
||||
return map_sexp(self.next_type, [*obj], parent=parent)
|
||||
else:
|
||||
return map_sexp(self.next_type, obj, parent=parent)
|
||||
|
||||
def __sexp__(self, value):
|
||||
value = sexp(self.next_type, value)
|
||||
if value is not None:
|
||||
yield [self.name_atom, *value]
|
||||
|
||||
|
||||
class Rename(WrapperType):
|
||||
def __init__(self, next_type, name=None):
|
||||
super().__init__(next_type)
|
||||
self.name_atom = Atom(name) if name else None
|
||||
|
||||
def __bind_field__(self, field):
|
||||
if self.name_atom is None:
|
||||
self.name_atom = Atom(field.name)
|
||||
|
||||
def __map__(self, obj, parent=None):
|
||||
return map_sexp(self.next_type, obj, parent=parent)
|
||||
|
||||
def __sexp__(self, value):
|
||||
value, = sexp(self.next_type, value)
|
||||
if self.next_type in (str, float, int, Atom):
|
||||
yield [self.name_atom, *value]
|
||||
else:
|
||||
key, *rest = value
|
||||
yield [self.name_atom, *rest]
|
||||
|
||||
|
||||
class OmitDefault(WrapperType):
|
||||
def __bind_field__(self, field):
|
||||
getattr(self.next_type, '__bind_field__', lambda x: None)(field)
|
||||
if field.default_factory != MISSING:
|
||||
self.default = field.default_factory()
|
||||
else:
|
||||
self.default = field.default
|
||||
|
||||
def __map__(self, obj, parent=None):
|
||||
return map_sexp(self.next_type, obj, parent=parent)
|
||||
|
||||
def __sexp__(self, value):
|
||||
if value != self.default:
|
||||
yield from sexp(self.next_type, value)
|
||||
|
||||
|
||||
class YesNoAtom:
|
||||
def __init__(self, yes=Atom.yes, no=Atom.no):
|
||||
self.yes, self.no = yes, no
|
||||
|
||||
def __map__(self, value, parent=None):
|
||||
value, = value
|
||||
return value == self.yes
|
||||
|
||||
def __sexp__(self, value):
|
||||
yield self.yes if value else self.no
|
||||
|
||||
|
||||
class Wrap(WrapperType):
|
||||
def __map__(self, value, parent=None):
|
||||
value, = value
|
||||
return map_sexp(self.next_type, value, parent=parent)
|
||||
|
||||
def __sexp__(self, value):
|
||||
for inner in sexp(self.next_type, value):
|
||||
yield [inner]
|
||||
|
||||
|
||||
class Array(WrapperType):
|
||||
def __map__(self, value, parent=None):
|
||||
return [map_sexp(self.next_type, [elem], parent=parent) for elem in value]
|
||||
|
||||
def __sexp__(self, value):
|
||||
for e in value:
|
||||
yield from sexp(self.next_type, e)
|
||||
|
||||
|
||||
class List(WrapperType):
|
||||
def __bind_field__(self, field):
|
||||
self.attr = field.name
|
||||
|
||||
def __map__(self, value, parent):
|
||||
l = getattr(parent, self.attr, [])
|
||||
mapped = map_sexp(self.next_type, value, parent=parent)
|
||||
l.append(mapped)
|
||||
setattr(parent, self.attr, l)
|
||||
|
||||
def __sexp__(self, value):
|
||||
for elem in value:
|
||||
yield from sexp(self.next_type, elem)
|
||||
|
||||
|
||||
class _SexpTemplate:
|
||||
@staticmethod
|
||||
def __atoms__(kls):
|
||||
return [kls.name_atom]
|
||||
|
||||
@staticmethod
|
||||
def __map__(kls, value, parent=None):
|
||||
positional = iter(kls.positional)
|
||||
inst = kls()
|
||||
|
||||
for v in value[1:]: # skip key
|
||||
if isinstance(v, Atom) and v in kls.keys:
|
||||
name, etype = kls.keys[v]
|
||||
mapped = map_sexp(etype, [v], parent=inst)
|
||||
if mapped is not None:
|
||||
setattr(inst, name, mapped)
|
||||
|
||||
elif isinstance(v, list):
|
||||
name, etype = kls.keys[v[0]]
|
||||
mapped = map_sexp(etype, v, parent=inst)
|
||||
if mapped is not None:
|
||||
setattr(inst, name, mapped)
|
||||
|
||||
else:
|
||||
try:
|
||||
pos_key = next(positional)
|
||||
setattr(inst, pos_key.name, v)
|
||||
except StopIteration:
|
||||
raise TypeError(f'Unhandled positional argument {v!r} while parsing {kls}')
|
||||
|
||||
getattr(inst, '__after_parse__', lambda x: None)(parent)
|
||||
return inst
|
||||
|
||||
@staticmethod
|
||||
def __sexp__(kls, value):
|
||||
getattr(value, '__before_sexp__', lambda: None)()
|
||||
|
||||
out = [kls.name_atom]
|
||||
for f in fields(kls):
|
||||
if f.type is SEXP_END:
|
||||
break
|
||||
out += sexp(f.type, getattr(value, f.name))
|
||||
yield out
|
||||
|
||||
@staticmethod
|
||||
def parse(kls, data):
|
||||
return kls.__map__(parse_sexp(data))
|
||||
|
||||
@staticmethod
|
||||
def sexp(self):
|
||||
return next(self.__sexp__(self))
|
||||
|
||||
|
||||
def sexp_type(name=None):
|
||||
def register(cls):
|
||||
cls = dataclass(cls)
|
||||
cls.name_atom = Atom(name) if name is not None else None
|
||||
for key in '__sexp__', '__map__', '__atoms__', 'parse':
|
||||
if not hasattr(cls, key):
|
||||
setattr(cls, key, classmethod(getattr(_SexpTemplate, key)))
|
||||
|
||||
if not hasattr(cls, 'sexp'):
|
||||
setattr(cls, 'sexp', getattr(_SexpTemplate, 'sexp'))
|
||||
cls.positional = []
|
||||
cls.keys = {}
|
||||
for f in fields(cls):
|
||||
f_type = f.type
|
||||
if f_type is SEXP_END:
|
||||
break
|
||||
|
||||
if hasattr(f_type, '__bind_field__'):
|
||||
f_type.__bind_field__(f)
|
||||
|
||||
atoms = getattr(f_type, '__atoms__', lambda: [])
|
||||
atoms = list(atoms())
|
||||
for atom in atoms:
|
||||
cls.keys[atom] = (f.name, f_type)
|
||||
if not atoms:
|
||||
cls.positional.append(f)
|
||||
|
||||
return cls
|
||||
return register
|
||||
|
||||
|
||||
446
gerbonara/cad/kicad/symbols.py
Normal file
446
gerbonara/cad/kicad/symbols.py
Normal file
|
|
@ -0,0 +1,446 @@
|
|||
"""
|
||||
Library for processing KiCad's symbol files.
|
||||
"""
|
||||
|
||||
import json
|
||||
import string
|
||||
import math
|
||||
import re
|
||||
import sys
|
||||
import itertools
|
||||
from fnmatch import fnmatch
|
||||
from collections import defaultdict
|
||||
from dataclasses import field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from .sexp import *
|
||||
from .sexp_mapper import *
|
||||
from .base_types import *
|
||||
|
||||
|
||||
PIN_ETYPE = AtomChoice(Atom.input, Atom.output, Atom.bidirectional, Atom.tri_state, Atom.passive, Atom.free,
|
||||
Atom.unspecified, Atom.power_in, Atom.power_out, Atom.open_collector, Atom.open_emitter,
|
||||
Atom.no_connect)
|
||||
|
||||
|
||||
PIN_STYLE = AtomChoice(Atom.line, Atom.inverted, Atom.clock, Atom.inverted_clock, Atom.input_low, Atom.clock_low,
|
||||
Atom.output_low, Atom.edge_clock_high, Atom.non_logic)
|
||||
|
||||
|
||||
@sexp_type('alternate')
|
||||
class AltFunction:
|
||||
name: str = None
|
||||
etype: PIN_ETYPE = Atom.unspecified
|
||||
shape: PIN_STYLE = Atom.line
|
||||
|
||||
|
||||
@sexp_type('__styled_text')
|
||||
class StyledText:
|
||||
value: str = None
|
||||
effects: TextEffect = field(default_factory=TextEffect)
|
||||
|
||||
|
||||
@sexp_type('pin')
|
||||
class Pin:
|
||||
etype: PIN_ETYPE = Atom.unspecified
|
||||
style: PIN_STYLE = Atom.line
|
||||
at: AtPos = field(default_factory=AtPos)
|
||||
length: Named(float) = 2.54
|
||||
hide: Flag() = False
|
||||
name: Rename(StyledText) = field(default_factory=StyledText)
|
||||
number: Rename(StyledText) = field(default_factory=StyledText)
|
||||
alternates: List(AltFunction) = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def direction(self):
|
||||
return {0: 'R', 90: 'U', 180: 'L', 270: 'D'}.get(self.at.rotation, 'R')
|
||||
|
||||
@direction.setter
|
||||
def direction(self, value):
|
||||
self.at.rotation = {0: 'R', 90: 'U', 180: 'L', 270: 'D'}[value[0].upper()]
|
||||
|
||||
|
||||
@sexp_type('fill')
|
||||
class Fill:
|
||||
type: Named(AtomChoice(Atom.none, Atom.outline, Atom.background)) = Atom.none
|
||||
|
||||
|
||||
@sexp_type('circle')
|
||||
class Circle:
|
||||
center: Rename(XYCoord) = field(default_factory=XYCoord)
|
||||
radius: Named(float) = 0.0
|
||||
stroke: Stroke = field(default_factory=Stroke)
|
||||
fill: Fill = field(default_factory=Fill)
|
||||
|
||||
|
||||
@sexp_type('arc')
|
||||
class Arc:
|
||||
start: Rename(XYCoord) = field(default_factory=XYCoord)
|
||||
mid: Rename(XYCoord) = field(default_factory=XYCoord)
|
||||
end: Rename(XYCoord) = field(default_factory=XYCoord)
|
||||
stroke: Stroke = field(default_factory=Stroke)
|
||||
fill: Fill = field(default_factory=Fill)
|
||||
|
||||
# TODO add function to calculate center, bounding box
|
||||
|
||||
|
||||
@sexp_type('polyline')
|
||||
class Polyline:
|
||||
pts: PointList = field(default_factory=PointList)
|
||||
stroke: Stroke = field(default_factory=Stroke)
|
||||
fill: Fill = field(default_factory=Fill)
|
||||
|
||||
@property
|
||||
def points(self):
|
||||
return self.pts.xy
|
||||
|
||||
@points.setter
|
||||
def points(self, value):
|
||||
self.pts.xy = value
|
||||
|
||||
@property
|
||||
def closed(self):
|
||||
# if the last and first point are the same, we consider the polyline closed
|
||||
# a closed triangle will have 4 points (A-B-C-A) stored in the list of points
|
||||
return len(self.points) > 3 and self.points[0] == self.points[-1]
|
||||
|
||||
@property
|
||||
def bbox(self):
|
||||
if not self.points:
|
||||
return (0.0, 0.0, 0.0, 0.0)
|
||||
|
||||
return (min(p.x for p in self.points),
|
||||
min(p.y for p in self.points),
|
||||
max(p.x for p in self.points),
|
||||
max(p.y for p in self.points))
|
||||
|
||||
def as_rectangle(self):
|
||||
(maxx, maxy, minx, miny) = self.get_boundingbox()
|
||||
return Rectangle(
|
||||
minx,
|
||||
maxy,
|
||||
maxx,
|
||||
miny,
|
||||
self.stroke_width,
|
||||
self.stroke_color,
|
||||
self.fill_type,
|
||||
self.fill_color,
|
||||
unit=self.unit,
|
||||
demorgan=self.demorgan,
|
||||
)
|
||||
|
||||
def get_center_of_boundingbox(self):
|
||||
(maxx, maxy, minx, miny) = self.get_boundingbox()
|
||||
return ((minx + maxx) / 2, ((miny + maxy) / 2))
|
||||
|
||||
def is_rectangle(self):
|
||||
# a rectangle has 5 points and is closed
|
||||
if len(self.points) != 5 or not self.is_closed():
|
||||
return False
|
||||
|
||||
# construct lines between the points
|
||||
p0 = self.points[0]
|
||||
for p1_idx in range(1, len(self.points)):
|
||||
p1 = self.points[p1_idx]
|
||||
dx = p1.x - p0.x
|
||||
dy = p1.y - p0.y
|
||||
if dx != 0 and dy != 0:
|
||||
# if a line is neither horizontal or vertical its not
|
||||
# part of a rectangle
|
||||
return False
|
||||
# select next point
|
||||
p0 = p1
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@sexp_type('at')
|
||||
class TextPos(XYCoord):
|
||||
x: float = 0 # in millimeter
|
||||
y: float = 0 # in millimeter
|
||||
rotation: int = 0 # in degrees
|
||||
|
||||
def __after_parse__(self, parent):
|
||||
self.rotation = self.rotation / 10
|
||||
|
||||
def __before_sexp__(self):
|
||||
self.rotation = round((self.rotation % 360) * 10)
|
||||
|
||||
@property
|
||||
def rotation_rad(self):
|
||||
return math.radians(self.rotation)
|
||||
|
||||
@rotation_rad.setter
|
||||
def rotation_rad(self, value):
|
||||
self.rotation = math.degrees(value)
|
||||
|
||||
|
||||
@sexp_type('text')
|
||||
class Text:
|
||||
text: str = None
|
||||
at: TextPos = field(default_factory=TextPos)
|
||||
rotation: float = None
|
||||
effects: TextEffect = field(default_factory=TextEffect)
|
||||
|
||||
|
||||
@sexp_type('rectangle')
|
||||
class Rectangle:
|
||||
"""
|
||||
Some v6 symbols use rectangles, newer ones encode them as polylines.
|
||||
At some point in time we can most likely remove this class since its not used anymore
|
||||
"""
|
||||
|
||||
start: Rename(XYCoord) = None
|
||||
end: Rename(XYCoord) = None
|
||||
stroke: Stroke = field(default_factory=Stroke)
|
||||
fill: Fill = field(default_factory=Fill)
|
||||
|
||||
def as_polyline(self):
|
||||
x1, y1 = self.start
|
||||
x2, y2 = self.end
|
||||
return Polyline([Point(x1, y1), Point(x2, y1), Point(x2, y2), Point(x1, y2), Point(x1, y1)],
|
||||
self.stroke, self.fill)
|
||||
|
||||
|
||||
@sexp_type('property')
|
||||
class Property:
|
||||
name: str = None
|
||||
value: str = None
|
||||
id: Named(int) = None
|
||||
at: AtPos = field(default_factory=AtPos)
|
||||
effects: TextEffect = field(default_factory=TextEffect)
|
||||
|
||||
|
||||
@sexp_type('pin_numbers')
|
||||
class PinNumberSpec:
|
||||
hide: Flag() = False
|
||||
|
||||
|
||||
@sexp_type('pin_names')
|
||||
class PinNameSpec:
|
||||
offset: OmitDefault(Named(float)) = 0.508
|
||||
hide: Flag() = False
|
||||
|
||||
|
||||
@sexp_type('symbol')
|
||||
class Unit:
|
||||
name: str = None
|
||||
circles: List(Circle) = field(default_factory=list)
|
||||
arcs: List(Arc) = field(default_factory=list)
|
||||
polylines: List(Polyline) = field(default_factory=list)
|
||||
rectangles: List(Rectangle) = field(default_factory=list)
|
||||
texts: List(Text) = field(default_factory=list)
|
||||
pins: List(Pin) = field(default_factory=list)
|
||||
unit_name: Named(str) = None
|
||||
_ : SEXP_END = None
|
||||
global_units: list = field(default_factory=list)
|
||||
unit_global: Flag() = False
|
||||
style_global: Flag() = False
|
||||
demorgan_style: int = 1
|
||||
unit_index: int = 1
|
||||
symbol = None
|
||||
|
||||
def __after_parse__(self, parent):
|
||||
self.symbol = parent
|
||||
|
||||
if not (m := re.fullmatch(r'(.*)_([0-9]+)_([0-9]+)', self.name)):
|
||||
raise FormatError(f'Invalid unit name "{self.name}"')
|
||||
sym_name, unit_index, demorgan_style = m.groups()
|
||||
if sym_name != self.symbol.name:
|
||||
raise FormatError(f'Unit name "{self.name}" does not match symbol name "{self.symbol.name}"')
|
||||
self.demorgan_style = int(demorgan_style)
|
||||
self.unit_index = int(unit_index)
|
||||
self.style_global = self._demorgan_style == 0
|
||||
self.unit_global = self.unit_index == 0
|
||||
|
||||
def __before_sexp__(self):
|
||||
self.name = f'{self.symbol.name}_{self.unit_index}_{self.demorgan_style}'
|
||||
|
||||
def __getattr__(self, name):
|
||||
if name.startswith('all_'):
|
||||
name = name[4:]
|
||||
return itertools.chain(getattr(self.global_units, name, []), getattr(self, name, []))
|
||||
|
||||
def pin_stacks(self):
|
||||
stacks = defaultdict(lambda: set())
|
||||
for pin in self.all_pins():
|
||||
stacks[(pin.at.x, pin.at.y)].add(pin)
|
||||
return stacks
|
||||
|
||||
|
||||
@sexp_type('symbol')
|
||||
class Symbol:
|
||||
name: str = None
|
||||
extends: Named(str) = None
|
||||
power: Wrap(Flag()) = False
|
||||
pin_numbers: OmitDefault(PinNumberSpec) = field(default_factory=PinNumberSpec)
|
||||
pin_names: OmitDefault(PinNameSpec) = field(default_factory=PinNameSpec)
|
||||
in_bom: Named(YesNoAtom()) = True
|
||||
on_board: Named(YesNoAtom()) = True
|
||||
properties: List(Property) = field(default_factory=list)
|
||||
raw_units: List(Unit) = field(default_factory=list)
|
||||
_ : SEXP_END = None
|
||||
styles: {str: {str: Unit}} = None
|
||||
global_units: {str: {str: Unit}} = None
|
||||
library = None
|
||||
|
||||
def __after_parse__(self, parent):
|
||||
self.library = parent
|
||||
|
||||
self.global_units = {}
|
||||
self.styles = {}
|
||||
|
||||
if self.extends:
|
||||
self.in_bom = None
|
||||
self.on_board = None
|
||||
|
||||
self.properties = {prop.name: prop for prop in self.properties}
|
||||
if (prop := self.properties.get('ki_fp_filters')):
|
||||
prop.value = prop.value.split() if prop.value else []
|
||||
|
||||
for unit in self.raw_units:
|
||||
if unit.unit_global or unit.style_global:
|
||||
d = self.global_units.get(unit.demorgan_style, {})
|
||||
d[unit.name] = unit
|
||||
self.global_units[unit.demorgan_style] = d
|
||||
|
||||
for other in self.raw_units:
|
||||
if other.unit_global or other.style_global or other == unit:
|
||||
continue
|
||||
if not (unit.unit_global or other.name == unit.name):
|
||||
continue
|
||||
if not (unit.style_global or other.demorgan_style == unit.demorgan_style):
|
||||
continue
|
||||
other.global_units.append(unit)
|
||||
|
||||
else:
|
||||
d = self.styles.get(unit.demorgan_style, {})
|
||||
d[unit.name] = unit
|
||||
self.styles[unit.demorgan_style] = d
|
||||
|
||||
def __before_sexp__(self):
|
||||
self.raw_units = ([unit for style in self.global_units.values() for unit in style.values()] +
|
||||
[unit for style in self.styles.values() for unit in style.values()])
|
||||
if (prop := self.properties.get('ki_fp_filters')):
|
||||
if not isinstance(prop.value, str):
|
||||
prop.value = ' '.join(prop.value)
|
||||
self.properties = list(self.properties.values())
|
||||
|
||||
def default_properties(self):
|
||||
for i, (name, value, hide) in enumerate([
|
||||
('Reference', 'U', False),
|
||||
('Value', None, False),
|
||||
('Footprint', None, True),
|
||||
('Datasheet', None, True),
|
||||
('ki_locked', None, True),
|
||||
('ki_keywords', None, True),
|
||||
('ki_description', None, True),
|
||||
('ki_fp_filters', None, False),
|
||||
]):
|
||||
self.properties[name] = Property(name=name, value=value, id=i, effects=TextEffect(hide=hide))
|
||||
|
||||
def units(self, demorgan_style=None):
|
||||
if self.extends:
|
||||
return self.library[self.extends].units(demorgan_style)
|
||||
else:
|
||||
return self.styles.get(demorgan_style or 'default', {})
|
||||
|
||||
def get_center_rectangle(self, units):
|
||||
# return a polyline for the requested unit that is a rectangle
|
||||
# and is closest to the center
|
||||
candidates = {}
|
||||
# building a dict with floats as keys.. there needs to be a rule against that^^
|
||||
pl_rects = [i.as_polyline() for i in self.rectangles]
|
||||
pl_rects.extend(pl for pl in self.polylines if pl.is_rectangle())
|
||||
for pl in pl_rects:
|
||||
if pl.unit in units:
|
||||
# extract the center, calculate the distance to origin
|
||||
(x, y) = pl.get_center_of_boundingbox()
|
||||
dist = math.sqrt(x * x + y * y)
|
||||
candidates[dist] = pl
|
||||
|
||||
if candidates:
|
||||
# sort the list return the first (smallest) item
|
||||
return candidates[sorted(candidates.keys())[0]]
|
||||
return None
|
||||
|
||||
def is_graphic_symbol(self):
|
||||
return self.extends is None and (
|
||||
not self.pins or self.get_property("Reference").value == "#SYM"
|
||||
)
|
||||
|
||||
def pins_by_name(self, demorgan_style=None):
|
||||
pins = defaultdict(lambda: set())
|
||||
for unit in self.units(demorgan_style):
|
||||
for pin in unit.all_pins:
|
||||
pins[pin.name].add(pin)
|
||||
return pins
|
||||
|
||||
def pins_by_number(self, demorgan_style=None):
|
||||
pins = defaultdict(lambda: set())
|
||||
for unit in self.units(demorgan_style):
|
||||
for pin in unit.all_pins:
|
||||
pins[pin.number].add(pin)
|
||||
return pins
|
||||
|
||||
def __getattr__(self, name):
|
||||
if name.startswith('all_'):
|
||||
return itertools.chain(getattr(unit, name) for unit in self.raw_units)
|
||||
|
||||
def filter_pins(self, name=None, direction=None, electrical_type=None):
|
||||
for pin in self.all_pins:
|
||||
if name and not fnmatch(pin.name, name):
|
||||
continue
|
||||
if direction and not pin.direction in direction:
|
||||
continue
|
||||
if electrical_type and not pin.etype in electical_type:
|
||||
continue
|
||||
yield pin
|
||||
|
||||
def heuristically_small(self):
|
||||
""" Heuristically try to determine whether this is a "small" component like a resistor, capacitor, LED, diode,
|
||||
or transistor etc. When we have at most two pins, or there is no filled rectangle as symbol outline and we have
|
||||
3 or 4 pins, we assume this is a small symbol.
|
||||
"""
|
||||
if len(self.all_pins) <= 2:
|
||||
return True
|
||||
if len(self.all_pins) > 4:
|
||||
return False
|
||||
return bool(self.get_center_rectangle(range(self.unit_count)))
|
||||
|
||||
|
||||
SUPPORTED_FILE_FORMAT_VERSIONS = [20211014, 20220914]
|
||||
@sexp_type('kicad_symbol_lib')
|
||||
class Library:
|
||||
_version: Named(int, name='version') = 20211014
|
||||
generator: Named(Atom) = Atom.kicad_library_utils
|
||||
symbols: List(Symbol) = field(default_factory=list)
|
||||
_ : SEXP_END = None
|
||||
original_filename: str = 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))}.')
|
||||
|
||||
@classmethod
|
||||
def open(cls, filename: str):
|
||||
with open(filename) as f:
|
||||
return cls.parse(f.read())
|
||||
|
||||
def write(self, filename=None):
|
||||
with open(filename or self.original_filename, 'w') as f:
|
||||
f.write(build_sexp(sexp(self)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) >= 2:
|
||||
a = Library.open(sys.argv[1])
|
||||
print(build_sexp(sexp(a)))
|
||||
else:
|
||||
print("pass a .kicad_sym file please")
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
|
@ -33,3 +34,30 @@ def pytest_sessionstart(session):
|
|||
run_cargo_cmd('resvg', '--help')
|
||||
except FileNotFoundError:
|
||||
pytest.exit('resvg binary not found, aborting test.', 2)
|
||||
|
||||
def pytest_addoption(parser):
|
||||
parser.addoption('--kicad-symbol-library', nargs='*', help='Run symbol library tests on given symbol libraries. May be given multiple times.')
|
||||
parser.addoption('--kicad-footprint-files', nargs='*', help='Run footprint library tests on given footprint files. May be given multiple times.')
|
||||
|
||||
def pytest_generate_tests(metafunc):
|
||||
if 'kicad_library_file' in metafunc.fixturenames:
|
||||
if not (library_files := metafunc.config.getoption('symbol_library', None)):
|
||||
if (lib_dir := os.environ.get('KICAD_SYMBOLS')):
|
||||
lib_dir = Path(lib_dir).expanduser()
|
||||
if not lib_dir.is_dir():
|
||||
raise ValueError(f'Path "{lib_dir}" given by KICAD_SYMBOLS environment variable does not exist or is not a directory.')
|
||||
library_files = list(lib_dir.glob('*.kicad_sym'))
|
||||
else:
|
||||
raise ValueError('Either --kicad-symbol-library command line parameter or KICAD_SYMBOLS environment variable must be given.')
|
||||
metafunc.parametrize('kicad_library_file', library_files, ids=list(map(str, library_files)))
|
||||
|
||||
if 'kicad_mod_file' in metafunc.fixturenames:
|
||||
if not (mod_files := metafunc.config.getoption('footprint_files', None)):
|
||||
if (lib_dir := os.environ.get('KICAD_FOOTPRINTS')):
|
||||
lib_dir = Path(lib_dir).expanduser()
|
||||
if not lib_dir.is_dir():
|
||||
raise ValueError(f'Path "{lib_dir}" given by KICAD_FOOTPRINTS environment variable does not exist or is not a directory.')
|
||||
mod_files = list(lib_dir.glob('**/*.kicad_mod'))
|
||||
else:
|
||||
raise ValueError('Either --kicad-footprint-files command line parameter or KICAD_FOOTPRINTS environment variable must be given.')
|
||||
metafunc.parametrize('kicad_mod_file', mod_files, ids=list(map(str, mod_files)))
|
||||
|
|
|
|||
57
gerbonara/tests/test_kicad_footprints.py
Normal file
57
gerbonara/tests/test_kicad_footprints.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
|
||||
from itertools import zip_longest
|
||||
import re
|
||||
|
||||
from ..cad.kicad.sexp import build_sexp
|
||||
from ..cad.kicad.sexp_mapper import sexp
|
||||
from ..cad.kicad.footprints import Footprint
|
||||
|
||||
def test_parse(kicad_mod_file):
|
||||
Footprint.open(kicad_mod_file)
|
||||
|
||||
def test_round_trip(kicad_mod_file):
|
||||
print('========== Stage 1 load ==========')
|
||||
orig_fp = Footprint.open(kicad_mod_file)
|
||||
print('========== Stage 1 save ==========')
|
||||
stage1_sexp = build_sexp(orig_fp.sexp())
|
||||
with open('/tmp/foo.sexp', 'w') as f:
|
||||
f.write(stage1_sexp)
|
||||
|
||||
print('========== Stage 2 load ==========')
|
||||
reparsed_fp = Footprint.parse(stage1_sexp)
|
||||
print('========== Stage 2 save ==========')
|
||||
stage2_sexp = build_sexp(reparsed_fp.sexp())
|
||||
print('========== Checks ==========')
|
||||
|
||||
for stage1, stage2 in zip_longest(stage1_sexp.splitlines(), stage2_sexp.splitlines()):
|
||||
assert stage1 == stage2
|
||||
|
||||
return
|
||||
|
||||
original = re.sub(r'\(', '\n(', re.sub(r'\s+', ' ', kicad_mod_file.read_text()))
|
||||
original = re.sub(r'\) \)', '))', original)
|
||||
original = re.sub(r'\) \)', '))', original)
|
||||
original = re.sub(r'\) \)', '))', original)
|
||||
original = re.sub(r'\) \)', '))', original)
|
||||
stage1 = re.sub(r'\(', '\n(', re.sub(r'\s+', ' ', stage1_sexp))
|
||||
for original, stage1 in zip_longest(original.splitlines(), stage1.splitlines()):
|
||||
if original.startswith('(version'):
|
||||
continue
|
||||
|
||||
original, stage1 = original.strip(), stage1.strip()
|
||||
if original != stage1:
|
||||
if any(original.startswith(f'({foo}') for foo in ['arc', 'circle', 'rectangle', 'polyline', 'text']):
|
||||
# These files have symbols with graphic primitives in non-standard order
|
||||
return
|
||||
|
||||
if original.startswith('(symbol') and stage1.startswith('(symbol'):
|
||||
# Re-export can change symbol order. This is ok.
|
||||
return
|
||||
|
||||
if original.startswith('(at') and stage1.startswith('(at'):
|
||||
# There is some disagreement as to whether rotation angles are ints or floats, and the spec doesn't say.
|
||||
return
|
||||
|
||||
assert original == stage1
|
||||
|
||||
|
||||
26
gerbonara/tests/test_kicad_sexpr.py
Normal file
26
gerbonara/tests/test_kicad_sexpr.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
|
||||
from ..cad.kicad.sexp import parse_sexp, build_sexp
|
||||
|
||||
def test_sexp_round_trip():
|
||||
test_sexp = '''(()() (foo) (23)\t(foo 23) (foo 23 bar baz) (foo bar baz) ("foo bar") (" foo " bar) (23 " baz ")
|
||||
(foo ( bar ( baz 23) 42) frob) (() (foo) ()()) foo 23 23.0 23.000001 "foo \\"( ))bar" "foo\\"bar\\"baz" "23" "23foo"
|
||||
"" "" ("") ("" 0 0.0)
|
||||
"lots of data" "lots of data" "lots of data" "lots of data" "lots of data" "lots of data"
|
||||
"lots of data" "lots of data" "lots of data" "lots of data" "lots of data" "lots of data"
|
||||
"lots of data" "lots of data" "lots of data" "lots of data" "lots of data" "lots of data"
|
||||
"lots of data" "lots of data" "lots of data" "lots of data" "lots of data" "lots of data"
|
||||
"lots of data" "lots of data" "lots of data" "lots of data" "lots of data" "lots of data"
|
||||
"lots of data" "lots of data" "lots of data" "lots of data" "lots of data" "lots of data"
|
||||
"lots of data" "lots of data" "lots of data" "lots of data" "lots of data" "lots of data"
|
||||
"lots of data" "lots of data" "lots of data" "lots of data" "lots of data" "lots of data"
|
||||
"lots of data" "lots of data" "lots of data" "lots of data" "lots of data" "lots of data"
|
||||
"lots of data" "lots of data" "lots of data" "lots of data" "lots of data" "lots of data")
|
||||
'''
|
||||
parsed = parse_sexp(test_sexp)
|
||||
sexp1 = build_sexp(parsed)
|
||||
re_parsed = parse_sexp(sexp1)
|
||||
sexp2 = build_sexp(parsed)
|
||||
|
||||
assert re_parsed == parsed
|
||||
assert sexp1 == sexp2
|
||||
|
||||
59
gerbonara/tests/test_kicad_symbols.py
Normal file
59
gerbonara/tests/test_kicad_symbols.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
|
||||
from itertools import zip_longest
|
||||
import re
|
||||
|
||||
from ..cad.kicad.sexp import build_sexp
|
||||
from ..cad.kicad.sexp_mapper import sexp
|
||||
from ..cad.kicad.symbols import Library
|
||||
|
||||
|
||||
def test_parse(kicad_library_file):
|
||||
Library.open(kicad_library_file)
|
||||
|
||||
|
||||
def test_round_trip(kicad_library_file):
|
||||
print('========== Stage 1 load ==========')
|
||||
orig_lib = Library.open(kicad_library_file)
|
||||
print('========== Stage 1 save ==========')
|
||||
stage1_sexp = build_sexp(orig_lib.sexp())
|
||||
|
||||
print('========== Stage 2 load ==========')
|
||||
reparsed_lib = Library.parse(stage1_sexp)
|
||||
print('========== Stage 2 save ==========')
|
||||
stage2_sexp = build_sexp(reparsed_lib.sexp())
|
||||
print('========== Checks ==========')
|
||||
|
||||
for stage1, stage2 in zip_longest(stage1_sexp.splitlines(), stage2_sexp.splitlines()):
|
||||
assert stage1 == stage2
|
||||
|
||||
original = re.sub(r'\(', '\n(', re.sub(r'\s+', ' ', kicad_library_file.read_text()))
|
||||
original = re.sub(r'\) \)', '))', original)
|
||||
original = re.sub(r'\) \)', '))', original)
|
||||
original = re.sub(r'\) \)', '))', original)
|
||||
original = re.sub(r'\) \)', '))', original)
|
||||
stage1 = re.sub(r'\(', '\n(', re.sub(r'\s+', ' ', stage1_sexp))
|
||||
for original, stage1 in zip_longest(original.splitlines(), stage1.splitlines()):
|
||||
if original.startswith('(version'):
|
||||
continue
|
||||
|
||||
original, stage1 = original.strip(), stage1.strip()
|
||||
if original != stage1:
|
||||
if any(original.startswith(f'({foo}') for foo in ['arc', 'circle', 'rectangle', 'polyline', 'text']):
|
||||
# These files have symbols with graphic primitives in non-standard order
|
||||
return
|
||||
|
||||
if original.startswith('(offset') and stage1.startswith('(offset'):
|
||||
# Some symbol files contain ints where floats should be.
|
||||
return
|
||||
|
||||
if original.startswith('(symbol') and stage1.startswith('(symbol'):
|
||||
# Re-export can change symbol order. This is ok.
|
||||
return
|
||||
|
||||
if original.startswith('(at') and stage1.startswith('(at'):
|
||||
# There is some disagreement as to whether rotation angles are ints or floats, and the spec doesn't say.
|
||||
return
|
||||
|
||||
assert original == stage1
|
||||
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue