summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorjaseg <git@jaseg.de>2023-06-12 18:39:33 +0200
committerjaseg <git@jaseg.de>2023-06-12 18:39:33 +0200
commit35618179036409c71c87746c32a27238260a02a4 (patch)
tree5982cc2556eb110a37ef03288dfe13d7bef841b3
parent03f2ec0a307a33fd9be1da1a65b1dcb569cfcffd (diff)
downloadgerbonara-35618179036409c71c87746c32a27238260a02a4.tar.gz
gerbonara-35618179036409c71c87746c32a27238260a02a4.tar.bz2
gerbonara-35618179036409c71c87746c32a27238260a02a4.zip
Add basic KiCad PCB file format support
-rw-r--r--gerbonara/cad/kicad/base_types.py49
-rw-r--r--gerbonara/cad/kicad/footprints.py51
-rw-r--r--gerbonara/cad/kicad/graphical_primitives.py53
-rw-r--r--gerbonara/cad/kicad/primitives.py5
-rw-r--r--gerbonara/cad/kicad/sexp_mapper.py3
-rw-r--r--gerbonara/cad/primitives.py58
6 files changed, 162 insertions, 57 deletions
diff --git a/gerbonara/cad/kicad/base_types.py b/gerbonara/cad/kicad/base_types.py
index 8f3036c..6bb5912 100644
--- a/gerbonara/cad/kicad/base_types.py
+++ b/gerbonara/cad/kicad/base_types.py
@@ -9,6 +9,42 @@ from contextlib import contextmanager
from itertools import cycle
+LAYER_MAP_K2G = {
+ 'F.Cu': ('top', 'copper'),
+ 'B.Cu': ('bottom', 'copper'),
+ 'F.SilkS': ('top', 'silk'),
+ 'B.SilkS': ('bottom', 'silk'),
+ 'F.Paste': ('top', 'paste'),
+ 'B.Paste': ('bottom', 'paste'),
+ 'F.Mask': ('top', 'mask'),
+ 'B.Mask': ('bottom', 'mask'),
+ 'B.CrtYd': ('bottom', 'courtyard'),
+ 'F.CrtYd': ('top', 'courtyard'),
+ 'B.Fab': ('bottom', 'fabrication'),
+ 'F.Fab': ('top', 'fabrication'),
+ 'B.Adhes': ('bottom', 'adhesive'),
+ 'F.Adhes': ('top', 'adhesive'),
+ 'Dwgs.User': ('mechanical', 'drawings'),
+ 'Cmts.User': ('mechanical', 'comments'),
+ 'Edge.Cuts': ('mechanical', 'outline'),
+ }
+
+LAYER_MAP_G2K = {v: k for k, v in LAYER_MAP_K2G.items()}
+
+
+@sexp_type('group')
+class Group:
+ name: str = ""
+ id: Named(str) = ""
+ members: Named(List(str)) = field(default_factory=list)
+
+
+@sexp_type('property')
+class Property:
+ key: str = ''
+ value: str = ''
+
+
@sexp_type('color')
class Color:
r: int = None
@@ -186,6 +222,19 @@ class Timestamp:
def bump(self):
self.value = uuid.uuid4()
+@sexp_type('uuid')
+class UUID:
+ value: str = field(default_factory=uuid.uuid4)
+
+ def __after_parse__(self, parent):
+ self.value = str(self.value)
+
+ def before_sexp(self):
+ self.value = Atom(str(self.value))
+
+ def bump(self):
+ self.value = uuid.uuid4()
+
@sexp_type('tedit')
class EditTime:
value: str = field(default_factory=time.time)
diff --git a/gerbonara/cad/kicad/footprints.py b/gerbonara/cad/kicad/footprints.py
index ece7b53..1be4d3c 100644
--- a/gerbonara/cad/kicad/footprints.py
+++ b/gerbonara/cad/kicad/footprints.py
@@ -30,12 +30,6 @@ from ...aperture_macros.parse import GenericMacros, ApertureMacro
from ...aperture_macros import primitive as amp
-@sexp_type('property')
-class Property:
- key: str = ''
- value: str = ''
-
-
@sexp_type('attr')
class Attribute:
type: AtomChoice(Atom.smd, Atom.through_hole) = None
@@ -166,6 +160,7 @@ class Circle:
for x1, y1, x2, y2 in dasher:
yield go.Line(x1, y1, x2, y2, aperture=aperture, unit=MM)
+
@sexp_type('fp_arc')
class Arc:
start: Rename(XYCoord) = None
@@ -372,6 +367,7 @@ class Pad:
tstamp: Timestamp = None
pin_function: Named(str) = None
pintype: Named(str) = None
+ pinfunction: Named(str) = None
die_length: Named(float) = None
solder_mask_margin: Named(float) = None
solder_paste_margin: Named(float) = None
@@ -543,13 +539,6 @@ class Pad:
yield go.Flash(self.at.x, self.at.y, aperture=aperture, unit=MM)
-@sexp_type('group')
-class Group:
- name: str = ""
- id: Named(str) = ""
- members: Named(List(str)) = field(default_factory=list)
-
-
@sexp_type('model')
class Model:
name: str = ''
@@ -564,7 +553,7 @@ SUPPORTED_FILE_FORMAT_VERSIONS = [20210108, 20211014, 20221018]
class Footprint:
name: str = None
_version: Named(int, name='version') = 20210108
- generator: Named(Atom) = Atom.kicad_library_utils
+ generator: Named(Atom) = Atom.gerbonara
locked: Flag() = False
placed: Flag() = False
layer: Named(str) = 'F.Cu'
@@ -655,11 +644,10 @@ class Footprint:
(self.dimensions if text else []),
(self.pads if pads else []))
- def render(self, layer_stack, layer_map, x=0, y=0, rotation=0, text=False, side=None, variables={}, cache=None):
+ def render(self, layer_stack, layer_map, x=0, y=0, rotation=0, text=False, flip=False, variables={}, cache=None):
x += self.at.x
y += self.at.y
rotation += math.radians(self.at.rotation)
- flip = (side != 'top') if side else (self.layer != 'F.Cu')
for obj in self.objects(pads=False, text=text):
if not (layer := layer_map.get(obj.layer)):
@@ -718,35 +706,11 @@ class Footprint:
if not self._bounding_box:
stack = LayerStack()
layer_map = {kc_id: gn_id for kc_id, gn_id in LAYER_MAP_K2G.items() if gn_id in stack}
- self.render(stack, layer_map, x=0, y=0, rotation=0, side='top', text=False, variables={})
+ self.render(stack, layer_map, x=0, y=0, rotation=0, flip=False, text=False, variables={})
self._bounding_box = stack.bounding_box(unit)
return self._bounding_box
-
-LAYER_MAP_K2G = {
- 'F.Cu': ('top', 'copper'),
- 'B.Cu': ('bottom', 'copper'),
- 'F.SilkS': ('top', 'silk'),
- 'B.SilkS': ('bottom', 'silk'),
- 'F.Paste': ('top', 'paste'),
- 'B.Paste': ('bottom', 'paste'),
- 'F.Mask': ('top', 'mask'),
- 'B.Mask': ('bottom', 'mask'),
- 'B.CrtYd': ('bottom', 'courtyard'),
- 'F.CrtYd': ('top', 'courtyard'),
- 'B.Fab': ('bottom', 'fabrication'),
- 'F.Fab': ('top', 'fabrication'),
- 'B.Adhes': ('bottom', 'adhesive'),
- 'F.Adhes': ('top', 'adhesive'),
- 'Dwgs.User': ('mechanical', 'drawings'),
- 'Cmts.User': ('mechanical', 'comments'),
- 'Edge.Cuts': ('mechanical', 'outline'),
- }
-
-LAYER_MAP_G2K = {v: k for k, v in LAYER_MAP_K2G.items()}
-
-
@dataclass
class FootprintInstance(Positioned):
sexp: Footprint = None
@@ -756,7 +720,7 @@ class FootprintInstance(Positioned):
variables: dict = field(default_factory=lambda: {})
def render(self, layer_stack, cache=None):
- x, y, rotation = self.abs_pos
+ x, y, rotation, flip= self.abs_pos
x, y = MM(x, self.unit), MM(y, self.unit)
variables = dict(self.variables)
@@ -771,13 +735,14 @@ class FootprintInstance(Positioned):
self.sexp.render(layer_stack, layer_map,
x=x, y=y, rotation=rotation,
- side=self.side,
+ flip=flip,
text=(not self.hide_text),
variables=variables, cache=cache)
def bounding_box(self, unit=MM):
return offset_bounds(self.sexp.bounding_box(unit), unit(self.x, self.unit), unit(self.y, self.unit))
+
if __name__ == '__main__':
import sys
from ...layers import LayerStack
diff --git a/gerbonara/cad/kicad/graphical_primitives.py b/gerbonara/cad/kicad/graphical_primitives.py
index 171fa76..f078268 100644
--- a/gerbonara/cad/kicad/graphical_primitives.py
+++ b/gerbonara/cad/kicad/graphical_primitives.py
@@ -24,6 +24,7 @@ class Text:
layer: TextLayer = field(default_factory=TextLayer)
tstamp: Timestamp = None
effects: TextEffect = field(default_factory=TextEffect)
+ render_cache: RenderCache = None
def render(self, variables={}):
if not self.effects or self.effects.hide or not self.effects.font:
@@ -107,13 +108,18 @@ class Line:
angle: Named(float) = None # wat
layer: Named(str) = None
width: Named(float) = None
+ stroke: Stroke = field(default_factory=Stroke)
tstamp: Timestamp = None
def render(self, variables=None):
if self.angle:
raise NotImplementedError('Angles on lines are not implemented. Please raise an issue and provide an example file.')
- aperture = ap.CircleAperture(self.width, unit=MM)
+ if self.width:
+ aperture = ap.CircleAperture(self.width, unit=MM)
+ else:
+ aperture = ap.CircleAperture(self.stroke.width, unit=MM)
+
yield go.Line(self.start.x, self.start.y, self.end.x, self.end.y, aperture=aperture, unit=MM)
@@ -179,16 +185,18 @@ class Arc:
end: Rename(XYCoord) = None
layer: Named(str) = None
width: Named(float) = None
+ stroke: Stroke = field(default_factory=Stroke)
tstamp: Timestamp = None
def render(self, variables=None):
if not self.width:
return
+ aperture = ap.CircleAperture(self.width, unit=MM),
cx, cy = self.mid.x, self.mid.y
x1, y1 = self.start.x, self.start.y
x2, y2 = self.end.x, self.end.y
- yield go.Arc(x1, y1, x2, y2, cx-x1, cy-y1, aperture=ap.CircleAperture(self.width or 0, unit=MM), clockwise=True, unit=MM)
+ yield go.Arc(x1, y1, x2, y2, cx-x1, cy-y1, aperture=aperture, clockwise=True, unit=MM)
@sexp_type('gr_poly')
@@ -228,3 +236,44 @@ class AnnotationBBox:
def render(self, variables=None):
return []
+
+@sexp_type('format')
+class DimensionFormat:
+ prefix: Named(str) = None
+ suffix: Named(str) = None
+ units: Named(int) = 2
+ units_format: Named(int) = 1
+ precision: Named(int) = 7
+ override_value: Named(str) = None
+ suppress_zeros: bool = False
+
+
+@sexp_type('style')
+class DimensionStyle:
+ thickness: Named(float) = 0.1
+ arrow_length: Named(float) = 1.27
+ text_position_mode: Named(int) = 0
+ extension_height: Named(float) = None
+ text_frame: Named(float) = None
+ extension_offset: Named(float) = None
+ keep_text_aligned: bool = False
+
+
+@sexp_type('dimension')
+class Dimension:
+ locked: bool = False
+ dimension_type: Named(AtomChoice(Atom.aligned, Atom.leader, Atom.center, Atom.orthogonal, Atom.radial), name='type') = Atom.aligned
+ layer: Named(str) = 'Dwgs.User'
+ tstamp: Timestamp = field(default_factory=Timestamp)
+ pts: Named(Array(XYCoord)) = field(default_factory=list)
+ height: Named(float) = None
+ orientation: Named(int) = None
+ leader_length: Named(float) = None
+ gr_text: Text = None
+ dimension_format: OmitDefault(DimensionFormat) = field(default_factory=DimensionFormat)
+ dimension_style: OmitDefault(DimensionStyle) = field(default_factory=DimensionStyle)
+
+ def render(self, variables=None):
+ raise NotImplementedError('Dimension rendering is not yet supported. Please raise an issue.')
+
+
diff --git a/gerbonara/cad/kicad/primitives.py b/gerbonara/cad/kicad/primitives.py
index 30ae611..3e78467 100644
--- a/gerbonara/cad/kicad/primitives.py
+++ b/gerbonara/cad/kicad/primitives.py
@@ -39,8 +39,8 @@ class ZoneFill:
thermal_gap: Named(float) = 0.508
thermal_bridge_width: Named(float) = 0.508
smoothing: ZoneSmoothing = None
- island_removal_node: Named(int) = None
- islan_area_min: Named(float) = None
+ island_removal_mode: Named(int) = None
+ island_area_min: Named(float) = None
hatch_thickness: Named(float) = None
hatch_gap: Named(float) = None
hatch_orientation: Named(int) = None
@@ -53,6 +53,7 @@ class ZoneFill:
@sexp_type('filled_polygon')
class FillPolygon:
layer: Named(str) = ""
+ island: Wrap(Flag()) = False
pts: PointList = field(default_factory=PointList)
diff --git a/gerbonara/cad/kicad/sexp_mapper.py b/gerbonara/cad/kicad/sexp_mapper.py
index 1d0f942..fa5f702 100644
--- a/gerbonara/cad/kicad/sexp_mapper.py
+++ b/gerbonara/cad/kicad/sexp_mapper.py
@@ -64,6 +64,7 @@ def sexp(t, v):
def map_sexp(t, v, parent=None):
if t is not Atom and hasattr(t, '__map__'):
return t.__map__(v, parent=parent)
+
elif t in (int, float, str, Atom):
v, = v
if not isinstance(v, t):
@@ -73,9 +74,11 @@ def map_sexp(t, v, parent=None):
else:
raise TypeError(f'Cannot map s-expression value {v} of type {type(v)} to Python type {t}')
return v
+
elif isinstance(t, list):
t, = t
return [map_sexp(t, elem, parent=parent) for elem in v]
+
else:
raise TypeError(f'Python type {t} has no defined s-expression deserialization')
diff --git a/gerbonara/cad/primitives.py b/gerbonara/cad/primitives.py
index 472cb32..2b7c209 100644
--- a/gerbonara/cad/primitives.py
+++ b/gerbonara/cad/primitives.py
@@ -173,6 +173,44 @@ class Positioned:
return True
+# The dataclass API is slightly idiotic here, so we have to duplicate the entire thing.
+@dataclass(frozen=True)
+class FrozenPositioned:
+ x: float
+ y: float
+ _: KW_ONLY
+ rotation: float = 0.0
+ flip: bool = False
+ unit: LengthUnit = MM
+ parent: object = None
+
+ @property
+ def abs_pos(self):
+ if self.parent is None:
+ px, py, pa, pf = 0, 0, 0, False
+ else:
+ px, py, pa, pf = self.parent.abs_pos
+
+ return self.x+px, self.y+py, self.rotation+pa, (bool(self.flip) != bool(pf))
+
+ def bounding_box(self, unit=MM):
+ stack = LayerStack()
+ self.render(stack)
+ objects = chain(*(l.objects for l in stack.graphic_layers.values()),
+ stack.drill_pth.objects, stack.drill_npth.objects)
+ objects = list(objects)
+ #print('foo', type(self).__name__,
+ # [(type(obj).__name__, [prim.bounding_box() for prim in obj.to_primitives(unit)]) for obj in objects], file=sys.stderr)
+ return sum_bounds(prim.bounding_box() for obj in objects for prim in obj.to_primitives(unit))
+
+ def overlaps(self, bbox, unit=MM):
+ return bbox_intersect(self.bounding_box(unit), bbox)
+
+ @property
+ def single_sided(self):
+ return True
+
+
@dataclass
class Graphics(Positioned):
top_copper: list = field(default_factory=list)
@@ -336,15 +374,6 @@ class Text(Positioned):
return (self.x+x0, self.y+y0), (self.x+x0+approx_w, self.y+y0+approx_h)
-@dataclass
-class Pad(Positioned):
- pad_stack: PadStack
-
- @property
- def single_sided(self):
- return self.pad_stack.single_sided
-
-
@dataclass(frozen=True, slots=True)
class PadStackAperture:
aperture: Aperture
@@ -493,7 +522,7 @@ class ThroughViaStack(PadStack):
@dataclass(frozen=True, slots=True)
-class Via(Positioned):
+class Via(FrozenPositioned):
pad_stack: PadStack
def render(self, layer_stack, cache=None):
@@ -506,6 +535,15 @@ class Via(Positioned):
@dataclass
+class Pad(Positioned):
+ pad_stack: PadStack
+
+ @property
+ def single_sided(self):
+ return self.pad_stack.single_sided
+
+
+@dataclass
class Trace:
width: float
start: object = None