kicad: Improve API and fix kicad-nightly compat

This commit is contained in:
jaseg 2023-07-17 23:23:19 +02:00
parent 860fa4c53b
commit 08c4091e57
6 changed files with 268 additions and 26 deletions

View file

@ -2,12 +2,14 @@ from .sexp import *
from .sexp_mapper import *
import time
from dataclasses import field
from dataclasses import field, replace
import math
import uuid
from contextlib import contextmanager
from itertools import cycle
from ...utils import rotate_point
LAYER_MAP_K2G = {
'F.Cu': ('top', 'copper'),
@ -144,9 +146,27 @@ class XYCoord:
x: float = 0
y: float = 0
def __init__(self, x=0, y=0):
if isinstance(x, XYCoord):
self.x, self.y = x.x, x.y
elif isinstance(x, (tuple, list)):
self.x, self.y = x
elif hasattr(x, 'abs_pos'):
self.x, self.y, _1, _2 = x.abs_pos
elif hasattr(x, 'at'):
self.x, self.y = x.at.x, x.at.y
else:
self.x, self.y = x, y
def isclose(self, other, tol=1e-6):
return math.isclose(self.x, other.x, tol) and math.isclose(self.y, other.y, tol)
def with_offset(self, x=0, y=0):
return replace(self, x=self.x+x, y=self.y+y)
def with_rotation(self, angle, cx=0, cy=0):
x, y = rotate_point(self.x, self.y, angle, cx, cy)
return replace(self, x=x, y=y)
@sexp_type('pts')
class PointList:
@ -178,6 +198,10 @@ class AtPos(XYCoord):
def rotation_rad(self, value):
self.rotation = math.degrees(value)
def with_rotation(self, angle, cx=0, cy=0):
obj = super().with_rotation(angle, cx, cy)
return replace(obj, rotation=self.rotation + angle)
@sexp_type('font')
class FontSpec:
@ -206,6 +230,9 @@ class TextEffect:
class Timestamp:
value: str = field(default_factory=uuid.uuid4)
def __deepcopy__(self, memo):
return Timestamp()
def __after_parse__(self, parent):
self.value = str(self.value)
@ -219,6 +246,9 @@ class Timestamp:
class UUID:
value: str = field(default_factory=uuid.uuid4)
def __deepcopy__(self, memo):
return UUID()
def __after_parse__(self, parent):
self.value = str(self.value)
@ -232,6 +262,9 @@ class UUID:
class EditTime:
value: str = field(default_factory=time.time)
def __deepcopy__(self, memo):
return EditTime()
def __after_parse__(self, parent):
self.value = int(str(self.value), 16)

View file

@ -402,13 +402,16 @@ class Pad:
@property
def abs_pos(self):
if self.footprint:
px, py = self.footprint.at.x, self.footprint.at.y
px, py, pr = self.footprint.at.x, self.footprint.at.y, self.footprint.at.rotation
else:
px, py = 0, 0
px, py, pr = 0, 0, 0
x, y = rotate_point(self.at.x, self.at.y, -math.radians(self.at.rotation))
x, y = rotate_point(self.at.x, self.at.y, math.radians(pr))
return x+px, y+py, self.at.rotation, False
def offset(self, x=0, y=0):
self.at = self.at.with_offset(x, y)
def find_connected(self, **filters):
""" Find footprints connected to the same net as this pad """
return self.footprint.board.find_footprints(net=self.net.name, **filters)
@ -630,7 +633,6 @@ class Footprint:
_bounding_box: tuple = None
board: object = None
def __after_parse__(self, parent):
for pad in self.pads:
pad.footprint = self
@ -667,7 +669,6 @@ class Footprint:
def find_pads(self, number=None, net=None):
for pad in self.pads:
if number is not None and pad.number == str(number):
print('find_pads', number, net, pad.number)
yield pad
elif isinstance(net, str) and fnmatch.fnmatch(pad.net.name, net):
yield pad
@ -684,10 +685,18 @@ class Footprint:
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')
@ -708,15 +717,10 @@ class Footprint:
def value(self):
return self.property_value('Value')
@reference.setter
@value.setter
def value(self, value):
self.set_property('Value', value)
@version.setter
def version(self, value):
if value not in SUPPORTED_FILE_FORMAT_VERSIONS:
raise FormatError(f'File format version {value} is not supported. Supported versions are {", ".join(map(str, SUPPORTED_FILE_FORMAT_VERSIONS))}.')
def write(self, filename=None):
with open(filename or self.original_filename, 'w') as f:
f.write(self.serialize())
@ -745,6 +749,42 @@ class Footprint:
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()
@ -786,9 +826,16 @@ class Footprint:
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)
@ -797,7 +844,13 @@ class Footprint:
for pad in self.pads:
pad.at.rotation = (pad.at.rotation + delta) % 360
def objects(self, text=False, pads=True):
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 []),
@ -808,7 +861,9 @@ class Footprint:
self.polygons,
self.curves,
(self.dimensions if text else []),
(self.pads if pads 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

View file

@ -66,6 +66,9 @@ class Text:
for p1, p2 in zip(out[:-1], out[1:]):
yield go.Line(*p1, *p2, aperture=aperture, unit=MM)
def offset(self, x=0, y=0):
self.at = self.at.with_offset(x, y)
@sexp_type('gr_text_box')
class TextBox:
@ -100,6 +103,10 @@ class TextBox:
yield reg
def offset(self, x=0, y=0):
self.start = self.start.with_offset(x, y)
self.end = self.end.with_offset(x, y)
@sexp_type('gr_line')
class Line:
@ -123,6 +130,10 @@ class Line:
yield go.Line(x1, y1, x2, y2, aperture=ap.CircleAperture(dasher.width, unit=MM), unit=MM)
# FIXME render all primitives using dasher, maybe share code w/ fp_ prefix primitives
def offset(self, x=0, y=0):
self.start = self.start.with_offset(x, y)
self.end = self.end.with_offset(x, y)
@sexp_type('fill')
class FillMode:
@ -159,6 +170,15 @@ class Rectangle:
# FIXME stroke support
yield from rect.outline_objects(aperture=ap.CircleAperture(self.width, unit=MM))
@property
def top_left(self):
return ((min(self.start.x, self.end.x), min(self.start.y, self.end.y)),
(max(self.start.x, self.end.x), max(self.start.y, self.end.y)))
def offset(self, x=0, y=0):
self.start = self.start.with_offset(x, y)
self.end = self.end.with_offset(x, y)
@sexp_type('gr_circle')
class Circle:
@ -182,6 +202,10 @@ class Circle:
if self.fill:
yield arc.to_region()
def offset(self, x=0, y=0):
self.center = self.center.with_offset(x, y)
self.end = self.end.with_offset(x, y)
@sexp_type('gr_arc')
class Arc:
@ -204,6 +228,11 @@ class Arc:
x2, y2 = self.end.x, self.end.y
yield go.Arc(x1, y1, x2, y2, cx-x1, cy-y1, aperture=aperture, clockwise=True, unit=MM)
def offset(self, x=0, y=0):
self.start = self.start.with_offset(x, y)
self.mid = self.mid.with_offset(x, y)
self.end = self.end.with_offset(x, y)
@sexp_type('gr_poly')
class Polygon:
@ -224,6 +253,9 @@ class Polygon:
if self.fill:
yield reg
def offset(self, x=0, y=0):
self.pts = PointList([pt.with_offset(x, y) for pt in self.pts])
@sexp_type('gr_curve')
class Curve:
@ -235,6 +267,9 @@ class Curve:
def render(self, variables=None):
raise NotImplementedError('Bezier rendering is not yet supported. Please raise an issue and provide an example file.')
def offset(self, x=0, y=0):
self.pts = PointList([pt.with_offset(x, y) for pt in self.pts])
@sexp_type('gr_bbox')
class AnnotationBBox:
@ -244,6 +279,10 @@ class AnnotationBBox:
def render(self, variables=None):
return []
def offset(self, x=0, y=0):
self.start = self.start.with_offset(x, y)
self.end = self.end.with_offset(x, y)
@sexp_type('format')
class DimensionFormat:
@ -273,7 +312,7 @@ class Dimension:
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)
pts: PointList = field(default_factory=PointList)
height: Named(float) = None
orientation: Named(int) = None
leader_length: Named(float) = None
@ -284,4 +323,6 @@ class Dimension:
def render(self, variables=None):
raise NotImplementedError('Dimension rendering is not yet supported. Please raise an issue.')
def offset(self, x=0, y=0):
self.pts = PointList([pt.with_offset(x, y) for pt in self.pts])

View file

@ -4,7 +4,7 @@ Library for handling KiCad's PCB files (`*.kicad_mod`).
import math
from pathlib import Path
from dataclasses import field
from dataclasses import field, KW_ONLY
from itertools import chain
import re
import fnmatch
@ -166,6 +166,9 @@ class Image:
uuid: UUID = field(default_factory=UUID)
data: str = ''
def offset(self, x=0, y=0):
self.at = self.at.with_offset(x, y)
@sexp_type('segment')
class TrackSegment:
@ -177,6 +180,10 @@ class TrackSegment:
net: Named(int) = 0
tstamp: Timestamp = field(default_factory=Timestamp)
def __post_init__(self):
self.start = XYCoord(self.start)
self.end = XYCoord(self.end)
def render(self, variables=None, cache=None):
if not self.width:
return
@ -191,6 +198,10 @@ class TrackSegment:
self.start.x, self.start.y = rotate_point(self.start.x, self.start.y, angle, cx, cy)
self.end.x, self.end.y = rotate_point(self.end.x, self.end.y, angle, cx, cy)
def offset(self, x=0, y=0):
self.start = self.start.with_offset(x, y)
self.end = self.end.with_offset(x, y)
@sexp_type('arc')
class TrackArc:
@ -202,6 +213,29 @@ class TrackArc:
locked: Flag() = False
net: Named(int) = 0
tstamp: Timestamp = field(default_factory=Timestamp)
_: KW_ONLY
center: XYCoord = None
def __post_init__(self):
self.start = XYCoord(self.start)
self.end = XYCoord(self.end)
if self.center is not None:
# Convert normal p1/p2/center notation to the insanity that is kicad's midpoint notation
center = XYCoord(self.center)
cx, cy = center.x, center.y
x1, y1 = self.start.x - cx, self.start.y - cy
x2, y2 = self.end.x - cx, self.end.y - cy
# Get a vector pointing towards the middle between "start" and "end"
dx, dy = (x1 + x2)/2, (y1 + y2)/2
# normalize vector, and multiply by radius to get final point
r = math.hypot(x1, y1)
l = math.hypot(dx, dy)
mx = cx + dx / l * r
my = cy + dy / l * r
self.mid = XYCoord(mx, my)
self.center = None
else:
self.mid = XYCoord(self.mid)
def render(self, variables=None, cache=None):
if not self.width:
@ -221,6 +255,11 @@ class TrackArc:
self.mid.x, self.mid.y = rotate_point(self.mid.x, self.mid.y, angle, cx, cy)
self.end.x, self.end.y = rotate_point(self.end.x, self.end.y, angle, cx, cy)
def offset(self, x=0, y=0):
self.start = self.start.with_offset(x, y)
self.mid = self.mid.with_offset(x, y)
self.end = self.end.with_offset(x, y)
@sexp_type('via')
class Via:
@ -229,13 +268,20 @@ class Via:
at: Rename(XYCoord) = field(default_factory=XYCoord)
size: Named(float) = 0.8
drill: Named(float) = 0.4
layers: Named(Array(str)) = field(default_factory=list)
layers: Named(Array(str)) = field(default_factory=lambda: ['F.Cu', 'B.Cu'])
remove_unused_layers: Flag() = False
keep_end_layers: Flag() = False
free: Wrap(Flag()) = False
net: Named(int) = 0
tstamp: Timestamp = field(default_factory=Timestamp)
@property
def abs_pos(self):
return self.at.x, self.at.y, 0, False
def __post_init__(self):
self.at = XYCoord(self.at)
def render_drill(self):
aperture = ap.ExcellonTool(self.drill, plated=True, unit=MM)
yield go.Flash(self.at.x, self.at.y, aperture=aperture, unit=MM)
@ -244,6 +290,15 @@ class Via:
aperture = ap.CircleAperture(self.size, unit=MM)
yield go.Flash(self.at.x, self.at.y, aperture, unit=MM)
def rotate(self, angle, cx=None, cy=None):
if cx is None or cy is None:
return
self.at.x, self.at.y = rotate_point(self.at.x, self.at.y, angle, cx, cy)
def offset(self, x=0, y=0):
self.at = self.at.with_offset(x, y)
SUPPORTED_FILE_FORMAT_VERSIONS = [20210108, 20211014, 20221018, 20230517]
@sexp_type('kicad_pcb')
@ -293,6 +348,43 @@ class Board:
self.properties = [Property(key, value) for key, value in self.properties.items()]
self.nets = [Net(index, name) for index, name in self.nets.items()]
def remove(self, obj):
match obj:
case gr.Text():
self.texts.remove(obj)
case gr.TextBox():
self.text_boxes.remove(obj)
case gr.Line():
self.lines.remove(obj)
case gr.Rectangle():
self.rectangles.remove(obj)
case gr.Circle():
self.circles.remove(obj)
case gr.Arc():
self.arcs.remove(obj)
case gr.Polygon():
self.polygons.remove(obj)
case gr.Curve():
self.curves.remove(obj)
case gr.Dimension():
self.dimensions.remove(obj)
case Image():
self.images.remove(obj)
case TrackSegment():
self.track_segments.remove(obj)
case TrackArc():
self.track_arcs.remove(obj)
case Via():
self.vias.remove(obj)
case Zone():
self.zones.remove(obj)
case Group():
self.groups.remove(obj)
case Footprint():
self.footprints.remove(obj)
case _:
raise TypeError('Can only remove KiCad objects, cannot map generic gerbonara.cad objects for removal')
def add(self, obj):
match obj:
case gr.Text():
@ -325,6 +417,8 @@ class Board:
self.zones.append(obj)
case Group():
self.groups.append(obj)
case Footprint():
self.footprints.append(obj)
case _:
for elem in self.map_gn_cad(obj):
self.add(elem)
@ -471,7 +565,7 @@ class Board:
def objects(self, vias=True, text=False, images=False):
return chain(self.graphic_objects(text=text, images=images), self.tracks(vias=vias))
return chain(self.graphic_objects(text=text, images=images), self.tracks(vias=vias), self.footprints, self.zones, self.groups)
def render(self, layer_stack, layer_map, x=0, y=0, rotation=0, text=False, flip=False, variables={}, cache=None):

View file

@ -49,7 +49,7 @@ class ZoneSmoothing:
@sexp_type('fill')
class ZoneFill:
yes: Flag() = False
mode: Flag(atom=Atom.hatched) = False
mode: Named(Flag(atom=Atom.hatch)) = False
thermal_gap: Named(float) = 0.508
thermal_bridge_width: Named(float) = 0.508
smoothing: ZoneSmoothing = None
@ -60,7 +60,7 @@ class ZoneFill:
hatch_orientation: Named(int) = None
hatch_smoothing_level: Named(int) = None
hatch_smoothing_value: Named(float) = None
hatch_border_algorithm: Named(int) = None
hatch_border_algorithm: Named(AtomChoice(Atom.hatch_thickness, Atom.min_thickness)) = None
hatch_min_hole_area: Named(float) = None

View file

@ -1,7 +1,8 @@
import textwrap
from dataclasses import MISSING
import copy
from dataclasses import MISSING, replace, fields
from .sexp import *
@ -121,9 +122,10 @@ class WrapperType:
return getattr(self.next_type, '__atoms__', lambda: [])()
class Named(WrapperType):
def __init__(self, next_type, name=None):
def __init__(self, next_type, name=None, omit_empty=True):
super().__init__(next_type)
self.name_atom = Atom(name) if name else None
self.omit_empty = omit_empty
def __bind_field__(self, field):
if self.next_type is not Atom:
@ -140,8 +142,13 @@ class Named(WrapperType):
def __sexp__(self, value):
value = sexp(self.next_type, value)
if value is not None:
yield [self.name_atom, *value]
if value is None:
return
if self.omit_empty and not value:
return
yield [self.name_atom, *value]
class Rename(WrapperType):
@ -389,6 +396,16 @@ class _SexpTemplate:
def sexp(self):
return next(self.__sexp__(self))
@staticmethod
def __deepcopy__(self, memo):
return replace(self, **{f.name: copy.deepcopy(getattr(self, f.name), memo) for f in fields(self) if not f.kw_only})
@staticmethod
def __copy__(self):
# Even during a shallow copy, we need to deep copy any fields whose types have a __before_sexp__ method to avoid
# those from being called more than once on the same object.
return replace(self, **{f.name: copy.copy(getattr(self, f.name)) for f in fields(self) if not f.kw_only and hasattr(f.type, '__before_sexp__')})
def sexp_type(name=None):
def register(cls):
@ -398,8 +415,10 @@ def sexp_type(name=None):
if not hasattr(cls, key):
setattr(cls, key, classmethod(getattr(_SexpTemplate, key)))
if not hasattr(cls, 'sexp'):
setattr(cls, 'sexp', getattr(_SexpTemplate, 'sexp'))
for key in 'sexp', '__deepcopy__', '__copy__':
if not hasattr(cls, key):
setattr(cls, key, getattr(_SexpTemplate, key))
cls.positional = []
cls.keys = {}
for f in fields(cls):