summaryrefslogtreecommitdiff
path: root/gerbonara/cad/kicad
diff options
context:
space:
mode:
authorjaseg <git@jaseg.de>2023-04-18 12:26:03 +0200
committerjaseg <git@jaseg.de>2023-04-19 00:51:27 +0200
commit2c6c9a5cbc6d389a17c0cc15173c6e626fd5d5c6 (patch)
treedd588a4beff48425d5723f767a25e51d28e4500f /gerbonara/cad/kicad
parent263033c9bdecf8c82027f6475c863d818f499914 (diff)
downloadgerbonara-2c6c9a5cbc6d389a17c0cc15173c6e626fd5d5c6.tar.gz
gerbonara-2c6c9a5cbc6d389a17c0cc15173c6e626fd5d5c6.tar.bz2
gerbonara-2c6c9a5cbc6d389a17c0cc15173c6e626fd5d5c6.zip
Basic KiCad footprint rendering works
Diffstat (limited to 'gerbonara/cad/kicad')
-rw-r--r--gerbonara/cad/kicad/base_types.py31
-rw-r--r--gerbonara/cad/kicad/footprints.py291
-rw-r--r--gerbonara/cad/kicad/graphical_primitives.py116
-rw-r--r--gerbonara/cad/kicad/sexp_mapper.py8
4 files changed, 424 insertions, 22 deletions
diff --git a/gerbonara/cad/kicad/base_types.py b/gerbonara/cad/kicad/base_types.py
index 1b3a327..8f3036c 100644
--- a/gerbonara/cad/kicad/base_types.py
+++ b/gerbonara/cad/kicad/base_types.py
@@ -25,18 +25,25 @@ class Stroke:
class Dasher:
- def __init__(self, stroke):
- self.width = stroke.width
- gap = 4*stroke.width
+ def __init__(self, obj):
+ if obj.stroke:
+ w, t = obj.stroke.width, obj.stroke.type
+ else:
+ w = obj.width or 0
+ t = Atom.solid
+
+ self.width = w
+ gap = 4*w
dot = 0
- dash = 11*stroke.width
+ dash = 11*w
self.pattern = {
Atom.dash: [dash, gap],
Atom.dot: [dot, gap],
Atom.dash_dot_dot: [dash, gap, dot, gap, dot, gap],
Atom.dash_dot: [dash, gap, dot, gap],
Atom.default: [1e99],
- Atom.solid: [1e99]}[stroke.type]
+ Atom.solid: [1e99]}[t]
+ self.solid = t in (Atom.default, Atom.solid)
self.start_x, self.start_y = None, None
self.cur_x, self.cur_y = None, None
self.segments = []
@@ -68,12 +75,14 @@ class Dasher:
def __iter__(self):
it = iter(self.segments)
segment_remaining, segment_pos = 0, 0
+
+ if self.width is None or self.width < 1e-3:
+ return
+
for length, stroked in cycle(zip(self.pattern, cycle([True, False]))):
length = max(1e-12, length)
import sys
- print('new dash', length, stroked, file=sys.stderr)
while length > 0:
- print(f'{length=} {segment_remaining=}', file=sys.stderr)
if segment_remaining == 0:
try:
x1, y1, x2, y2 = next(it)
@@ -83,7 +92,6 @@ class Dasher:
lx, ly = x1, y1
segment_remaining = math.hypot(dx, dy)
segment_pos = 0
- print('new segment', x1, y1, x2, y2, segment_remaining, file=sys.stderr)
if segment_remaining > length:
segment_pos += length
@@ -192,7 +200,12 @@ class EditTime:
self.value = time.time()
if __name__ == '__main__':
- d = Dasher(Stroke(0.01, Atom.dash_dot_dot))
+ class Foo:
+ pass
+
+ foo = Foo()
+ foo.stroke = troke(0.01, Atom.dash_dot_dot)
+ d = Dasher(foo)
#d = Dasher(Stroke(0.01, Atom.solid))
d.move(1, 1)
d.line(1, 2)
diff --git a/gerbonara/cad/kicad/footprints.py b/gerbonara/cad/kicad/footprints.py
index f76fd3f..4b95d4e 100644
--- a/gerbonara/cad/kicad/footprints.py
+++ b/gerbonara/cad/kicad/footprints.py
@@ -7,13 +7,23 @@ import enum
import datetime
import math
import time
-from typing import Any, Dict, Iterable, List, Optional, Tuple, Union
+import fnmatch
+from itertools import chain
+from pathlib import Path
from .sexp import *
from .base_types import *
from .primitives import *
from . import graphical_primitives as gr
+from ..primitives import Positioned
+
+from ... import graphic_primitives as gp
+from ... import graphic_objects as go
+from ... import apertures as ap
+from ...utils import MM
+from ...aperture_macros.parse import GenericMacros
+
@sexp_type('property')
class Property:
@@ -40,6 +50,9 @@ class Text:
effects: TextEffect = field(default_factory=TextEffect)
tstamp: Timestamp = None
+ def render(self):
+ raise NotImplementedError()
+
@sexp_type('fp_text_box')
class TextBox:
@@ -55,6 +68,9 @@ class TextBox:
stroke: Stroke = field(default_factory=Stroke)
render_cache: RenderCache = None
+ def render(self):
+ raise NotImplementedError()
+
@sexp_type('fp_line')
class Line:
@@ -66,6 +82,14 @@ class Line:
locked: Flag() = False
tstamp: Timestamp = None
+ def render(self):
+ dasher = Dasher(self)
+ dasher.move(self.start.x, self.start.y)
+ dasher.line(self.end.x, self.end.y)
+
+ for x1, y1, x2, y2 in dasher:
+ yield go.Line(x1, y1, x2, y2, aperture=ap.CircleAperture(dasher.width, unit=MM), unit=MM)
+
@sexp_type('fp_rect')
class Rectangle:
@@ -78,6 +102,27 @@ class Rectangle:
locked: Flag() = False
tstamp: Timestamp = None
+ def render(self):
+ x1, y1 = self.start.x, self.start.y
+ x2, y2 = self.end.x, self.end.y
+ x1, x2 = min(x1, x2), max(x1, x2)
+ y1, y2 = min(y1, y2), max(y1, y2)
+ w, h = x2-x1, y1-y2
+
+ if self.fill == Atom.solid:
+ yield go.Region.from_rectangle(x1, y1, w, y, unit=MM)
+
+ dasher = Dasher(self)
+ dasher.move(x1, y1)
+ dasher.line(x1, y2)
+ dasher.line(x2, y2)
+ dasher.line(x2, y1)
+ dasher.close()
+
+ aperture = ap.CircleAperture(dasher.width, unit=MM)
+ for x1, y1, x2, y2 in dasher:
+ yield go.Line(x1, y1, x2, y2, aperture=aperture, unit=MM)
+
@sexp_type('fp_circle')
class Circle:
@@ -90,6 +135,26 @@ class Circle:
locked: Flag() = False
tstamp: Timestamp = None
+ def render(self):
+ x, y = self.center.x, self.center.y
+ r = math.dist((x, y), (self.end.x, self.end.y)) # insane
+
+ circle = go.Arc.from_circle(x, y, r, unit=MM)
+ if self.fill == Atom.solid:
+ yield circle.to_region()
+
+ dasher = Dasher(self)
+ if dasher.solid:
+ circle.aperture = CircleAperture(dasher.width, unit=MM)
+ yield circle
+
+ else: # pain
+ for line in circle.approximate(): # TODO precision settings
+ dasher.segments.append((line.x1, line.y1, line.x2, line.y2))
+
+ aperture = ap.CircleAperture(dasher.width, unit=MM)
+ for x1, y1, x2, y2 in dasher:
+ yield go.Line(x1, y1, x2, y2, aperture=aperture, unit=MM)
@sexp_type('fp_arc')
class Arc:
@@ -103,6 +168,26 @@ class Arc:
tstamp: Timestamp = None
+ def render(self):
+ cx, cy = self.mid.x, self.mid.y
+ x1, y1 = self.start.x, self.start.y
+ x2, y2 = self.end.x, self.end.y
+ dasher = Dasher(self)
+
+ # KiCad only has clockwise arcs.
+ arc = go.Arc(x1, y1, x2, y2, cx-x1, cy-y1, clockwise=True, aperture=ap.CircleAperture(dasher.width, unit=MM), unit=MM)
+ if dasher.solid:
+ yield arc
+
+ else:
+ # use approximation from graphic object arc class
+ for line in arc.approximate():
+ dasher.segments.append((line.x1, line.y1, line.x2, line.y2))
+
+ for line in dasher:
+ yield go.Line(x1, y1, x2, y2, aperture=ap.CircleAperture(dasher.width, unit=MM), unit=MM)
+
+
@sexp_type('fp_poly')
class Polygon:
pts: PointList = field(default_factory=PointList)
@@ -113,6 +198,23 @@ class Polygon:
locked: Flag() = False
tstamp: Timestamp = None
+ def render(self):
+ if len(self.pts.xy) < 2:
+ return
+
+ dasher = Dasher(self)
+ start = self.pts.xy[0]
+ dasher.move(start.x, start.y)
+ for point in self.pts.xy[1:]:
+ dasher.line(point.x, point.y)
+
+ aperture = ap.CircleAperture(dasher.width, unit=MM)
+ for x1, y1, x2, y2 in dasher:
+ 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)
+
@sexp_type('fp_curve')
class Curve:
@@ -123,6 +225,9 @@ class Curve:
locked: Flag() = False
tstamp: Timestamp = None
+ def render(self):
+ raise NotImplementedError('Bezier rendering is not yet supported. Please raise an issue and provide an example file.')
+
@sexp_type('format')
class DimensionFormat:
@@ -160,6 +265,9 @@ class Dimension:
format: DimensionFormat = field(default_factory=DimensionFormat)
style: DimensionStyle = field(default_factory=DimensionStyle)
+ def render(self):
+ raise NotImplementedError()
+
@sexp_type('drill')
class Drill:
@@ -193,6 +301,14 @@ class CustomPadPrimitives:
width: Named(float) = None
fill: Named(YesNoAtom()) = True
+ def all(self):
+ yield from self.lines
+ yield from self.rectangles
+ yield from self.circles
+ yield from self.arcs
+ yield from self.polygons
+ yield from self.curves
+
@sexp_type('chamfer')
class Chamfer:
@@ -201,6 +317,7 @@ class Chamfer:
bottom_left: Flag() = False
bottom_right: Flag() = False
+
@sexp_type('pad')
class Pad:
number: str = None
@@ -234,6 +351,70 @@ class Pad:
options: OmitDefault(CustomPadOptions) = None
primitives: OmitDefault(CustomPadPrimitives) = None
+ def render(self):
+ if self.type in (Atom.connect, Atom.np_thru_hole):
+ return
+
+ yield go.Flash(self.at.x, self.at.y, self.aperture().rotated(math.radians(self.at.rotation)), unit=MM)
+
+ def aperture(self):
+ if self.shape == Atom.circle:
+ return ap.CircleAperture(self.size.x, unit=MM)
+
+ elif self.shape == Atom.rect:
+ return ap.RectangleAperture(self.size.x, self.size.y, unit=MM)
+
+ elif self.shape == Atom.oval:
+ return ap.ObroundAperture(self.size.x, self.size.y, unit=MM)
+
+ elif self.shape == Atom.trapezoid:
+ # KiCad's trapezoid aperture "rect_delta" param is just weird to the point that I think it's probably
+ # bugged. If you have a size of 2mm by 2mm, and set this param to 1mm, the resulting pad extends past the
+ # original bounding box, and the trapezoid's base and tip length are 3mm and 1mm.
+
+ x, y = self.size.x, self.size.y
+ dx, dy = self.rect_delta.x, self.rect_delta.y
+
+ # Note: KiCad already uses MM units, so no conversion needed here.
+ return ApertureMacroInstance(GenericMacros.isosceles_trapezoid,
+ [x+dx, y+dy,
+ 2*max(dx, dy),
+ 0, 0, # no hole
+ math.radians(self.at.rotation)])
+
+ elif self.shape == Atom.roundrect:
+ x, y = self.size.x, self.size.y
+ r = min(x, y) * self.roundrect_rratio
+ return ApertureMacroInstance(GenericMacros.rounded_rect,
+ [x, y,
+ r,
+ 0, 0, # no hole
+ math.radians(self.at.rotation)])
+
+ elif self.shape == Atom.custom:
+ primitives = []
+ # One round trip through the Gerbonara APIs, please!
+ for obj in self.primitives.all():
+ for gn_obj in obj.render():
+ primitives += gn_obj._aperture_macro_primitives() # todo: precision params
+ macro = ApertureMacro(primitives=primitives)
+ return ApertureMacroInstance(macro)
+
+ def render_drill(self):
+ if not self.drill:
+ return
+
+ plated = self.type != Atom.np_thru_hole
+ aperture = ap.ExcellonTool(self.drill.diameter, plated=plated, unit=MM)
+ if self.drill.oval:
+ w = self.drill.width / 2
+ l = go.Line(-w, 0, w, 0, aperture=aperture, unit=MM)
+ l.rotate(math.radians(self.at.rotation))
+ l.offset(self.at.x, self.at.y)
+ yield l
+ else:
+ yield go.Flash(self.at.x, self.at.y, aperture=aperture, unit=MM)
+
@sexp_type('group')
class Group:
@@ -304,13 +485,109 @@ class Footprint:
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:
+ def write(self, filename=None):
with open(filename or self.original_filename, 'w') as f:
f.write(build_sexp(sexp(self)))
+ @classmethod
+ def open_pretty(kls, pretty_dir, fp_name, *args, **kwargs):
+ pretty_dir = Path(pretty_dir) / f'{fp_name}.kicad_mod'
+ return kls.open_mod(pretty_dir / mod_name, *args, **kwargs)
+
+ @classmethod
+ def open_mod(kls, mod_file, *args, **kwargs):
+ return kls.load(Path(mod_file).read_text(), *args, **kwargs, original_filename=mod_file)
+
+ @classmethod
+ def open_system(kls, fp_path):
+ raise NotImplementedError()
+
+ @classmethod
+ def open_download(kls, fp_path):
+ raise NotImplementedError()
+
+ @classmethod
+ def load(kls, data, *args, **kwargs):
+ return kls.parse(data, *args, **kwargs)
+
+ @property
+ def single_sided(self):
+ raise NotImplementedError()
+
+ def objects(self, text=False, pads=True):
+ return chain(
+ (self.texts if text else []),
+ self.lines,
+ self.rectangles,
+ self.circles,
+ self.arcs,
+ self.polygons,
+ self.curves,
+ (self.dimensions if text else []),
+ (self.pads if pads else []),
+ self.zones)
+
+ def render(self, layer_stack, layer_map, x=0, y=0, rotation=0, side=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=False):
+ if not (layer := layer_map.get(obj.layer)):
+ continue
+
+ for fe in obj.render():
+ fe.rotate(rotation)
+ fe.offset(x, y, MM)
+ layer_stack[layer].objects.append(fe)
+
+ for obj in self.pads:
+ for glob in obj.layers or []:
+ for layer in fnmatch.filter(layer_map, glob):
+ for fe in obj.render():
+ fe.rotate(rotation)
+ fe.offset(x, y, MM)
+ layer_stack[layer_map[layer]].objects.append(fe)
+
+ for obj in self.pads:
+ for fe in obj.render_drill():
+ fe.rotate(rotation)
+ fe.offset(x, y, MM)
+
+ if obj.type == Atom.np_thru_hole:
+ layer_stack.drill_npth.append(fe)
+ else:
+ layer_stack.drill_pth.append(fe)
+
+LAYER_MAP = {
+ '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'),
+ 'Edge.Cuts': ('mechanical', 'outline'),
+ }
+
+
+@dataclass
+class FootprintInstance(Positioned):
+ sexp: Footprint = None
+
+ def render(self, layer_stack):
+ x, y, rotation = self.abs_pos
+ x, y = MM(x, self.unit), MM(y, self.unit)
+ self.sexp.render(layer_stack, LAYER_MAP, x=x, y=y, rotation=rotation, side=self.side)
+
+if __name__ == '__main__':
+ import sys
+ from ...layers import LayerStack
+ fp = Footprint.open_mod(sys.argv[1])
+ stack = LayerStack()
+ FootprintInstance(0, 0, fp, unit=MM).render(stack)
+ print(stack.to_pretty_svg())
+ stack.save_to_directory('/tmp/testdir')
diff --git a/gerbonara/cad/kicad/graphical_primitives.py b/gerbonara/cad/kicad/graphical_primitives.py
index 391b38b..ed40c96 100644
--- a/gerbonara/cad/kicad/graphical_primitives.py
+++ b/gerbonara/cad/kicad/graphical_primitives.py
@@ -1,8 +1,15 @@
+import math
+
from .sexp import *
from .base_types import *
from .primitives import *
+from ... import graphic_objects as go
+from ... import apertures as ap
+from ...newstroke import Newstroke
+from ...utils import rotate_point
+
@sexp_type('layer')
class TextLayer:
layer: str = ''
@@ -17,6 +24,40 @@ class Text:
tstamp: Timestamp = None
effects: TextEffect = field(default_factory=TextEffect)
+ def render(self):
+ if not self.effects or self.effects.hide or not self.effects.font:
+ return
+
+ font = Newstroke.load()
+ strokes = list(font.render(self.text, size=self.effects.font.size.y))
+ min_x = min(x for st in strokes for x, y in st)
+ min_y = min(y for st in strokes for x, y in st)
+ max_x = max(x for st in strokes for x, y in st)
+ max_y = max(y for st in strokes for x, y in st)
+ w = max_x - min_x
+ h = max_y - min_y
+
+ offx = -min_x + {
+ None: -w/2,
+ Atom.right: -w,
+ Atom.left: 0
+ }[self.effects.justify.h if self.effects.justify else None]
+ offy = {
+ None: -h/2,
+ Atom.top: -h,
+ Atom.bottom: 0
+ }[self.effects.justify.v if self.effects.justify else None]
+
+ aperture = ap.CircleAperture(self.effects.font.width or 0.2, unit=MM)
+ for stroke in strokes:
+ out = []
+ for point in stroke:
+ x, y = rotate_point(x, y, math.radians(self.at.rotation or 0))
+ x, y = x+offx, y+offy
+ out.append((x, y))
+ for p1, p2 in zip(out[:-1], out[1:]):
+ yield go.Line(*p1, *p2, aperture=ap, unit=MM)
+
@sexp_type('gr_text_box')
class TextBox:
@@ -32,16 +73,38 @@ class TextBox:
stroke: Stroke = field(default_factory=Stroke)
render_cache: RenderCache = None
+ def render(self):
+ if not render_cache or not render_cache.polygons:
+ raise ValueError('Text box 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)
+
+ if self.stroke:
+ if self.stroke.type not in (None, Atom.default, Atom.solid):
+ raise ValueError('Dashed strokes are not supported on vector text')
+
+ yield from reg.outline_objects(aperture=CircleAperture(self.stroke.width, unit=MM))
+
+ yield reg
+
@sexp_type('gr_line')
class Line:
start: Rename(XYCoord) = None
end: Rename(XYCoord) = None
- angle: Named(float) = None
+ angle: Named(float) = None # wat
layer: Named(str) = None
width: Named(float) = None
tstamp: Timestamp = None
+ def render(self):
+ if self.angle:
+ raise NotImplementedError('Angles on lines are not implemented. Please raise an issue and provide an example file.')
+
+ ap = ap.CircleAperture(self.width, unit=MM)
+ return go.Line(self.start.x, self.start.y, self.end.x, self.end.y, aperture=ap, unit=MM)
+
@sexp_type('fill')
class FillMode:
@@ -65,6 +128,17 @@ class Rectangle:
fill: FillMode = False
tstamp: Timestamp = None
+ def render(self):
+ rect = go.Region.from_rectangle(self.start.x, self.start.y,
+ self.end.x-self.start.x, self.end.y-self.start.y,
+ unit=MM)
+
+ if self.fill:
+ yield rect
+
+ if self.width:
+ yield from rect.outline_objects(aperture=CircleAperture(self.width, unit=MM))
+
@sexp_type('gr_circle')
class Circle:
@@ -75,6 +149,17 @@ class Circle:
fill: FillMode = False
tstamp: Timestamp = None
+ def render(self):
+ r = math.dist((self.center.x, self.center.y), (self.end.x, self.end.y))
+ arc = go.Arc.from_circle(self.center.x, self.center.y, r, unit=MM)
+
+ if self.width:
+ arc.aperture = ap.CircleAperture(self.width, unit=MM)
+ yield arc
+
+ if self.fill:
+ yield arc.to_region()
+
@sexp_type('gr_arc')
class Arc:
@@ -85,6 +170,19 @@ class Arc:
width: Named(float) = None
tstamp: Timestamp = None
+ def render(self):
+ cx, cy = self.mid.x, self.mid.y
+ x1, y1 = self.start.x, self.start.y
+ x2, y2 = self.end.x, self.end.y
+ arc = go.Arc(x1, y1, x2, y2, cx-x1, cy-y1, unit=MM)
+
+ if self.width:
+ arc.aperture = CircleAperture(self.width, unit=MM)
+ yield arc
+
+ if self.fill:
+ yield arc.to_region()
+
@sexp_type('gr_poly')
class Polygon:
@@ -94,6 +192,15 @@ class Polygon:
fill: FillMode= False
tstamp: Timestamp = None
+ def render(self):
+ reg = go.Region([(pt.x, pt.y) for pt in self.pts.xy], unit=MM)
+
+ if width:
+ yield from reg.outline_objects(aperture=CircleAperture(self.width, unit=MM))
+
+ if self.fill:
+ yield reg
+
@sexp_type('gr_curve')
class Curve:
@@ -102,10 +209,15 @@ class Curve:
width: Named(float) = None
tstamp: Timestamp = None
+ def render(self):
+ raise NotImplementedError('Bezier rendering is not yet supported. Please raise an issue and provide an example file.')
+
@sexp_type('gr_bbox')
class AnnotationBBox:
start: Rename(XYCoord) = None
end: Rename(XYCoord) = None
-
+ def render(self):
+ return []
+
diff --git a/gerbonara/cad/kicad/sexp_mapper.py b/gerbonara/cad/kicad/sexp_mapper.py
index cb7c99f..1d0f942 100644
--- a/gerbonara/cad/kicad/sexp_mapper.py
+++ b/gerbonara/cad/kicad/sexp_mapper.py
@@ -209,9 +209,9 @@ class _SexpTemplate:
return [kls.name_atom]
@staticmethod
- def __map__(kls, value, parent=None):
+ def __map__(kls, value, *args, parent=None, **kwargs):
positional = iter(kls.positional)
- inst = kls()
+ inst = kls(*args, **kwargs)
for v in value[1:]: # skip key
if isinstance(v, Atom) and v in kls.keys:
@@ -248,8 +248,8 @@ class _SexpTemplate:
yield out
@staticmethod
- def parse(kls, data):
- return kls.__map__(parse_sexp(data))
+ def parse(kls, data, *args, **kwargs):
+ return kls.__map__(parse_sexp(data), *args, **kwargs)
@staticmethod
def sexp(self):