Schematic rendering WIP

This commit is contained in:
jaseg 2023-07-20 16:42:05 +02:00
parent a1b8cbf861
commit bdbdf7f586
6 changed files with 626 additions and 104 deletions

View file

@ -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:

View file

@ -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)

View file

@ -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)

View file

@ -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,9 +24,27 @@ 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:
path: str = '/'
@ -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()))

View file

@ -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'

View file

@ -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: