summaryrefslogtreecommitdiff
path: root/gerbonara/cad/kicad
diff options
context:
space:
mode:
authorjaseg <git@jaseg.de>2023-07-20 16:42:05 +0200
committerjaseg <git@jaseg.de>2023-07-20 16:42:19 +0200
commitbdbdf7f58607bb98999e17ace8a743267a06cd9d (patch)
tree05a0b3d491d52782c8b96cfc4acbd13e44fa28e0 /gerbonara/cad/kicad
parenta1b8cbf86160eb2fbe73ffda61c953589ffa3512 (diff)
downloadgerbonara-bdbdf7f58607bb98999e17ace8a743267a06cd9d.tar.gz
gerbonara-bdbdf7f58607bb98999e17ace8a743267a06cd9d.tar.bz2
gerbonara-bdbdf7f58607bb98999e17ace8a743267a06cd9d.zip
Schematic rendering WIP
Diffstat (limited to 'gerbonara/cad/kicad')
-rw-r--r--gerbonara/cad/kicad/base_types.py151
-rw-r--r--gerbonara/cad/kicad/footprints.py2
-rw-r--r--gerbonara/cad/kicad/graphical_primitives.py42
-rw-r--r--gerbonara/cad/kicad/schematic.py240
-rw-r--r--gerbonara/cad/kicad/schematic_colors.py12
-rw-r--r--gerbonara/cad/kicad/symbols.py285
6 files changed, 627 insertions, 105 deletions
diff --git a/gerbonara/cad/kicad/base_types.py b/gerbonara/cad/kicad/base_types.py
index f763585..263f19d 100644
--- a/gerbonara/cad/kicad/base_types.py
+++ b/gerbonara/cad/kicad/base_types.py
@@ -1,14 +1,17 @@
-from .sexp import *
-from .sexp_mapper import *
+import string
import time
-
from dataclasses import field, replace
import math
import uuid
from contextlib import contextmanager
from itertools import cycle
-from ...utils import rotate_point
+from .sexp import *
+from .sexp_mapper import *
+from ...newstroke import Newstroke
+from ...utils import rotate_point, Tag, MM
+from ... import apertures as ap
+from ... import graphic_objects as go
LAYER_MAP_K2G = {
@@ -46,7 +49,16 @@ class Color:
r: int = None
g: int = None
b: int = None
- a: int = None
+ a: float = None
+
+ def __bool__(self):
+ return self.r or self.b or self.g or not math.isclose(self.a, 0, abs_tol=1e-3)
+
+ def svg(self, default=None):
+ if default and not self:
+ return default
+
+ return f'rgba({self.r} {self.g} {self.b} {self.a})'
@sexp_type('stroke')
@@ -54,7 +66,32 @@ class Stroke:
width: Named(float) = 0.254
type: Named(AtomChoice(Atom.dash, Atom.dot, Atom.dash_dot_dot, Atom.dash_dot, Atom.default, Atom.solid)) = Atom.default
color: Color = None
+
+ def svg_color(self, default=None):
+ if self.color:
+ return self.color.svg(default)
+ else:
+ return default
+ def svg_attrs(self, default_color=None):
+ w = self.width
+ if not (color := self.color or default_color):
+ return {}
+
+ attrs = {'stroke': color,
+ 'stroke_linecap': 'round',
+ 'stroke_widtj': self.width}
+
+ if self.type not in (Atom.default, Atom.solid):
+ attrs['stroke_dasharray'] = {
+ Atom.dash: f'{w*5:.3f},{w*5:.3f}',
+ Atom.dot: f'{w*2:.3f},{w*2:.3f}',
+ Atom.dash_dot: f'{w*5:.3f},{w*3:.3f}{w:.3f},{w*3:.3f}',
+ Atom.dash_dot_dot: f'{w*5:.3f},{w*3:.3f}{w:.3f},{w*3:.3f}{w:.3f},{w*3:.3f}',
+ }[self.type]
+
+ return attrs
+
class Dasher:
def __init__(self, obj):
@@ -140,6 +177,19 @@ class Dasher:
if stroked:
yield lx, ly, x2, y2
+ def svg(self, **kwargs):
+ if 'fill' not in kwargs:
+ kwargs['fill'] = 'none'
+ if 'stroke' not in kwargs:
+ kwargs['stroke'] = 'black'
+ if 'stroke_width' not in kwargs:
+ kwargs['stroke_width'] = 0.254
+ if 'stroke_linecap' not in kwargs:
+ kwargs['stroke_linecap'] = 'round'
+
+ d = ' '.join(f'M {x1:.3f} {y1:.3f} L {x2:.3f} {y2:.3f}' for x1, y1, x2, y2 in self)
+ return Tag('path', d=d, **kwargs)
+
@sexp_type('xy')
class XYCoord:
@@ -158,7 +208,7 @@ class XYCoord:
else:
self.x, self.y = x, y
- def isclose(self, other, tol=1e-6):
+ def isclose(self, other, tol=1e-3):
return math.isclose(self.x, other.x, tol) and math.isclose(self.y, other.y, tol)
def with_offset(self, x=0, y=0):
@@ -168,6 +218,7 @@ class XYCoord:
x, y = rotate_point(self.x, self.y, angle, cx, cy)
return replace(self, x=x, y=y)
+
@sexp_type('pts')
class PointList:
xy : List(XYCoord) = field(default_factory=list)
@@ -226,6 +277,83 @@ class TextEffect:
hide: Flag() = False
justify: OmitDefault(Justify) = field(default_factory=Justify)
+class TextMixin:
+ @property
+ def size(self):
+ return self.effects.font.size.y or 1.27
+
+ @size.setter
+ def size(self, value):
+ self.effects.font.size.x = self.effects.font.size.y = value
+
+ @property
+ def line_width(self):
+ return self.effects.font.thickness or 0.254
+
+ @line_width.setter
+ def line_width(self, value):
+ self.effects.font.thickness = value
+
+ def bounding_box(self, default=None):
+ if not self.text or not self.text.strip():
+ return default
+
+ lines = list(self.render())
+ x1 = min(min(l.x1, l.x2) for l in lines)
+ y1 = min(min(l.y1, l.y2) for l in lines)
+ x2 = max(max(l.x1, l.x2) for l in lines)
+ y2 = max(max(l.y1, l.y2) for l in lines)
+ r = self.effects.font.thickness/2
+ return (x1-r, y1-r), (x2+r, y2+r)
+
+ def svg_path_data(self):
+ for line in self.render():
+ yield f'M {line.x1:.3f} {line.y1:.3f} L {line.x2:.3f} {line.y2:.3f}'
+
+ def to_svg(self, color='black'):
+ d = ' '.join(self.svg_path_data())
+ yield Tag('path', d=d, fill='none', stroke=color, stroke_width=f'{self.line_width:.3f}')
+
+ def render(self, variables={}):
+ if not self.effects or self.effects.hide or not self.effects.font:
+ return
+
+ font = Newstroke.load()
+ text = string.Template(self.text).safe_substitute(variables)
+ strokes = list(font.render(text, size=self.size))
+ 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: self.size/2,
+ Atom.top: self.size,
+ Atom.bottom: 0
+ }[self.effects.justify.v if self.effects.justify else None]
+
+ aperture = ap.CircleAperture(self.line_width or 0.2, unit=MM)
+ for stroke in strokes:
+ out = []
+
+ for x, y in stroke:
+ x, y = x+offx, y+offy
+ x, y = rotate_point(x, y, math.radians(self.at.rotation or 0))
+ x, y = x+self.at.x, y+self.at.y
+ out.append((x, y))
+
+ for p1, p2 in zip(out[:-1], out[1:]):
+ yield go.Line(*p1, *p2, aperture=aperture, unit=MM)
+
+
@sexp_type('tstamp')
class Timestamp:
@@ -293,7 +421,7 @@ class Property:
@sexp_type('property')
-class DrawnProperty:
+class DrawnProperty(TextMixin):
key: str = None
value: str = None
id: Named(int) = None
@@ -303,6 +431,15 @@ class DrawnProperty:
tstamp: Timestamp = None
effects: TextEffect = field(default_factory=TextEffect)
+ # Alias value for text mixin
+ @property
+ def text(self):
+ return self.value
+
+ @text.setter
+ def text(self, value):
+ self.value = value
+
if __name__ == '__main__':
class Foo:
diff --git a/gerbonara/cad/kicad/footprints.py b/gerbonara/cad/kicad/footprints.py
index 805b1e0..47d474a 100644
--- a/gerbonara/cad/kicad/footprints.py
+++ b/gerbonara/cad/kicad/footprints.py
@@ -362,7 +362,7 @@ class Pad:
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
at: AtPos = field(default_factory=AtPos)
- locked: Wrap(Flag()) = False
+ locked: Flag() = False
size: Rename(XYCoord) = field(default_factory=XYCoord)
drill: Drill = None
layers: Named(Array(str)) = field(default_factory=list)
diff --git a/gerbonara/cad/kicad/graphical_primitives.py b/gerbonara/cad/kicad/graphical_primitives.py
index 9ddd807..f1d13e6 100644
--- a/gerbonara/cad/kicad/graphical_primitives.py
+++ b/gerbonara/cad/kicad/graphical_primitives.py
@@ -18,7 +18,7 @@ class TextLayer:
@sexp_type('gr_text')
-class Text:
+class Text(TextMixin):
text: str = ''
at: AtPos = field(default_factory=AtPos)
layer: TextLayer = field(default_factory=TextLayer)
@@ -26,46 +26,6 @@ class Text:
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:
- return
-
- font = Newstroke.load()
- line_width = self.effects.font.thickness
- text = string.Template(self.text).safe_substitute(variables)
- strokes = list(font.render(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: self.effects.font.size.y/2,
- Atom.top: self.effects.font.size.y,
- Atom.bottom: 0
- }[self.effects.justify.v if self.effects.justify else None]
-
- aperture = ap.CircleAperture(line_width or 0.2, unit=MM)
- for stroke in strokes:
- out = []
-
- for x, y in stroke:
- x, y = x+offx, y+offy
- x, y = rotate_point(x, y, math.radians(self.at.rotation or 0))
- x, y = x+self.at.x, y+self.at.y
- out.append((x, y))
-
- 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)
diff --git a/gerbonara/cad/kicad/schematic.py b/gerbonara/cad/kicad/schematic.py
index 1bdbf38..846df2a 100644
--- a/gerbonara/cad/kicad/schematic.py
+++ b/gerbonara/cad/kicad/schematic.py
@@ -9,6 +9,7 @@ from itertools import chain
import re
import fnmatch
import os.path
+import warnings
from .sexp import *
from .base_types import *
@@ -23,8 +24,26 @@ from ... import graphic_objects as go
from ... import apertures as ap
from ...layers import LayerStack
from ...newstroke import Newstroke
-from ...utils import MM, rotate_point
-
+from ...utils import MM, rotate_point, Tag, setup_svg
+from .schematic_colors import *
+
+
+KICAD_PAPER_SIZES = {
+ 'A5': (210, 148),
+ 'A4': (297, 210),
+ 'A3': (420, 297),
+ 'A2': (594, 420),
+ 'A1': (841, 594),
+ 'A0': (1189, 841),
+ 'A': (11*25.4, 8.5*25.4),
+ 'B': (17*25.4, 11*15.4),
+ 'C': (22*25.4, 17*25.4),
+ 'D': (34*25.4, 22*25.4),
+ 'E': (44*25.4, 34*25.4),
+ 'USLetter': (11*25.4, 8.5*25.4),
+ 'USLegal': (14*25.4, 8.5*25.4),
+ 'USLedger': (17*25.4, 11*25.4),
+ }
@sexp_type('path')
class SheetPath:
@@ -39,12 +58,29 @@ class Junction:
color: Color = field(default_factory=lambda: Color(0, 0, 0, 0))
uuid: UUID = field(default_factory=UUID)
+ def bounding_box(self, default=None):
+ r = (self.diameter/2 or 0.635)
+ return (self.at.x - r, self.at.y - r), (self.at.x + r, self.at.y + r)
+
+ def to_svg(self, colorscheme=Colorscheme.KiCad):
+ yield Tag('circle', cx=f'{self.at.x:.3f}', cy=f'{self.at.y:.3f}', r=(self.diameter/2 or 0.635),
+ fill=self.color.svg(colorscheme.wire))
+
@sexp_type('no_connect')
class NoConnect:
at: Rename(XYCoord) = field(default_factory=XYCoord)
uuid: UUID = field(default_factory=UUID)
+ def bounding_box(self, default=None):
+ r = 0.635
+ return (self.at.x - r, self.at.y - r), (self.at.x + r, self.at.y + r)
+
+ def to_svg(self, colorscheme=Colorscheme.KiCad):
+ r = 0.635
+ yield Tag('path', d=f'M {-r:.3f} {-r:.3f} L {r:.3f} {r:.3f} M {-r:.3f} {r:.3f} L {r:.3f} {-r:.3f}',
+ fill='none', stroke_width='0.1', stroke=colorscheme.no_connect)
+
@sexp_type('bus_entry')
class BusEntry:
@@ -53,6 +89,44 @@ class BusEntry:
stroke: Stroke = field(default_factory=Stroke)
uuid: UUID = field(default_factory=UUID)
+ def bounding_box(self, default=None):
+ r = math.hypot(self.size.x, self.size.y)
+ x1, y1 = self.at.x, self.at.y
+ x2, y2 = rotate_point(x1+r, y1+r, self.at.rotation or 0)
+ x1, x2 = min(x1, x2), max(x1, x2)
+ y1, y2 = min(y1, y2), max(y1, y2)
+
+ r = (self.stroke.width or 0.254) / 2
+ return (x1-r, y1-r), (x2+r, y2+r)
+
+ def to_svg(self, colorscheme=Colorscheme.KiCad):
+ yield Tag('path', d='M {self.at.x} {self.at.y} l {self.size.x} {self.size.y}',
+ transform=f'rotate({self.at.rotation or 0})',
+ fill='none', stroke=self.stroke.svg_color(colorscheme.bus), width=self.stroke.width or '0.254')
+
+
+def _polyline_svg(self, default_color):
+ da = Dasher(self)
+ if len(self.points.xy) < 2:
+ warnings.warn(f'Schematic {type(self)} with less than two points')
+
+ x0, y0, *rest = self.points.xy
+ da.move(x0, y0)
+ for xn, yn in rest:
+ da.line(xn, yn)
+
+ return da.svg(stroke=self.stroke.svg_color(default_color))
+
+
+def _polyline_bounds(self):
+ x1 = min(pt.x for pt in self.points)
+ y1 = min(pt.y for pt in self.points)
+ x2 = max(pt.x for pt in self.points)
+ y2 = max(pt.y for pt in self.points)
+
+ r = (self.stroke.width or 0.254) / 2
+ return (x1-r, y1-r), (x2+r, y2+r)
+
@sexp_type('wire')
class Wire:
@@ -60,6 +134,12 @@ class Wire:
stroke: Stroke = field(default_factory=Stroke)
uuid: UUID = field(default_factory=UUID)
+ def bounding_box(self, default=None):
+ return _polyline_bounds(self)
+
+ def to_svg(self, colorscheme=Colorscheme.KiCad):
+ yield _polyline_svg(self, colorscheme.wire)
+
@sexp_type('bus')
class Bus:
@@ -67,6 +147,12 @@ class Bus:
stroke: Stroke = field(default_factory=Stroke)
uuid: UUID = field(default_factory=UUID)
+ def bounding_box(self, default=None):
+ return _polyline_bounds(self)
+
+ def to_svg(self, colorscheme=Colorscheme.KiCad):
+ yield _polyline_svg(self, colorscheme.bus)
+
@sexp_type('polyline')
class Polyline:
@@ -74,27 +160,62 @@ class Polyline:
stroke: Stroke = field(default_factory=Stroke)
uuid: UUID = field(default_factory=UUID)
+ def bounding_box(self, default=None):
+ return _polyline_bounds(self)
+
+ def to_svg(self, colorscheme=Colorscheme.KiCad):
+ yield _polyline_svg(self, colorscheme.lines)
+
@sexp_type('text')
-class Text:
+class Text(TextMixin):
text: str = ''
exclude_from_sim: Named(YesNoAtom()) = True
at: AtPos = field(default_factory=AtPos)
effects: TextEffect = field(default_factory=TextEffect)
uuid: UUID = field(default_factory=UUID)
+ def to_svg(self, colorscheme=Colorscheme.KiCad):
+ yield from TextMixin.to_svg(self, colorscheme.text)
+
@sexp_type('label')
-class LocalLabel:
+class LocalLabel(TextMixin):
text: str = ''
at: AtPos = field(default_factory=AtPos)
fields_autoplaced: Wrap(Flag()) = False
effects: TextEffect = field(default_factory=TextEffect)
uuid: UUID = field(default_factory=UUID)
+ def to_svg(self, colorscheme=Colorscheme.KiCad):
+ yield from TextMixin.to_svg(self, colorscheme.text)
+
+
+def label_shape_path_d(shape, w, h):
+ l, r = {
+ Atom.input: '<]',
+ Atom.output: '[>',
+ Atom.bidirectional: '<>',
+ Atom.tri_state: '<>',
+ Atom.passive: '[]'}.get(shape, '<]')
+ r = h/2
+
+ if l == '[':
+ d = 'M {r:.3f} {r:.3f} L 0 {r:.3f} L 0 {-r:.3f} L {r:.3f} {-r:.3f}'
+ else:
+ d = 'M {r:.3f} {r:.3f} L 0 0 L {r:.3f} {-r:.3f}'
+
+ e = w+r
+ d += ' L {e:.3f} {-r:.3f}'
+
+ if l == '[':
+ return d + 'L {e+r:.3f} {-r:.3f} L {e+r:.3f} {r:.3f} L {e:.3f} {r:.3f} Z'
+ else:
+ return d + 'L {e+r:.3f} {0:.3f} L {e:.3f} {r:.3f} Z'
+
@sexp_type('global_label')
-class GlobalLabel:
+class GlobalLabel(TextMixin):
text: str = ''
shape: Named(AtomChoice(Atom.input, Atom.output, Atom.bidirectional, Atom.tri_state, Atom.passive)) = Atom.input
at: AtPos = field(default_factory=AtPos)
@@ -103,9 +224,17 @@ class GlobalLabel:
uuid: UUID = field(default_factory=UUID)
properties: List(Property) = field(default_factory=list)
+ def to_svg(self, colorscheme=Colorscheme.KiCad):
+ text = super(TextMixin, self).to_svg(colorscheme.text),
+ text.attrs['transform'] = f'translate({self.size*0.6:.3f} 0)'
+ (x1, y1), (x2, y2) = self.bounding_box()
+ frame = Tag('path', fill='none', stroke_width=0.254, stroke=colorscheme.lines,
+ d=label_shape_path_d(self.shape, self.size*0.2 + y2-y1, self.size*1.2 + 0.254))
+ yield Tag('g', children=[frame, text])
+
@sexp_type('hierarchical_label')
-class HierarchicalLabel:
+class HierarchicalLabel(TextMixin):
text: str = ''
shape: Named(AtomChoice(Atom.input, Atom.output, Atom.bidirectional, Atom.tri_state, Atom.passive)) = Atom.input
at: AtPos = field(default_factory=AtPos)
@@ -113,6 +242,13 @@ class HierarchicalLabel:
effects: TextEffect = field(default_factory=TextEffect)
uuid: UUID = field(default_factory=UUID)
+ def to_svg(self, colorscheme=Colorscheme.KiCad):
+ text, = TextMixin.to_svg(self, colorscheme.text),
+ text.attrs['transform'] = f'translate({self.size*1.2:.3f} 0)'
+ frame = Tag('path', fill='none', stroke_width=0.254, stroke=colorscheme.lines,
+ d=label_shape_path_d(self.shape, self.size, self.size))
+ yield Tag('g', children=[frame, text])
+
@sexp_type('pin')
class Pin:
@@ -141,13 +277,26 @@ class MirrorFlags:
@sexp_type('property')
-class DrawnProperty:
+class DrawnProperty(TextMixin):
key: str = None
value: str = None
at: AtPos = field(default_factory=AtPos)
hide: Flag() = False
effects: TextEffect = field(default_factory=TextEffect)
+ # Alias value for text mixin
+ @property
+ def text(self):
+ return self.value
+
+ @text.setter
+ def text(self, value):
+ self.value = value
+
+ def to_svg(self, colorscheme=Colorscheme.KiCad):
+ if not self.hide:
+ yield from TextMixin.to_svg(self, colorscheme.text)
+
@sexp_type('symbol')
class SymbolInstance:
@@ -168,6 +317,31 @@ class SymbolInstance:
# AFAICT this property, too, is completely redundant. It ultimately just lists paths and references of at most
# three other uses of the same symbol in this schematic.
instances: Named(List(SymbolCrosslinkProject)) = field(default_factory=list)
+ _ : SEXP_END = None
+ schematic: object = None
+
+ def __after_parse__(self, parent):
+ self.schematic = parent
+
+ def to_svg(self, colorscheme=Colorscheme.KiCad):
+ children = []
+
+ for prop in self.properties:
+ children += prop.to_svg()
+
+ sym = self.schematic.lookup_symbol(self.lib_name, self.lib_id).raw_units[self.unit - 1]
+ for elem in sym.graphical_elements:
+ children += elem.to_svg(colorscheme)
+
+ xform = f'translate({self.at.x:.3f} {self.at.y:.3f})'
+ if self.at.rotation:
+ xform = f'rotate({self.at.rotation}) {xform}'
+ if self.mirror.x:
+ xform = f'scale(-1 1) {xform}'
+ if self.mirror.y:
+ xform = f'scale(1 -1) {xform}'
+
+ yield Tag('g', children=children, transform=xform, fill=colorscheme.fill, stroke=colorscheme.lines)
@sexp_type('path')
@@ -189,6 +363,11 @@ class SubsheetPin:
at: AtPos = field(default_factory=AtPos)
effects: TextEffect = field(default_factory=TextEffect)
uuid: UUID = field(default_factory=UUID)
+ _ : SEXP_END = None
+ subsheet: object = None
+
+ def __after_parse__(self, parent):
+ self.subsheet = parent
@sexp_type('fill')
@@ -235,6 +414,21 @@ class Subsheet:
return Schematic.open(resolved)
+ def to_svg(self, colorscheme=Colorscheme.KiCad):
+ children = []
+
+ for prop in self._properties:
+ children += prop.to_svg(colorscheme)
+
+ # FIXME
+ #for elem in self.pins:
+ # children += pin.to_svg(colorscheme)
+
+ xform = f'translate({self.at.x:.3f} {self.at.y:.3f})'
+ yield Tag('g', children=children, transform=xform,
+ fill=self.fill.color.svg(colorscheme.fill),
+ **self.stroke.svg_attrs(colorscheme.lines))
+
@sexp_type('lib_symbols')
class LocalLibrary:
@@ -277,6 +471,14 @@ class Schematic:
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 lookup_symbol(self, lib_name, lib_id):
+ key = lib_name or lib_id
+ for sym in self.lib_symbols.symbols:
+ if sym.name == key or sym.raw_name == key:
+ return sym
+ raise KeyError(f'Symbol with {lib_name=} {lib_id=} not found')
+
def write(self, filename=None):
with open(filename or self.original_filename, 'w') as f:
f.write(self.serialize())
@@ -292,6 +494,29 @@ class Schematic:
def load(kls, data, *args, **kwargs):
return kls.parse(data, *args, **kwargs)
+ @property
+ def elements(self):
+ yield from self.junctions
+ yield from self.no_connects
+ yield from self.bus_entries
+ yield from self.wires
+ yield from self.buses
+ yield from self.images
+ yield from self.polylines
+ yield from self.texts
+ yield from self.local_labels
+ yield from self.global_labels
+ yield from self.hierarchical_labels
+ yield from self.symbols
+ yield from self.subsheets
+
+ def to_svg(self, colorscheme=Colorscheme.KiCad):
+ children = []
+ for elem in self.elements:
+ children += elem.to_svg(colorscheme)
+ w, h = KICAD_PAPER_SIZES[self.page_settings.page_format]
+ return setup_svg(children, ((0, 0), (w, h)))
+
if __name__ == '__main__':
import sys
@@ -303,4 +528,5 @@ if __name__ == '__main__':
print('Loaded sub-sheet with', len(subsh.wires), 'wires and', len(subsh.symbols), 'symbols.')
sch.write('/tmp/test.kicad_sch')
+ Path('/tmp/test.svg').write_text(str(sch.to_svg()))
diff --git a/gerbonara/cad/kicad/schematic_colors.py b/gerbonara/cad/kicad/schematic_colors.py
new file mode 100644
index 0000000..4bcbe7b
--- /dev/null
+++ b/gerbonara/cad/kicad/schematic_colors.py
@@ -0,0 +1,12 @@
+
+class Colorscheme:
+ class KiCad:
+ wire = 'black'
+ bus = 'black'
+ lines = 'black'
+ no_connect = 'black'
+ text = 'black'
+ values = 'black'
+ labels = 'black'
+ fill = '#cccccc'
+
diff --git a/gerbonara/cad/kicad/symbols.py b/gerbonara/cad/kicad/symbols.py
index 0f32b4d..b21b94e 100644
--- a/gerbonara/cad/kicad/symbols.py
+++ b/gerbonara/cad/kicad/symbols.py
@@ -17,6 +17,9 @@ from typing import Any, Dict, List, Optional, Tuple
from .sexp import *
from .sexp_mapper import *
from .base_types import *
+from ...utils import rotate_point, Tag, arc_bounds
+from ...newstroke import Newstroke
+from .schematic_colors import *
PIN_ETYPE = AtomChoice(Atom.input, Atom.output, Atom.bidirectional, Atom.tri_state, Atom.passive, Atom.free,
@@ -60,11 +63,129 @@ class Pin:
def direction(self, value):
self.at.rotation = {0: 'R', 90: 'U', 180: 'L', 270: 'D'}[value[0].upper()]
+ def bounding_box(self, default=None):
+ font = Newstroke.load()
+ strokes = list(font.render(self.name, size=2.54))
+ 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, h = max_x - min_x, max_y - min_y
+ l = self.length + 0.2 + w
+
+ x1, y1 = x2, y2 = self.at.x, self.at.y
+ if self.at.rotation == 0:
+ x2 += w
+ y1 -= h/2
+ y2 += h/2
+ if self.at.rotation == 90:
+ y2 += w
+ x1 -= h/2
+ x2 += h/2
+ if self.at.rotation == 180:
+ x1 -= w
+ y1 -= h/2
+ y2 += h/2
+ if self.at.rotation == 270:
+ y1 -= w
+ x1 -= h/2
+ x2 += h/2
+ else:
+ raise ValueError(f'Invalid pin rotation {self.at.rotation}')
+
+ return (x1, y1), (x2, y2)
+
+ def to_svg(self, colorscheme=Colorscheme.KiCad):
+ x1, y1 = self.at.x, self.at.y
+ x2, y2 = x1+self.length, y1
+ xform = {'transform': f'rotate({-self.at.rotation} {x1} {y1})'}
+ style = {'stroke_width': 0.254, 'stroke': colorscheme.lines}
+
+ yield Tag('path', **xform, **style, d=f'M {x1:.6f} {y1:.6f} L {x2:.6f} {y2:.6f}')
+
+ eps = 1
+ for tag in {
+ 'line': [],
+ 'inverted': [
+ Tag('circle', **xform, **style, cx=x2-eps/3-0.2, cy=y2, r=eps/3)],
+ 'clock': [
+ Tag('path', **xform, **style, d=f'M {x2} {y2-eps/2} L {x2+eps/2} {y2} L {x2} {y2+eps/2}')], # NOQA: E501
+ 'inverted_clock': [
+ Tag('circle', **xform, **style, cx=x2-eps/3-0.2, cy=y2, r=eps/3),
+ Tag('path', **xform, **style, d=f'M {x2} {y2-eps/2} L {x2+eps/2} {y2} L {x2} {y2+eps/2}')], # NOQA: E501
+ 'input_low': [
+ Tag('path', **xform, **style, d=f'M {x2} {y2} L {x2-eps} {y2-eps} L {x2-eps} {y2}')], # NOQA: E501
+ 'clock_low': [
+ Tag('path', **xform, **style, d=f'M {x2} {y2} L {x2-eps} {y2-eps} L {x2-eps} {y2}'), # NOQA: E501
+ Tag('path', **xform, **style, d=f'M {x2} {y2-eps/2} L {x2+eps/2} {y2} L {x2} {y2+eps/2}')], # NOQA: E501
+ 'output_low': [
+ Tag('path', **xform, **style, d=f'M {x2} {y2-eps} L {x2-eps} {y2}')], # NOQA: E501
+ 'edge_clock_high': [
+ Tag('path', **xform, **style, d=f'M {x2} {y2} L {x2-eps} {y2-eps} L {x2-eps} {y2}'), # NOQA: E501
+ Tag('path', **xform, **style, d=f'M {x2} {y2-eps/2} L {x2+eps/2} {y2} L {x2} {y2+eps/2}')], # NOQA: E501
+ 'non_logic': [
+ Tag('path', **xform, **style, d=f'M {x2-eps/2} {y2-eps/2} L {x2+eps/2} {y2+eps/2}'), # NOQA: E501
+ Tag('path', **xform, **style, d=f'M {x2-eps/2} {y2+eps/2} L {x2+eps/2} {y2-eps/2}')], # NOQA: E501
+ # FIXME...
+ }.get(self.style, []):
+ yield tag
+
+ if self.at.rotation in (90, 270):
+ t_rot = 90
+ else:
+ t_rot = 0
+
+ size = self.name.effects.font.size.y or 1.27
+ font = Newstroke.load()
+ strokes = list(font.render(self.name.value, size=size))
+ min_x = min(x for st in strokes for x, y in st) if strokes else 0
+ min_y = min(y for st in strokes for x, y in st) if strokes else 0
+ max_x = max(x for st in strokes for x, y in st) if strokes else 0
+ max_y = max(y for st in strokes for x, y in st) if strokes else 0
+ w = max_x - min_x
+ h = max_y - min_y
+
+ if self.at.rotation == 0:
+ offx = -min_x + self.length + 0.2
+ offy = h/2
+ elif self.at.rotation == 180:
+ offx = min_x - self.length - 0.2 - w
+ offy = h/2
+ elif self.at.rotation == 90:
+ offx = -h/2
+ offy = min_x - self.length - 0.2 - w
+ elif self.at.rotation == 270:
+ offx = -h/2
+ offy = -min_x + self.length + 0.2
+ else:
+ raise ValueError(f'Invalid pin rotation {self.at.rotation}')
+
+ yield f'M {line.x1:.3f} {line.y1:.3f} L {line.x2:.3f} {line.y2:.3f}'
+
+ d = []
+ for stroke in strokes:
+ points = []
+ for x, y in stroke:
+ x, y = x+offx, y+offy
+ x, y = rotate_point(x, y, math.radians(self.at.rotation or 0))
+ x, y = x+self.at.x, y+self.at.y
+ points.append(f'{x:.3f} {y:.3f}')
+ d.append('M '+ ' L '.join(points) + ' ')
+ yield Tag('path', d=d, fill='none', stroke=colorscheme.text, stroke_width='0.254')
+
@sexp_type('fill')
class Fill:
type: Named(AtomChoice(Atom.none, Atom.outline, Atom.background)) = Atom.none
+ def svg(self, fg, bg):
+ if self.type == 'outline':
+ return fg
+ elif self.type == 'background':
+ return bg
+ else:
+ return 'none'
+
@sexp_type('circle')
class Circle:
@@ -73,6 +194,37 @@ class Circle:
stroke: Stroke = field(default_factory=Stroke)
fill: Fill = field(default_factory=Fill)
+ def bounding_box(self, default=None):
+ x, y, r = self.center.x, self.center.y, self.radius
+ return (x-r, y-r), (x+r, y+r)
+
+ def to_svg(self, colorscheme=Colorscheme.KiCad):
+ yield Tag('circle', cx=f'{self.center.x:.3f}', cy=f'{self.center.y:.3f}', r=f'{self.radius:.3f}',
+ fill=self.fill.svg(colorscheme.lines, colorscheme.fill),
+ **self.stroke.svg_attrs(colorscheme.lines))
+
+
+# https://stackoverflow.com/questions/28910718/give-3-points-and-a-plot-circle
+def define_circle(p1, p2, p3):
+ """
+ Returns the center and radius of the circle passing the given 3 points.
+ In case the 3 points form a line, raises a ValueError.
+ """
+ temp = p2[0] * p2[0] + p2[1] * p2[1]
+ bc = (p1[0] * p1[0] + p1[1] * p1[1] - temp) / 2
+ cd = (temp - p3[0] * p3[0] - p3[1] * p3[1]) / 2
+ det = (p1[0] - p2[0]) * (p2[1] - p3[1]) - (p2[0] - p3[0]) * (p1[1] - p2[1])
+
+ if abs(det) < 1.0e-6:
+ raise ValueError()
+
+ # Center of circle
+ cx = (bc*(p2[1] - p3[1]) - cd*(p1[1] - p2[1])) / det
+ 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)
+
@sexp_type('arc')
class Arc:
@@ -82,7 +234,30 @@ class Arc:
stroke: Stroke = field(default_factory=Stroke)
fill: Fill = field(default_factory=Fill)
- # TODO add function to calculate center, bounding box
+ def bounding_box(self, default=None):
+ (cx, cy), r = define_circle((self.start.x, self.start.y), (self.mid.x, self.mid.y), (self.end.x, self.end.y))
+ x1, y1 = self.start.x, self.start.y
+ x2, y2 = self.mid.x-x1, self.mid.y-x2
+ x3, y3 = (self.end.x - x1)/2, (self.end.y - y1)/2
+ clockwise = math.atan2(x2*y3-x3*y2, x2*x3+y2*y3) > 0
+ return arc_bounds(x1, y1, self.end.x, self.end.y, cx-x1, cy-y1, clockwise)
+
+
+ def to_svg(self, colorscheme=Colorscheme.KiCad):
+ (cx, cy), r = define_circle((self.start.x, self.start.y), (self.mid.x, self.mid.y), (self.end.x, self.end.y))
+
+ x1r = self.start.x - cx
+ y1r = self.start.y - cy
+ x2r = self.end.x - cx
+ y2r = self.end.y - cy
+ a1 = math.atan2(x1r, y1r)
+ a2 = math.atan2(x2r, y2r)
+ da = (a2 - a1 + math.pi) % (2*math.pi) - math.pi
+
+ large_arc = int(da > math.pi)
+ d = f'M {self.start.x:.3f} {self.start.y:.3f} A {r:.3f} {r:.3f} 0 {large_arc} 0 {self.end.x:.3f} {self.end.y:.3f}'
+ yield Tag('path', d=d, fill=self.fill.svg(colorscheme.lines, colorscheme.fill),
+ **self.stroke.svg_attrs(colorscheme.lines))
@sexp_type('polyline')
@@ -103,54 +278,41 @@ class Polyline:
def closed(self):
# if the last and first point are the same, we consider the polyline closed
# a closed triangle will have 4 points (A-B-C-A) stored in the list of points
- return len(self.points) > 3 and self.points[0] == self.points[-1]
+ return len(self.points) > 3 and self.points[0].isclose(self.points[-1])
- @property
- def bbox(self):
+ def bounding_box(self, default=None):
if not self.points:
- return (0.0, 0.0, 0.0, 0.0)
+ return default
- return (min(p.x for p in self.points),
- min(p.y for p in self.points),
- max(p.x for p in self.points),
- max(p.y for p in self.points))
+ return (min(p.x for p in self.points), min(p.y for p in self.points)), \
+ (max(p.x for p in self.points), max(p.y for p in self.points))
def as_rectangle(self):
- (maxx, maxy, minx, miny) = self.get_boundingbox()
- return Rectangle(
- minx,
- maxy,
- maxx,
- miny,
- self.stroke_width,
- self.stroke_color,
- self.fill_type,
- self.fill_color,
- unit=self.unit,
- demorgan=self.demorgan,
- )
+ (maxx, maxy, minx, miny) = self.bbox()
+ return Rectangle(minx, maxy, maxx, miny, self.stroke, self.fill)
- def get_center_of_boundingbox(self):
- (maxx, maxy, minx, miny) = self.get_boundingbox()
- return ((minx + maxx) / 2, ((miny + maxy) / 2))
+ def to_svg(self, colorscheme=Colorscheme.KiCad):
+ p0, *rest = self.points
+ if not rest:
+ return
+
+ d = ' '.join([f'M {p0.x:.3f} {p0.y:.3f}', *(f'L {pn.x:.3f} {pn.y:.3f}' for pn in rest)])
+ yield Tag('path', d=d, fill=self.fill.svg(colorscheme.lines, colorscheme.fill), **self.stroke.svg_attrs(colorscheme.lines))
def is_rectangle(self):
- # a rectangle has 5 points and is closed
+ # A rectangle has 5 points and is closed
if len(self.points) != 5 or not self.is_closed():
return False
+
+ # Check that we have all four corners present
+ (x1, y1), (x2, y2) = self.bbox()
+ if not all(any(cand.isclose(pt) for cand in self.points[:-1]) for pt in
+ [(x1, y1), (x1, y2), (x2, y2), (x2, y1)]):
+ return False
- # construct lines between the points
- p0 = self.points[0]
- for p1_idx in range(1, len(self.points)):
- p1 = self.points[p1_idx]
- dx = p1.x - p0.x
- dy = p1.y - p0.y
- if dx != 0 and dy != 0:
- # if a line is neither horizontal or vertical its not
- # part of a rectangle
- return False
- # select next point
- p0 = p1
+ # Check that we only have horizontal or vertical lines
+ if any(x2-x1 and y2-y1 for (x1, y1), (x2, y2) in zip(self.points[:-1], self.points[1:])):
+ return False
return True
@@ -177,40 +339,56 @@ class TextPos(XYCoord):
@sexp_type('text')
-class Text:
+class Text(TextMixin):
text: str = None
at: TextPos = field(default_factory=TextPos)
rotation: float = None
effects: TextEffect = field(default_factory=TextEffect)
+ def to_svg(self, colorscheme=Colorscheme.KiCad):
+ yield from TextMixin.to_svg(self, colorscheme.text)
+
@sexp_type('rectangle')
class Rectangle:
- """
- Some v6 symbols use rectangles, newer ones encode them as polylines.
- At some point in time we can most likely remove this class since its not used anymore
- """
+ # Some v6 symbols use rectangles, newer ones encode them as polylines.
+ # At some point in time we can most likely remove this class since its not used anymore
start: Rename(XYCoord) = None
end: Rename(XYCoord) = None
stroke: Stroke = field(default_factory=Stroke)
fill: Fill = field(default_factory=Fill)
- def as_polyline(self):
- x1, y1 = self.start
- x2, y2 = self.end
- return Polyline([Point(x1, y1), Point(x2, y1), Point(x2, y2), Point(x1, y2), Point(x1, y1)],
+ def to_polyline(self):
+ x1, y1 = self.start.x, self.start.y
+ x2, y2 = self.end.x, self.end.y
+ return Polyline(PointList([XYCoord(x1, y1), XYCoord(x2, y1), XYCoord(x2, y2), XYCoord(x1, y2), XYCoord(x1, y1)]),
self.stroke, self.fill)
+ def to_svg(self, colorscheme=Colorscheme.KiCad):
+ return self.to_polyline().to_svg(colorscheme)
+
@sexp_type('property')
-class Property:
+class Property(TextMixin):
name: str = None
value: str = None
id: Named(int) = None
at: AtPos = field(default_factory=AtPos)
effects: TextEffect = field(default_factory=TextEffect)
+ # Alias value for text mixin
+ @property
+ def text(self):
+ return self.value
+
+ @text.setter
+ def text(self, value):
+ self.value = value
+
+ def to_svg(self, colorscheme=Colorscheme.KiCad):
+ yield from TextMixin.to_svg(self, colorscheme.text)
+
@sexp_type('pin_numbers')
class PinNumberSpec:
@@ -254,6 +432,15 @@ class Unit:
self.style_global = self._demorgan_style == 0
self.unit_global = self.unit_index == 0
+ @property
+ def graphical_elements(self):
+ yield from self.circles
+ yield from self.arcs
+ yield from self.polylines
+ yield from self.rectangles
+ yield from self.texts
+ yield from self.pins
+
def __before_sexp__(self):
self.name = f'{self.symbol.name}_{self.unit_index}_{self.demorgan_style}'
@@ -354,7 +541,7 @@ class Symbol:
# and is closest to the center
candidates = {}
# building a dict with floats as keys.. there needs to be a rule against that^^
- pl_rects = [i.as_polyline() for i in self.rectangles]
+ pl_rects = [i.to_polyline() for i in self.rectangles]
pl_rects.extend(pl for pl in self.polylines if pl.is_rectangle())
for pl in pl_rects:
if pl.unit in units: