summaryrefslogtreecommitdiff
path: root/gerbonara/cad
diff options
context:
space:
mode:
Diffstat (limited to 'gerbonara/cad')
-rw-r--r--gerbonara/cad/kicad/base_types.py35
-rw-r--r--gerbonara/cad/kicad/footprints.py81
-rw-r--r--gerbonara/cad/kicad/graphical_primitives.py43
-rw-r--r--gerbonara/cad/kicad/pcb.py100
-rw-r--r--gerbonara/cad/kicad/primitives.py4
-rw-r--r--gerbonara/cad/kicad/sexp_mapper.py31
6 files changed, 268 insertions, 26 deletions
diff --git a/gerbonara/cad/kicad/base_types.py b/gerbonara/cad/kicad/base_types.py
index 2a6f196..fc2df71 100644
--- a/gerbonara/cad/kicad/base_types.py
+++ b/gerbonara/cad/kicad/base_types.py
@@ -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)
diff --git a/gerbonara/cad/kicad/footprints.py b/gerbonara/cad/kicad/footprints.py
index d38976c..805b1e0 100644
--- a/gerbonara/cad/kicad/footprints.py
+++ b/gerbonara/cad/kicad/footprints.py
@@ -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())
@@ -746,6 +750,42 @@ class Footprint:
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
diff --git a/gerbonara/cad/kicad/graphical_primitives.py b/gerbonara/cad/kicad/graphical_primitives.py
index f6c56ce..ce02df4 100644
--- a/gerbonara/cad/kicad/graphical_primitives.py
+++ b/gerbonara/cad/kicad/graphical_primitives.py
@@ -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])
diff --git a/gerbonara/cad/kicad/pcb.py b/gerbonara/cad/kicad/pcb.py
index bfdb46b..aee0d54 100644
--- a/gerbonara/cad/kicad/pcb.py
+++ b/gerbonara/cad/kicad/pcb.py
@@ -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):
diff --git a/gerbonara/cad/kicad/primitives.py b/gerbonara/cad/kicad/primitives.py
index 6e9f41d..58a5b2c 100644
--- a/gerbonara/cad/kicad/primitives.py
+++ b/gerbonara/cad/kicad/primitives.py
@@ -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
diff --git a/gerbonara/cad/kicad/sexp_mapper.py b/gerbonara/cad/kicad/sexp_mapper.py
index ca000c3..c6e7464 100644
--- a/gerbonara/cad/kicad/sexp_mapper.py
+++ b/gerbonara/cad/kicad/sexp_mapper.py
@@ -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):