From bdbdf7f58607bb98999e17ace8a743267a06cd9d Mon Sep 17 00:00:00 2001 From: jaseg Date: Thu, 20 Jul 2023 16:42:05 +0200 Subject: Schematic rendering WIP --- gerbonara/cad/kicad/base_types.py | 151 ++++++++++++++- gerbonara/cad/kicad/footprints.py | 2 +- gerbonara/cad/kicad/graphical_primitives.py | 42 +--- gerbonara/cad/kicad/schematic.py | 240 ++++++++++++++++++++++- gerbonara/cad/kicad/schematic_colors.py | 12 ++ gerbonara/cad/kicad/symbols.py | 285 +++++++++++++++++++++++----- 6 files changed, 627 insertions(+), 105 deletions(-) create mode 100644 gerbonara/cad/kicad/schematic_colors.py (limited to 'gerbonara') 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: -- cgit