summaryrefslogtreecommitdiff
path: root/gerbonara/cad
diff options
context:
space:
mode:
Diffstat (limited to 'gerbonara/cad')
-rw-r--r--gerbonara/cad/kicad/base_types.py69
-rw-r--r--gerbonara/cad/kicad/footprints.py70
-rw-r--r--gerbonara/cad/kicad/graphical_primitives.py91
-rw-r--r--gerbonara/cad/kicad/pcb.py117
-rw-r--r--gerbonara/cad/kicad/primitives.py32
-rw-r--r--gerbonara/cad/kicad/schematic.py10
-rw-r--r--gerbonara/cad/kicad/sexp_mapper.py18
7 files changed, 259 insertions, 148 deletions
diff --git a/gerbonara/cad/kicad/base_types.py b/gerbonara/cad/kicad/base_types.py
index a4eba70..d800a36 100644
--- a/gerbonara/cad/kicad/base_types.py
+++ b/gerbonara/cad/kicad/base_types.py
@@ -9,7 +9,8 @@ from itertools import cycle
from .sexp import *
from .sexp_mapper import *
from ...newstroke import Newstroke
-from ...utils import rotate_point, Tag, MM
+from ...utils import rotate_point, sum_bounds, Tag, MM
+from ...layers import LayerStack
from ... import apertures as ap
from ... import graphic_objects as go
@@ -37,6 +38,16 @@ LAYER_MAP_K2G = {
LAYER_MAP_G2K = {v: k for k, v in LAYER_MAP_K2G.items()}
+class BBoxMixin:
+ def bounding_box(self, unit=MM):
+ if not hasattr(self, '_bounding_box'):
+ (min_x, min_y), (max_x, max_y) = sum_bounds(fe.bounding_box(unit) for fe in self.render())
+ # Convert back from gerbonara's coordinates to kicad coordinates.
+ self._bounding_box = (min_x, -max_y), (max_x, -min_y)
+
+ return self._bounding_box
+
+
@sexp_type('uuid')
class UUID:
value: str = field(default_factory=uuid.uuid4)
@@ -112,6 +123,14 @@ class Stroke:
return attrs
+class WidthMixin:
+ def __post_init__(self):
+ if self.width is None:
+ self.width = self.stroke.width
+ else:
+ self.stroke = Stroke(self.width)
+
+
class Dasher:
def __init__(self, obj):
if obj.stroke:
@@ -259,7 +278,14 @@ class XYCoord:
@sexp_type('pts')
class PointList:
- xy : List(XYCoord) = field(default_factory=list)
+ @classmethod
+ def __map__(kls, obj, parent=None):
+ _tag, *values = obj
+ return [map_sexp(XYCoord, elem, parent=parent) for elem in values]
+
+ @classmethod
+ def __sexp__(kls, value):
+ yield [kls.name_atom, *(e for elem in value for e in elem.__sexp__(elem))]
@sexp_type('arc')
@@ -281,6 +307,29 @@ class ArcPointList:
yield [kls.name_atom, *(e for elem in value for e in elem.__sexp__(elem))]
+@sexp_type('net')
+class Net:
+ index: int = 0
+ name: str = ''
+
+
+class NetMixin:
+ def reset_net(self):
+ self.net = Net()
+
+ @property
+ def net_index(self):
+ if self.net is None:
+ return 0
+ return self.net.index
+
+ @property
+ def net_name(self):
+ if self.net is None:
+ return ''
+ return self.net.name
+
+
@sexp_type('xyz')
class XYZCoord:
x: float = 0
@@ -316,8 +365,8 @@ class FontSpec:
face: Named(str) = None
size: Rename(XYCoord) = field(default_factory=lambda: XYCoord(1.27, 1.27))
thickness: Named(float) = None
- bold: OmitDefault(Named(YesNoAtom())) = False
- italic: OmitDefault(Named(YesNoAtom())) = False
+ bold: OmitDefault(Named(LegacyCompatibleFlag())) = False
+ italic: OmitDefault(Named(LegacyCompatibleFlag())) = False
line_spacing: Named(float) = None
@@ -345,8 +394,8 @@ class Justify:
@sexp_type('effects')
class TextEffect:
font: FontSpec = field(default_factory=FontSpec)
- hide: OmitDefault(Named(YesNoAtom())) = False
justify: OmitDefault(Justify) = field(default_factory=Justify)
+ hide: OmitDefault(Named(LegacyCompatibleFlag())) = False
class TextMixin:
@@ -523,13 +572,13 @@ class DrawnProperty(TextMixin):
key: str = None
value: str = None
id: Named(int) = None
- at: AtPos = field(default_factory=AtPos)
- unlocked: Named(YesNoAtom()) = True
+ at: AtPos = None
+ unlocked: OmitDefault(Named(YesNoAtom())) = True
layer: Named(str) = None
- hide: Named(YesNoAtom()) = False
- uuid: UUID = field(default_factory=UUID)
+ hide: OmitDefault(Named(YesNoAtom())) = False
+ uuid: UUID = None
tstamp: Timestamp = None
- effects: TextEffect = field(default_factory=TextEffect)
+ effects: OmitDefault(TextEffect) = field(default_factory=TextEffect)
_ : SEXP_END = None
parent: object = None
diff --git a/gerbonara/cad/kicad/footprints.py b/gerbonara/cad/kicad/footprints.py
index cb7b69d..1d7ee08 100644
--- a/gerbonara/cad/kicad/footprints.py
+++ b/gerbonara/cad/kicad/footprints.py
@@ -55,7 +55,7 @@ class Text:
type: AtomChoice(Atom.reference, Atom.value, Atom.user) = Atom.user
text: str = ""
at: AtPos = field(default_factory=AtPos)
- unlocked: Flag() = False
+ unlocked: OmitDefault(Named(YesNoAtom())) = False
layer: Named(str) = None
uuid: UUID = field(default_factory=UUID)
hide: Flag() = False
@@ -243,7 +243,7 @@ class Arc:
@sexp_type('fp_poly')
class Polygon:
- pts: PointList = field(default_factory=PointList)
+ pts: PointList = field(default_factory=list)
layer: Named(str) = None
uuid: UUID = field(default_factory=UUID)
width: Named(float) = None
@@ -253,13 +253,13 @@ class Polygon:
tstamp: Timestamp = None
def render(self, variables=None, cache=None):
- if len(self.pts.xy) < 2:
+ if len(self.pts) < 2:
return
dasher = Dasher(self)
- start = self.pts.xy[0]
+ start = self.pts[0]
dasher.move(start.x, start.y)
- for point in self.pts.xy[1:]:
+ for point in self.pts[1:]:
dasher.line(point.x, point.y)
if dasher.width > 0:
@@ -268,12 +268,12 @@ class Polygon:
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)
+ yield go.Region([(pt.x, -pt.y) for pt in self.pts], unit=MM)
@sexp_type('fp_curve')
class Curve:
- pts: PointList = field(default_factory=PointList)
+ pts: PointList = field(default_factory=list)
layer: Named(str) = None
uuid: UUID = field(default_factory=UUID)
width: Named(float) = None
@@ -314,7 +314,7 @@ class Dimension:
layer: Named(str) = None
uuid: UUID = field(default_factory=UUID)
tstamp: Timestamp = None
- pts: PointList = field(default_factory=PointList)
+ pts: PointList = field(default_factory=list)
height: Named(float) = None
orientation: Named(int) = 0
leader_length: Named(float) = None
@@ -334,12 +334,6 @@ class Drill:
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
@@ -376,7 +370,7 @@ class Chamfer:
@sexp_type('pad')
-class Pad:
+class Pad(NetMixin):
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
@@ -395,7 +389,7 @@ class Pad:
thermal_bridge_width: Named(float) = 0.5
chamfer_ratio: Named(float) = None
chamfer: Chamfer = None
- net: NetDef = None
+ net: Net = None
tstamp: Timestamp = None
pin_function: Named(str) = None
pintype: Named(str) = None
@@ -411,7 +405,7 @@ class Pad:
options: OmitDefault(CustomPadOptions) = None
primitives: OmitDefault(CustomPadPrimitives) = None
_: SEXP_END = None
- footprint: object = None
+ footprint: object = field(repr=False, default=None)
def __after_parse__(self, parent=None):
self.layers = unfuck_layers(self.layers)
@@ -619,6 +613,7 @@ SUPPORTED_FILE_FORMAT_VERSIONS = [20210108, 20211014, 20221018, 20230517]
class Footprint:
name: str = None
_version: Named(int, name='version') = 20221018
+ uuid: UUID = field(default_factory=UUID)
generator: Named(str) = Atom.gerbonara
generator_version: Named(str) = __version__
locked: Flag() = False
@@ -657,11 +652,11 @@ class Footprint:
pads: List(Pad) = field(default_factory=list)
zones: List(Zone) = field(default_factory=list)
groups: List(Group) = field(default_factory=list)
+ embedded_fonts: Named(YesNoAtom()) = False
models: List(Model) = field(default_factory=list)
_ : SEXP_END = None
original_filename: str = None
- _bounding_box: tuple = None
- board: object = None
+ board: object = field(repr=False, default=None)
def __after_parse__(self, parent):
for pad in self.pads:
@@ -708,6 +703,10 @@ class Footprint:
if not self.property_value('Description', None):
self.set_property('Description', self.descr or '', 0, 0, 0)
+ def reset_nets(self):
+ for pad in self.pads:
+ pad.reset_net()
+
@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}
@@ -734,6 +733,14 @@ class Footprint:
def offset(self, x=0, y=0):
self.at = self.at.with_offset(x, y)
+ def copy_placement(self, template):
+ # Fix up rotation of pads - KiCad saves each pad's rotation in *absolute* coordinates, not relative to the
+ # footprint. Because we overwrite the footprint's rotation below, we have to first fix all pads to match the
+ # new rotation.
+ self.rotate(math.radians(template.at.rotation - self.at.rotation))
+ self.at = copy.copy(template.at)
+ self.side = template.side
+
@property
def version(self):
return self._version
@@ -818,7 +825,7 @@ class Footprint:
self.layer = flip_layer(self.layer)
for obj in self.objects():
- if hasattr(obj, 'layer'):
+ if getattr(obj, 'layer', None) is not None:
obj.layer = flip_layer(obj.layer)
if hasattr(obj, 'layers'):
@@ -828,8 +835,9 @@ class Footprint:
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)
+ if obj.layer is not None:
+ obj.effects.justify.mirror = not obj.effects.justify.mirror
+ obj.layer = flip_layer(obj.layer)
@property
def single_sided(self):
@@ -868,19 +876,20 @@ class Footprint:
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.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
+ 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
+ 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
+ if prop.at is not None:
+ 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
+ text.at.rotation = (text.at.rotation + math.degrees(angle)) % 360
def set_rotation(self, angle):
old_deg = self.at.rotation
@@ -891,7 +900,8 @@ class Footprint:
pad.at.rotation = (pad.at.rotation + delta) % 360
for prop in self.properties:
- prop.at.rotation = (prop.at.rotation + delta) % 360
+ if prop.at is not None:
+ prop.at.rotation = (prop.at.rotation + delta) % 360
for text in self.texts:
text.at.rotation = (text.at.rotation + delta) % 360
@@ -970,7 +980,7 @@ class Footprint:
layer_stack.drill_pth.append(fe)
def bounding_box(self, unit=MM):
- if not self._bounding_box:
+ if not hasattr(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={})
diff --git a/gerbonara/cad/kicad/graphical_primitives.py b/gerbonara/cad/kicad/graphical_primitives.py
index 94a61a4..f86fcc7 100644
--- a/gerbonara/cad/kicad/graphical_primitives.py
+++ b/gerbonara/cad/kicad/graphical_primitives.py
@@ -9,7 +9,7 @@ from .primitives import *
from ... import graphic_objects as go
from ... import apertures as ap
from ...newstroke import Newstroke
-from ...utils import rotate_point, MM
+from ...utils import rotate_point, MM, arc_bounds
@sexp_type('layer')
class TextLayer:
@@ -18,10 +18,11 @@ class TextLayer:
@sexp_type('gr_text')
-class Text(TextMixin):
+class Text(TextMixin, BBoxMixin):
text: str = ''
at: AtPos = field(default_factory=AtPos)
layer: TextLayer = field(default_factory=TextLayer)
+ uuid: UUID = field(default_factory=UUID)
tstamp: Timestamp = None
effects: TextEffect = field(default_factory=TextEffect)
render_cache: RenderCache = None
@@ -31,14 +32,15 @@ class Text(TextMixin):
@sexp_type('gr_text_box')
-class TextBox:
+class TextBox(BBoxMixin):
locked: Flag() = False
text: str = ''
start: Named(XYCoord) = None
end: Named(XYCoord) = None
- pts: PointList = field(default_factory=PointList)
+ pts: PointList = field(default_factory=list)
angle: OmitDefault(Named(float)) = 0.0
layer: Named(str) = ""
+ uuid: UUID = field(default_factory=UUID)
tstamp: Timestamp = None
effects: TextEffect = field(default_factory=TextEffect)
stroke: Stroke = field(default_factory=Stroke)
@@ -53,7 +55,7 @@ class TextBox:
raise ValueError('Vector font text with empty render cache')
for poly in render_cache.polygons:
- reg = go.Region([(p.x, -p.y) for p in poly.pts.xy], unit=MM)
+ reg = go.Region([(p.x, -p.y) for p in poly.pts], unit=MM)
if self.stroke:
if self.stroke.type not in (None, Atom.default, Atom.solid):
@@ -69,13 +71,14 @@ class TextBox:
@sexp_type('gr_line')
-class Line:
+class Line(WidthMixin):
start: Rename(XYCoord) = None
end: Rename(XYCoord) = None
angle: Named(float) = None # wat
layer: Named(str) = None
width: Named(float) = None
stroke: Stroke = field(default_factory=Stroke)
+ uuid: UUID = field(default_factory=UUID)
tstamp: Timestamp = None
def rotate(self, angle, cx=None, cy=None):
@@ -98,6 +101,12 @@ class Line:
self.start = self.start.with_offset(x, y)
self.end = self.end.with_offset(x, y)
+ def bounding_box(self, unit=MM):
+ x_min, x_max = min(self.start.x, self.end.x), max(self.start.x, self.end.x)
+ y_min, y_max = min(self.start.y, self.end.y), max(self.start.y, self.end.y)
+ w = self.stroke.width if self.stroke else self.width
+ return (x_min-w, y_max-w), (x_max+w, y_max+w)
+
@sexp_type('fill')
class FillMode:
@@ -113,13 +122,14 @@ class FillMode:
yield [Atom.fill, Atom.solid if value else Atom.none]
@sexp_type('gr_rect')
-class Rectangle:
+class Rectangle(BBoxMixin, WidthMixin):
start: Rename(XYCoord) = None
end: Rename(XYCoord) = None
layer: Named(str) = None
width: Named(float) = None
stroke: Stroke = field(default_factory=Stroke)
fill: FillMode = False
+ uuid: UUID = field(default_factory=UUID)
tstamp: Timestamp = None
def render(self, variables=None):
@@ -130,9 +140,9 @@ class Rectangle:
if self.fill:
yield rect
- if self.width:
+ if (w := self.stroke.width if self.stroke else self.width):
# FIXME stroke support
- yield from rect.outline_objects(aperture=ap.CircleAperture(self.width, unit=MM))
+ yield from rect.outline_objects(aperture=ap.CircleAperture(w, unit=MM))
@property
def top_left(self):
@@ -145,21 +155,23 @@ class Rectangle:
@sexp_type('gr_circle')
-class Circle:
+class Circle(BBoxMixin, WidthMixin):
center: Rename(XYCoord) = None
end: Rename(XYCoord) = None
layer: Named(str) = None
width: Named(float) = None
stroke: Stroke = field(default_factory=Stroke)
fill: FillMode = False
+ uuid: UUID = field(default_factory=UUID)
tstamp: Timestamp = None
def render(self, variables=None):
r = math.dist((self.center.x, -self.center.y), (self.end.x, -self.end.y))
- aperture = ap.CircleAperture(self.width or 0, unit=MM)
+ w = self.stroke.width if self.stroke else self.width
+ aperture = ap.CircleAperture(w or 0, unit=MM)
arc = go.Arc.from_circle(self.center.x, -self.center.y, r, aperture=aperture, unit=MM)
- if self.width:
+ if w:
# FIXME stroke support
yield arc
@@ -170,15 +182,20 @@ class Circle:
self.center = self.center.with_offset(x, y)
self.end = self.end.with_offset(x, y)
+ def rotate(self, angle, cx=0, cy=0):
+ self.center = self.center.with_rotation(angle, cx, cy)
+ self.end = self.end.with_rotation(angle, cx, cy)
+
@sexp_type('gr_arc')
-class Arc:
+class Arc(WidthMixin, BBoxMixin):
start: Rename(XYCoord) = None
mid: Rename(XYCoord) = None
end: Rename(XYCoord) = None
layer: Named(str) = None
width: Named(float) = None
stroke: Stroke = field(default_factory=Stroke)
+ uuid: UUID = field(default_factory=UUID)
tstamp: Timestamp = None
_: SEXP_END = None
center: XYCoord = None
@@ -192,35 +209,35 @@ class Arc:
self.mid = center_arc_to_kicad_mid(XYCoord(self.center), self.start, self.end)
self.center = None
- def rotate(self, angle, cx=None, cy=None):
- self.start.x, self.start.y = rotate_point(self.start.x, self.start.y, angle, cx, cy)
- 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 render(self, variables=None):
- # FIXME stroke support
- if not self.width:
+ if not (w := self.stroke.width if self.stroke else self.width):
return
- aperture = ap.CircleAperture(self.width, unit=MM)
+ aperture = ap.CircleAperture(w, unit=MM)
x1, y1 = self.start.x, self.start.y
x2, y2 = self.end.x, self.end.y
- (cx, cy), _r = kicad_mid_to_center_arc(self.mid, self.start, self.end)
- yield go.Arc(x1, -y1, x2, -y2, cx-x1, -(cy-y1), aperture=aperture, clockwise=False, unit=MM)
+ (cx, cy), _r, clockwise = kicad_mid_to_center_arc(self.mid, self.start, self.end)
+ yield go.Arc(x1, -y1, x2, -y2, cx-x1, -(cy-y1), aperture=aperture, clockwise=not clockwise, 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)
+ def rotate(self, angle, cx=None, cy=None):
+ self.start.x, self.start.y = rotate_point(self.start.x, self.start.y, angle, cx, cy)
+ 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)
+
@sexp_type('gr_poly')
-class Polygon:
+class Polygon(BBoxMixin, WidthMixin):
pts: ArcPointList = field(default_factory=list)
layer: Named(str) = None
width: Named(float) = None
stroke: Stroke = field(default_factory=Stroke)
fill: FillMode = True
+ uuid: UUID = field(default_factory=UUID)
tstamp: Timestamp = None
def render(self, variables=None):
@@ -236,35 +253,40 @@ class Polygon:
else: # base_types.Arc
points.append((point_or_arc.start.x, -point_or_arc.start.y))
points.append((point_or_arc.end.x, -point_or_arc.end.y))
- (cx, cy), _r = kicad_mid_to_center_arc(point_or_arc.mid, point_or_arc.start, point_or_arc.end)
- centers.append((False, (cx, -cy)))
+ (cx, cy), _r, clockwise = kicad_mid_to_center_arc(point_or_arc.mid, point_or_arc.start, point_or_arc.end)
+ centers.append((not clockwise, (cx, -cy)))
reg = go.Region(points, centers, unit=MM)
reg.close()
+ w = self.stroke.width if self.stroke else self.width
# FIXME stroke support
- if self.width and self.width >= 0.005 or self.stroke.width and self.stroke.width > 0.005:
- yield from reg.outline_objects(aperture=ap.CircleAperture(self.width, unit=MM))
+ if w and w >= 0.005:
+ yield from reg.outline_objects(aperture=ap.CircleAperture(w, unit=MM))
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])
+ self.pts = [pt.with_offset(x, y) for pt in self.pts]
+
+ def rotate(self, angle, cx=0, cy=0):
+ self.pts = [pt.with_rotation(angle, cx, cy) for pt in self.pts]
@sexp_type('gr_curve')
-class Curve:
- pts: PointList = field(default_factory=PointList)
+class Curve(BBoxMixin, WidthMixin):
+ pts: PointList = field(default_factory=list)
layer: Named(str) = None
width: Named(float) = None
+ uuid: UUID = field(default_factory=UUID)
tstamp: Timestamp = None
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])
+ self.pts =[pt.with_offset(x, y) for pt in self.pts]
@sexp_type('gr_bbox')
@@ -319,8 +341,9 @@ class Dimension:
locked: Flag() = False
dimension_type: Named(AtomChoice(Atom.aligned, Atom.leader, Atom.center, Atom.orthogonal, Atom.radial), name='type') = Atom.aligned
layer: Named(str) = 'Dwgs.User'
+ uuid: UUID = field(default_factory=UUID)
tstamp: Timestamp = field(default_factory=Timestamp)
- pts: PointList = field(default_factory=PointList)
+ pts: PointList = field(default_factory=list)
height: Named(float) = None
orientation: Named(int) = None
leader_length: Named(float) = None
@@ -332,5 +355,5 @@ class Dimension:
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])
+ self.pts = [pt.with_offset(x, y) for pt in self.pts]
diff --git a/gerbonara/cad/kicad/pcb.py b/gerbonara/cad/kicad/pcb.py
index 767b763..6bbcad1 100644
--- a/gerbonara/cad/kicad/pcb.py
+++ b/gerbonara/cad/kicad/pcb.py
@@ -59,13 +59,14 @@ def gn_layer_to_kicad(layer, flip=False):
@sexp_type('general')
class GeneralSection:
thickness: Named(float) = 1.60
+ legacy_teardrops: Named(YesNoAtom()) = False
@sexp_type('layers')
class LayerSettings:
index: int = 0
canonical_name: str = None
- layer_type: AtomChoice(Atom.jumper, Atom.mixed, Atom.power, Atom.signal, Atom.user) = Atom.signal
+ layer_type: AtomChoice(Atom.jumper, Atom.mixed, Atom.power, Atom.signal, Atom.user, Atom.auxiliary) = Atom.signal
custom_name: str = None
@@ -92,43 +93,44 @@ class StackupSettings:
edge_plating: Named(YesNoAtom()) = None
-TFBool = YesNoAtom(yes=Atom.true, no=Atom.false)
-
@sexp_type('pcbplotparams')
class ExportSettings:
layerselection: Named(Atom) = None
plot_on_all_layers_selection: Named(Atom) = None
- disableapertmacros: Named(TFBool) = False
- usegerberextensions: Named(TFBool) = True
- usegerberattributes: Named(TFBool) = True
- usegerberadvancedattributes: Named(TFBool) = True
- creategerberjobfile: Named(TFBool) = True
+ disableapertmacros: Named(YesNoAtom()) = False
+ usegerberextensions: Named(YesNoAtom()) = True
+ usegerberattributes: Named(YesNoAtom()) = True
+ usegerberadvancedattributes: Named(YesNoAtom()) = True
+ creategerberjobfile: Named(YesNoAtom()) = True
dashed_line_dash_ratio: Named(float) = 12.0
dashed_line_gap_ratio: Named(float) = 3.0
- svguseinch: Named(TFBool) = False
+ svguseinch: Named(YesNoAtom()) = False
svgprecision: Named(float) = 4
- excludeedgelayer: Named(TFBool) = False
- plotframeref: Named(TFBool) = False
- viasonmask: Named(TFBool) = False
+ excludeedgelayer: Named(YesNoAtom()) = False
+ plotframeref: Named(YesNoAtom()) = False
+ viasonmask: Named(YesNoAtom()) = False
mode: Named(int) = 1
- useauxorigin: Named(TFBool) = False
+ useauxorigin: Named(YesNoAtom()) = False
hpglpennumber: Named(int) = 1
hpglpenspeed: Named(int) = 20
hpglpendiameter: Named(float) = 15.0
- pdf_front_fp_property_popups: Named(TFBool) = True
- pdf_back_fp_property_popups: Named(TFBool) = True
- dxfpolygonmode: Named(TFBool) = True
- dxfimperialunits: Named(TFBool) = False
- dxfusepcbnewfont: Named(TFBool) = True
- psnegative: Named(TFBool) = False
- psa4output: Named(TFBool) = False
- plotreference: Named(TFBool) = True
- plotvalue: Named(TFBool) = True
- plotinvisibletext: Named(TFBool) = False
- sketchpadsonfab: Named(TFBool) = False
- subtractmaskfromsilk: Named(TFBool) = False
+ pdf_front_fp_property_popups: Named(YesNoAtom()) = True
+ pdf_back_fp_property_popups: Named(YesNoAtom()) = True
+ pdf_metadata: Named(YesNoAtom()) = True
+ dxfpolygonmode: Named(YesNoAtom()) = True
+ dxfimperialunits: Named(YesNoAtom()) = False
+ dxfusepcbnewfont: Named(YesNoAtom()) = True
+ psnegative: Named(YesNoAtom()) = False
+ psa4output: Named(YesNoAtom()) = False
+ plotreference: Named(YesNoAtom()) = True
+ plotvalue: Named(YesNoAtom()) = True
+ plotfptext: Named(YesNoAtom()) = True
+ plotinvisibletext: Named(YesNoAtom()) = False
+ sketchpadsonfab: Named(YesNoAtom()) = False
+ plotpadnumbers: Named(YesNoAtom()) = False
+ subtractmaskfromsilk: Named(YesNoAtom()) = False
outputformat: Named(int) = 1
- mirror: Named(TFBool) = False
+ mirror: Named(YesNoAtom()) = False
drillshape: Named(int) = 0
scaleselection: Named(int) = 1
outputdirectory: Named(str) = "gerber"
@@ -141,26 +143,23 @@ class BoardSetup:
solder_mask_min_width: Named(float) = None
pad_to_past_clearance: Named(float) = None
pad_to_paste_clearance_ratio: Named(float) = None
+ allow_soldermask_bridges_in_footprints: Named(YesNoAtom()) = False
+ tenting: Named(Array(AtomChoice(Atom.front, Atom.back))) = field(default_factory=lambda: [Atom.front, Atom.back])
aux_axis_origin: Rename(XYCoord) = None
grid_origin: Rename(XYCoord) = None
export_settings: ExportSettings = field(default_factory=ExportSettings)
-@sexp_type('net')
-class Net:
- index: int = 0
- name: str = ''
-
-
@sexp_type('segment')
-class TrackSegment:
+class TrackSegment(BBoxMixin):
start: Rename(XYCoord) = field(default_factory=XYCoord)
end: Rename(XYCoord) = field(default_factory=XYCoord)
width: Named(float) = 0.5
layer: Named(str) = 'F.Cu'
locked: Flag() = False
net: Named(int) = 0
- tstamp: Timestamp = field(default_factory=Timestamp)
+ uuid: UUID = field(default_factory=UUID)
+ tstamp: Timestamp = None
@classmethod
def from_footprint_line(kls, line, flip=False):
@@ -195,7 +194,7 @@ class TrackSegment:
@sexp_type('arc')
-class TrackArc:
+class TrackArc(BBoxMixin):
start: Rename(XYCoord) = field(default_factory=XYCoord)
mid: Rename(XYCoord) = field(default_factory=XYCoord)
end: Rename(XYCoord) = field(default_factory=XYCoord)
@@ -203,14 +202,15 @@ class TrackArc:
layer: Named(str) = 'F.Cu'
locked: Flag() = False
net: Named(int) = 0
- tstamp: Timestamp = field(default_factory=Timestamp)
+ uuid: UUID = field(default_factory=UUID)
+ tstamp: Timestamp = None
_: SEXP_END = None
center: XYCoord = None
def __post_init__(self):
self.start = XYCoord(self.start)
self.end = XYCoord(self.end)
- self.mid = XYCoord(self.mid) if self.mid else center_arc_to_kicad_mid(XYCoord(self.center), self.start, self.end)
+ self.mid = XYCoord(self.mid) if self.center is None else center_arc_to_kicad_mid(XYCoord(self.center), self.start, self.end)
self.center = None
@property
@@ -239,7 +239,7 @@ class TrackArc:
@sexp_type('via')
-class Via:
+class Via(BBoxMixin):
via_type: AtomChoice(Atom.blind, Atom.micro) = None
locked: Flag() = False
at: Rename(XYCoord) = field(default_factory=XYCoord)
@@ -250,7 +250,8 @@ class Via:
keep_end_layers: Flag() = False
free: Named(YesNoAtom()) = False
net: Named(int) = 0
- tstamp: Timestamp = field(default_factory=Timestamp)
+ uuid: UUID = field(default_factory=UUID)
+ tstamp: Timestamp = None
@classmethod
def from_pad(kls, pad):
@@ -307,7 +308,8 @@ SUPPORTED_FILE_FORMAT_VERSIONS = [20210108, 20211014, 20221018, 20230517]
@sexp_type('kicad_pcb')
class Board:
_version: Named(int, name='version') = 20230517
- generator: Named(Atom) = Atom.gerbonara
+ generator: Named(str) = Atom.gerbonara
+ generator_version: Named(str) = Atom.gerbonara
general: GeneralSection = None
page: PageSettings = None
layers: Named(Array(Untagged(LayerSettings))) = field(default_factory=list)
@@ -333,10 +335,10 @@ class Board:
# Other stuff
zones: List(Zone) = field(default_factory=list)
groups: List(Group) = field(default_factory=list)
+ embedded_fonts: Named(YesNoAtom()) = False
_ : SEXP_END = None
original_filename: str = None
- _bounding_box: tuple = None
_trace_index: rtree.index.Index = None
_trace_index_map: dict = None
@@ -375,15 +377,15 @@ class Board:
(47, 'F.CrtYd', 'user', 'F.Courtyard'),
(48, 'B.Fab', 'user', None),
(49, 'F.Fab', 'user', None),
- (50, 'User.1', 'user', None),
- (51, 'User.2', 'user', None),
- (52, 'User.3', 'user', None),
- (53, 'User.4', 'user', None),
- (54, 'User.5', 'user', None),
- (55, 'User.6', 'user', None),
- (56, 'User.7', 'user', None),
- (57, 'User.8', 'user', None),
- (58, 'User.9', 'user', None)]]
+ (50, 'User.1', 'auxiliary', None),
+ (51, 'User.2', 'auxiliary', None),
+ (52, 'User.3', 'auxiliary', None),
+ (53, 'User.4', 'auxiliary', None),
+ (54, 'User.5', 'auxiliary', None),
+ (55, 'User.6', 'auxiliary', None),
+ (56, 'User.7', 'auxiliary', None),
+ (57, 'User.8', 'auxiliary', None),
+ (58, 'User.9', 'auxiliary', None)]]
def rebuild_trace_index(self):
@@ -660,11 +662,11 @@ class Board:
for fp in self.footprints:
if name and not match_filter(name, fp.name):
continue
- if value and not match_filter(value, fp.properties.get('value', '')):
+ if value and not match_filter(value, fp.value):
continue
- if reference and not match_filter(reference, fp.properties.get('reference', '')):
+ if reference and not match_filter(reference, fp.reference):
continue
- if net and not any(match_filter(net, pad.net.name) for pad in fp.pads):
+ if net and not any(pad.net and match_filter(net, pad.net.name) for pad in fp.pads):
continue
if sheetname and not match_filter(sheetname, fp.sheetname):
continue
@@ -780,15 +782,6 @@ class Board:
fe.offset(x, -y, MM)
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 BoardInstance(cad_pr.Positioned):
sexp: Board = None
diff --git a/gerbonara/cad/kicad/primitives.py b/gerbonara/cad/kicad/primitives.py
index 65566d0..ad1cc90 100644
--- a/gerbonara/cad/kicad/primitives.py
+++ b/gerbonara/cad/kicad/primitives.py
@@ -38,7 +38,7 @@ def layer_mask(layers):
case 'B.Cu':
mask |= 1<<31
case _:
- if (m := re.match(f'In([0-9]+)\.Cu', layer)):
+ if (m := re.match(fr'In([0-9]+)\.Cu', layer)):
mask |= 1<<int(m.group(1))
return mask
@@ -62,6 +62,8 @@ def center_arc_to_kicad_mid(center, start, end):
def kicad_mid_to_center_arc(mid, start, end):
""" Convert kicad's slightly insane midpoint notation to standrad center/p1/p2 notation.
+ returns a ((center_x, center_y), radius, clockwise) tuple in KiCad coordinates.
+
Returns the center and radius of the circle passing the given 3 points.
In case the 3 points form a line, raises a ValueError.
"""
@@ -81,7 +83,7 @@ def kicad_mid_to_center_arc(mid, start, end):
cy = ((p1[0] - p2[0]) * cd - (p2[0] - p3[0]) * bc) / det
radius = math.sqrt((cx - p1[0])**2 + (cy - p1[1])**2)
- return (cx, cy), radius
+ return (cx, cy), radius, det < 0
@sexp_type('hatch')
@@ -92,7 +94,7 @@ class Hatch:
@sexp_type('connect_pads')
class PadConnection:
- type: AtomChoice(Atom.thru_hole_only, Atom.full, Atom.no) = None
+ type: AtomChoice(Atom.yes, Atom.thru_hole_only, Atom.full, Atom.no) = None
clearance: Named(float) = 0
@@ -133,18 +135,18 @@ class ZoneFill:
class FillPolygon:
layer: Named(str) = ""
island: Wrap(Flag()) = False
- pts: PointList = field(default_factory=PointList)
+ pts: PointList = field(default_factory=list)
@sexp_type('fill_segments')
class FillSegment:
layer: Named(str) = ""
- pts: PointList = field(default_factory=PointList)
+ pts: PointList = field(default_factory=list)
@sexp_type('polygon')
class ZonePolygon:
- pts: PointList = field(default_factory=PointList)
+ pts: PointList = field(default_factory=list)
@sexp_type('zone')
@@ -178,10 +180,26 @@ class Zone:
self.fill_polygons = []
self.fill_segments = []
+ def rotate(self, angle, cx=None, cy=None):
+ self.unfill()
+ self.polygon.pts = [pt.with_rotation(angle, cx, cy) for pt in self.polygon.pts]
+
+ def offset(self, x=0, y=0):
+ self.unfill()
+ self.polygon.pts = [pt.with_offset(x, y) for pt in self.polygon.pts]
+
+
+ def bounding_box(self):
+ min_x = min(pt.x for pt in self.polygon.pts)
+ min_y = min(pt.y for pt in self.polygon.pts)
+ max_x = max(pt.x for pt in self.polygon.pts)
+ max_y = max(pt.y for pt in self.polygon.pts)
+ return (min_x, min_y), (max_x, max_y)
+
@sexp_type('polygon')
class RenderCachePolygon:
- pts: PointList = field(default_factory=PointList)
+ pts: PointList = field(default_factory=list)
@sexp_type('render_cache')
diff --git a/gerbonara/cad/kicad/schematic.py b/gerbonara/cad/kicad/schematic.py
index 0a2f4de..9e7c6d4 100644
--- a/gerbonara/cad/kicad/schematic.py
+++ b/gerbonara/cad/kicad/schematic.py
@@ -132,7 +132,7 @@ def _polyline_bounds(self):
@sexp_type('wire')
class Wire:
- points: PointList = field(default_factory=PointList)
+ points: PointList = field(default_factory=list)
stroke: Stroke = field(default_factory=Stroke)
uuid: UUID = field(default_factory=UUID)
@@ -145,7 +145,7 @@ class Wire:
@sexp_type('bus')
class Bus:
- points: PointList = field(default_factory=PointList)
+ points: PointList = field(default_factory=list)
stroke: Stroke = field(default_factory=Stroke)
uuid: UUID = field(default_factory=UUID)
@@ -158,7 +158,7 @@ class Bus:
@sexp_type('polyline')
class Polyline:
- points: PointList = field(default_factory=PointList)
+ points: PointList = field(default_factory=list)
stroke: Stroke = field(default_factory=Stroke)
uuid: UUID = field(default_factory=UUID)
@@ -357,7 +357,7 @@ class SymbolInstance:
# three other uses of the same symbol in this schematic.
instances: Named(Array(SymbolCrosslinkProject)) = field(default_factory=list)
_ : SEXP_END = None
- schematic: object = None
+ schematic: object = field(repr=False, default=None)
def __after_parse__(self, parent):
self.schematic = parent
@@ -496,7 +496,7 @@ class Subsheet:
_ : SEXP_END = None
sheet_name: object = field(default_factory=lambda: DrawnProperty('Sheetname', ''))
file_name: object = field(default_factory=lambda: DrawnProperty('Sheetfile', ''))
- schematic: object = None
+ schematic: object = field(repr=False, default=None)
def __after_parse__(self, parent):
self.sheet_name, self.file_name = self._properties
diff --git a/gerbonara/cad/kicad/sexp_mapper.py b/gerbonara/cad/kicad/sexp_mapper.py
index d2d9d30..c197765 100644
--- a/gerbonara/cad/kicad/sexp_mapper.py
+++ b/gerbonara/cad/kicad/sexp_mapper.py
@@ -202,6 +202,24 @@ class YesNoAtom:
yield self.yes if value else self.no
+class LegacyCompatibleFlag:
+ '''Variant of YesNoAtom that accepts both the `(flag <yes/no>)` variant and the bare `flag` variant for compatibility.'''
+
+ def __init__(self, yes=Atom.yes, no=Atom.no, value_when_empty=True):
+ self.yes, self.no = yes, no
+ self.value_when_empty = value_when_empty
+
+ def __map__(self, value, parent=None):
+ if value == []:
+ return self.value_when_empty
+
+ 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