gerbonara/gerbonara/cad/kicad/base_types.py

530 wiersze
15 KiB
Python

import string
import time
from dataclasses import field, replace
import math
import uuid
from contextlib import contextmanager
from itertools import cycle
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 = {
'F.Cu': ('top', 'copper'),
'B.Cu': ('bottom', 'copper'),
'F.SilkS': ('top', 'silk'),
'B.SilkS': ('bottom', 'silk'),
'F.Paste': ('top', 'paste'),
'B.Paste': ('bottom', 'paste'),
'F.Mask': ('top', 'mask'),
'B.Mask': ('bottom', 'mask'),
'B.CrtYd': ('bottom', 'courtyard'),
'F.CrtYd': ('top', 'courtyard'),
'B.Fab': ('bottom', 'fabrication'),
'F.Fab': ('top', 'fabrication'),
'B.Adhes': ('bottom', 'adhesive'),
'F.Adhes': ('top', 'adhesive'),
'Dwgs.User': ('mechanical', 'drawings'),
'Cmts.User': ('mechanical', 'comments'),
'Edge.Cuts': ('mechanical', 'outline'),
}
LAYER_MAP_G2K = {v: k for k, v in LAYER_MAP_K2G.items()}
@sexp_type('group')
class Group:
name: str = ""
id: Named(str) = ""
members: Named(List(str)) = field(default_factory=list)
@sexp_type('color')
class Color:
r: int = None
g: int = None
b: 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')
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_linejoin': 'round',
'stroke_width': self.width or 0.254}
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):
if obj.stroke:
w, t = obj.stroke.width or 0.254, obj.stroke.type
else:
w = obj.width or 0
t = Atom.solid
self.width = w
gap = 4*w
dot = 0
dash = 11*w
self.pattern = {
Atom.dash: [dash, gap],
Atom.dot: [dot, gap],
Atom.dash_dot_dot: [dash, gap, dot, gap, dot, gap],
Atom.dash_dot: [dash, gap, dot, gap],
Atom.default: [1e99],
Atom.solid: [1e99]}[t]
self.solid = t in (Atom.default, Atom.solid)
self.start_x, self.start_y = None, None
self.cur_x, self.cur_y = None, None
self.segments = []
def move(self, x, y):
if self.cur_x is None:
self.start_x, self.start_y = x, y
self.cur_x, self.cur_y = x, y
def line(self, x, y):
if x is None or y is None:
raise ValueError('line() called before move()')
self.segments.append((self.cur_x, self.cur_y, x, y))
self.cur_x, self.cur_y = x, y
def close(self):
self.segments.append((self.cur_x, self.cur_y, self.start_x, self.start_y))
self.cur_x, self.cur_y = None, None
@staticmethod
def _interpolate(x1, y1, x2, y2, length):
dx, dy = x2-x1, y2-y1
total = math.hypot(dx, dy)
if total == 0:
return x2, y2
frac = length / total
return x1 + dx*frac, y1 + dy*frac
def __iter__(self):
it = iter(self.segments)
segment_remaining, segment_pos = 0, 0
if self.width is None or self.width < 1e-3:
return
for length, stroked in cycle(zip(self.pattern, cycle([True, False]))):
length = max(1e-12, length)
while length > 0:
if segment_remaining == 0:
try:
x1, y1, x2, y2 = next(it)
except StopIteration:
return
dx, dy = x2-x1, y2-y1
lx, ly = x1, y1
segment_remaining = math.hypot(dx, dy)
segment_pos = 0
if segment_remaining > length:
segment_pos += length
ix, iy = self._interpolate(x1, y1, x2, y2, segment_pos)
segment_remaining -= length
if stroked:
yield lx, ly, ix, iy
lx, ly = ix, iy
break
else:
length -= segment_remaining
segment_remaining = 0
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:
x: float = 0
y: float = 0
def __init__(self, x=0, y=0):
if isinstance(x, XYCoord):
self.x, self.y = x.x, x.y
elif isinstance(x, (tuple, list)):
self.x, self.y = x
elif hasattr(x, 'abs_pos'):
self.x, self.y, _1, _2 = x.abs_pos
elif hasattr(x, 'at'):
self.x, self.y = x.at.x, x.at.y
else:
self.x, self.y = x, y
def within_distance(self, x, y, dist):
return math.dist((x, y), (self.x, self.y)) < dist
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):
return replace(self, x=self.x+x, y=self.y+y)
def with_rotation(self, angle, cx=0, cy=0):
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)
@sexp_type('xyz')
class XYZCoord:
x: float = 0
y: float = 0
z: float = 0
@sexp_type('at')
class AtPos(XYCoord):
x: float = 0 # in millimeter
y: float = 0 # in millimeter
rotation: int = 0 # in degrees, can only be 0, 90, 180 or 270.
unlocked: Flag() = False
def __before_sexp__(self):
self.rotation = int(round(self.rotation % 360))
@property
def rotation_rad(self):
return math.radians(self.rotation)
@rotation_rad.setter
def rotation_rad(self, value):
self.rotation = math.degrees(value)
def with_rotation(self, angle, cx=0, cy=0):
obj = super().with_rotation(angle, cx, cy)
return replace(obj, rotation=self.rotation + angle)
@sexp_type('font')
class FontSpec:
face: Named(str) = None
size: Rename(XYCoord) = field(default_factory=lambda: XYCoord(1.27, 1.27))
thickness: Named(float) = None
bold: Flag() = False
italic: Flag() = False
line_spacing: Named(float) = None
@sexp_type('justify')
class Justify:
h: AtomChoice(Atom.left, Atom.right) = None
v: AtomChoice(Atom.top, Atom.bottom) = None
mirror: Flag() = False
@property
def h_str(self):
if self.h is None:
return 'center'
else:
return str(self.h)
@property
def v_str(self):
if self.v is None:
return 'middle'
else:
return str(self.v)
@sexp_type('effects')
class TextEffect:
font: FontSpec = field(default_factory=FontSpec)
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}'
@property
def default_v_align(self):
return 'bottom'
@property
def h_align(self):
return 'left' if self.effects.justify.h else 'center'
@property
def mirrored(self):
return False, False
def to_svg(self, color='black', 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)
aperture = ap.CircleAperture(self.line_width or 0.2, unit=MM)
rot = self.rotation
h_align = self.h_align
mx, my = self.mirrored
if rot in (90, 270):
h_align = {'left': 'right', 'right': 'left'}.get(h_align, h_align)
rot = (rot+180)%360
elif rot == 180:
rot = 0
h_align = {'left': 'right', 'right': 'left'}.get(h_align, h_align)
if my and rot in (0, 180):
h_align = {'left': 'right', 'right': 'left'}.get(h_align, h_align)
rot = (rot+180)%360
if mx and rot in (90, 270):
h_align = {'left': 'right', 'right': 'left'}.get(h_align, h_align)
rot = (rot+180)%360
if rot == 180:
rot = 0
h_align = {'left': 'right', 'right': 'left'}.get(h_align, h_align)
if rot == 90:
rot = 270
h_align = {'left': 'right', 'right': 'left'}.get(h_align, h_align)
yield font.render_svg(text,
size=self.size or 1.27,
h_align=h_align,
v_align=self.effects.justify.v or self.default_v_align,
stroke=color,
stroke_width=f'{self.line_width:.3f}',
scale=(1,1),
rotation=0,
transform=f'translate({self.at.x:.3f} {self.at.y:.3f}) rotate({rot})',
)
@property
def _text_offset(self):
return (0, 0)
@property
def rotation(self):
return self.at.rotation
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)
aperture = ap.CircleAperture(self.line_width or 0.2, unit=MM)
for stroke in font.render(text,
x0=self.at.x, y=self.at.y,
size=self.size or 1.27,
h_align=self.effects.justify.h_str,
v_align=self.effects.justify.v_str,
rotation=self.at.rotation,
):
points = []
for x, y in stroke:
x, y = x+offx, y+offy
x, y = rotate_point(x, y, math.radians(-rot or 0))
x, y = x+self.at.x, y+self.at.y
points.append((x, y))
for p1, p2 in zip(points[:-1], points[1:]):
yield go.Line(*p1, *p2, aperture=aperture, unit=MM)
@sexp_type('tstamp')
class Timestamp:
value: str = field(default_factory=uuid.uuid4)
def __deepcopy__(self, memo):
return Timestamp()
def __after_parse__(self, parent):
self.value = str(self.value)
def before_sexp(self):
self.value = Atom(str(self.value))
def bump(self):
self.value = uuid.uuid4()
@sexp_type('uuid')
class UUID:
value: str = field(default_factory=uuid.uuid4)
def __deepcopy__(self, memo):
return UUID()
def __after_parse__(self, parent):
self.value = str(self.value)
def before_sexp(self):
self.value = Atom(str(self.value))
def bump(self):
self.value = uuid.uuid4()
@sexp_type('tedit')
class EditTime:
value: str = field(default_factory=time.time)
def __deepcopy__(self, memo):
return EditTime()
def __after_parse__(self, parent):
self.value = int(str(self.value), 16)
def __before_sexp__(self):
self.value = Atom(f'{int(self.value):08X}')
def bump(self):
self.value = time.time()
@sexp_type('paper')
class PageSettings:
page_format: str = 'A4'
width: float = None
height: float = None
portrait: Flag() = False
@sexp_type('property')
class Property:
key: str = ''
value: str = ''
@sexp_type('property')
class DrawnProperty(TextMixin):
key: str = None
value: str = None
id: Named(int) = None
at: AtPos = field(default_factory=AtPos)
layer: Named(str) = None
hide: Flag() = False
tstamp: Timestamp = None
effects: TextEffect = field(default_factory=TextEffect)
_ : SEXP_END = None
parent: object = None
def __after_parse(self, parent=None):
self.parent = parent
# 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:
pass
foo = Foo()
foo.stroke = troke(0.01, Atom.dash_dot_dot)
d = Dasher(foo)
#d = Dasher(Stroke(0.01, Atom.solid))
d.move(1, 1)
d.line(1, 2)
d.line(3, 2)
d.line(3, 1)
d.close()
print('<?xml version="1.0" standalone="no"?>')
print('<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">')
print('<svg version="1.1" width="4cm" height="3cm" viewBox="0 0 4 3" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">')
for x1, y1, x2, y2 in d:
print(f'<path fill="none" stroke="black" stroke-width="0.01" stroke-linecap="round" d="M {x1},{y1} L {x2},{y2}"/>')
print('</svg>')