Basic KiCad footprint rendering works
This commit is contained in:
parent
263033c9bd
commit
2c6c9a5cbc
8 changed files with 527 additions and 29 deletions
|
|
@ -178,6 +178,17 @@ class GenericMacros:
|
|||
ap.Circle('mm', [1, var(3)*2, -(var(1)/2-var(3)), -(var(2)/2-var(3)), 0]),
|
||||
*_generic_hole(4)])
|
||||
|
||||
# params: width, height, length difference between narrow side (top) and wide side (bottom), *hole, rotation
|
||||
isosceles_trapezoid = ApertureMacro('GTR', [
|
||||
ap.Outline('mm', [1, 4,
|
||||
var(1)/-2, var(2)/-2,
|
||||
var(1)/-2+var(3)/2, var(2)/2,
|
||||
var(1)/2-var(3)/2, var(2)/2,
|
||||
var(1)/2, var(2)/-2,
|
||||
var(1)/-2, var(2)/-2,
|
||||
var(6) * -deg_per_rad]),
|
||||
*_generic_hole(4)])
|
||||
|
||||
# w must be larger than h
|
||||
obround = ApertureMacro('GNO', [
|
||||
ap.CenterLine('mm', [1, var(1), var(2), 0, 0, var(5) * -deg_per_rad]),
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import math
|
|||
from .expression import Expression, UnitExpression, ConstantExpression, expr
|
||||
|
||||
from .. import graphic_primitives as gp
|
||||
from .. import graphic_objects as go
|
||||
|
||||
|
||||
def point_distance(a, b):
|
||||
|
|
@ -18,8 +19,13 @@ def point_distance(a, b):
|
|||
x2, y2 = b
|
||||
return math.sqrt((x2 - x1)**2 + (y2 - y1)**2)
|
||||
|
||||
|
||||
def deg_to_rad(a):
|
||||
return (a / 180) * math.pi
|
||||
return a * (math.pi / 180)
|
||||
|
||||
def rad_to_deg(a):
|
||||
return a * (180 / math.pi)
|
||||
|
||||
|
||||
class Primitive:
|
||||
def __init__(self, unit, args):
|
||||
|
|
@ -240,7 +246,7 @@ class Outline(Primitive):
|
|||
self.exposure = args.pop(0)
|
||||
|
||||
# length arg must not contain variables (that would not make sense)
|
||||
length_arg = args.pop(0).calculate()
|
||||
length_arg = (args.pop(0) * ConstantExpression(1)).calculate()
|
||||
|
||||
if length_arg != len(args)//2-1:
|
||||
raise ValueError(f'Invalid aperture macro outline primitive, given size {length_arg} does not match length of coordinate list({len(args)//2-1}).')
|
||||
|
|
@ -290,6 +296,7 @@ class Comment:
|
|||
def scale(self, scale):
|
||||
pass
|
||||
|
||||
|
||||
PRIMITIVE_CLASSES = {
|
||||
**{cls.code: cls for cls in [
|
||||
Comment,
|
||||
|
|
|
|||
|
|
@ -25,18 +25,25 @@ class Stroke:
|
|||
|
||||
|
||||
class Dasher:
|
||||
def __init__(self, stroke):
|
||||
self.width = stroke.width
|
||||
gap = 4*stroke.width
|
||||
def __init__(self, obj):
|
||||
if obj.stroke:
|
||||
w, t = obj.stroke.width, obj.stroke.type
|
||||
else:
|
||||
w = obj.width or 0
|
||||
t = Atom.solid
|
||||
|
||||
self.width = w
|
||||
gap = 4*w
|
||||
dot = 0
|
||||
dash = 11*stroke.width
|
||||
dash = 11*w
|
||||
self.pattern = {
|
||||
Atom.dash: [dash, gap],
|
||||
Atom.dot: [dot, gap],
|
||||
Atom.dash_dot_dot: [dash, gap, dot, gap, dot, gap],
|
||||
Atom.dash_dot: [dash, gap, dot, gap],
|
||||
Atom.default: [1e99],
|
||||
Atom.solid: [1e99]}[stroke.type]
|
||||
Atom.solid: [1e99]}[t]
|
||||
self.solid = t in (Atom.default, Atom.solid)
|
||||
self.start_x, self.start_y = None, None
|
||||
self.cur_x, self.cur_y = None, None
|
||||
self.segments = []
|
||||
|
|
@ -68,12 +75,14 @@ class Dasher:
|
|||
def __iter__(self):
|
||||
it = iter(self.segments)
|
||||
segment_remaining, segment_pos = 0, 0
|
||||
|
||||
if self.width is None or self.width < 1e-3:
|
||||
return
|
||||
|
||||
for length, stroked in cycle(zip(self.pattern, cycle([True, False]))):
|
||||
length = max(1e-12, length)
|
||||
import sys
|
||||
print('new dash', length, stroked, file=sys.stderr)
|
||||
while length > 0:
|
||||
print(f'{length=} {segment_remaining=}', file=sys.stderr)
|
||||
if segment_remaining == 0:
|
||||
try:
|
||||
x1, y1, x2, y2 = next(it)
|
||||
|
|
@ -83,7 +92,6 @@ class Dasher:
|
|||
lx, ly = x1, y1
|
||||
segment_remaining = math.hypot(dx, dy)
|
||||
segment_pos = 0
|
||||
print('new segment', x1, y1, x2, y2, segment_remaining, file=sys.stderr)
|
||||
|
||||
if segment_remaining > length:
|
||||
segment_pos += length
|
||||
|
|
@ -192,7 +200,12 @@ class EditTime:
|
|||
self.value = time.time()
|
||||
|
||||
if __name__ == '__main__':
|
||||
d = Dasher(Stroke(0.01, Atom.dash_dot_dot))
|
||||
class Foo:
|
||||
pass
|
||||
|
||||
foo = Foo()
|
||||
foo.stroke = troke(0.01, Atom.dash_dot_dot)
|
||||
d = Dasher(foo)
|
||||
#d = Dasher(Stroke(0.01, Atom.solid))
|
||||
d.move(1, 1)
|
||||
d.line(1, 2)
|
||||
|
|
|
|||
|
|
@ -7,13 +7,23 @@ import enum
|
|||
import datetime
|
||||
import math
|
||||
import time
|
||||
from typing import Any, Dict, Iterable, List, Optional, Tuple, Union
|
||||
import fnmatch
|
||||
from itertools import chain
|
||||
from pathlib import Path
|
||||
|
||||
from .sexp import *
|
||||
from .base_types import *
|
||||
from .primitives import *
|
||||
from . import graphical_primitives as gr
|
||||
|
||||
from ..primitives import Positioned
|
||||
|
||||
from ... import graphic_primitives as gp
|
||||
from ... import graphic_objects as go
|
||||
from ... import apertures as ap
|
||||
from ...utils import MM
|
||||
from ...aperture_macros.parse import GenericMacros
|
||||
|
||||
|
||||
@sexp_type('property')
|
||||
class Property:
|
||||
|
|
@ -40,6 +50,9 @@ class Text:
|
|||
effects: TextEffect = field(default_factory=TextEffect)
|
||||
tstamp: Timestamp = None
|
||||
|
||||
def render(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
@sexp_type('fp_text_box')
|
||||
class TextBox:
|
||||
|
|
@ -55,6 +68,9 @@ class TextBox:
|
|||
stroke: Stroke = field(default_factory=Stroke)
|
||||
render_cache: RenderCache = None
|
||||
|
||||
def render(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
@sexp_type('fp_line')
|
||||
class Line:
|
||||
|
|
@ -66,6 +82,14 @@ class Line:
|
|||
locked: Flag() = False
|
||||
tstamp: Timestamp = None
|
||||
|
||||
def render(self):
|
||||
dasher = Dasher(self)
|
||||
dasher.move(self.start.x, self.start.y)
|
||||
dasher.line(self.end.x, self.end.y)
|
||||
|
||||
for x1, y1, x2, y2 in dasher:
|
||||
yield go.Line(x1, y1, x2, y2, aperture=ap.CircleAperture(dasher.width, unit=MM), unit=MM)
|
||||
|
||||
|
||||
@sexp_type('fp_rect')
|
||||
class Rectangle:
|
||||
|
|
@ -78,6 +102,27 @@ class Rectangle:
|
|||
locked: Flag() = False
|
||||
tstamp: Timestamp = None
|
||||
|
||||
def render(self):
|
||||
x1, y1 = self.start.x, self.start.y
|
||||
x2, y2 = self.end.x, self.end.y
|
||||
x1, x2 = min(x1, x2), max(x1, x2)
|
||||
y1, y2 = min(y1, y2), max(y1, y2)
|
||||
w, h = x2-x1, y1-y2
|
||||
|
||||
if self.fill == Atom.solid:
|
||||
yield go.Region.from_rectangle(x1, y1, w, y, unit=MM)
|
||||
|
||||
dasher = Dasher(self)
|
||||
dasher.move(x1, y1)
|
||||
dasher.line(x1, y2)
|
||||
dasher.line(x2, y2)
|
||||
dasher.line(x2, y1)
|
||||
dasher.close()
|
||||
|
||||
aperture = ap.CircleAperture(dasher.width, unit=MM)
|
||||
for x1, y1, x2, y2 in dasher:
|
||||
yield go.Line(x1, y1, x2, y2, aperture=aperture, unit=MM)
|
||||
|
||||
|
||||
@sexp_type('fp_circle')
|
||||
class Circle:
|
||||
|
|
@ -90,6 +135,26 @@ class Circle:
|
|||
locked: Flag() = False
|
||||
tstamp: Timestamp = None
|
||||
|
||||
def render(self):
|
||||
x, y = self.center.x, self.center.y
|
||||
r = math.dist((x, y), (self.end.x, self.end.y)) # insane
|
||||
|
||||
circle = go.Arc.from_circle(x, y, r, unit=MM)
|
||||
if self.fill == Atom.solid:
|
||||
yield circle.to_region()
|
||||
|
||||
dasher = Dasher(self)
|
||||
if dasher.solid:
|
||||
circle.aperture = CircleAperture(dasher.width, unit=MM)
|
||||
yield circle
|
||||
|
||||
else: # pain
|
||||
for line in circle.approximate(): # TODO precision settings
|
||||
dasher.segments.append((line.x1, line.y1, line.x2, line.y2))
|
||||
|
||||
aperture = ap.CircleAperture(dasher.width, unit=MM)
|
||||
for x1, y1, x2, y2 in dasher:
|
||||
yield go.Line(x1, y1, x2, y2, aperture=aperture, unit=MM)
|
||||
|
||||
@sexp_type('fp_arc')
|
||||
class Arc:
|
||||
|
|
@ -103,6 +168,26 @@ class Arc:
|
|||
tstamp: Timestamp = None
|
||||
|
||||
|
||||
def render(self):
|
||||
cx, cy = self.mid.x, self.mid.y
|
||||
x1, y1 = self.start.x, self.start.y
|
||||
x2, y2 = self.end.x, self.end.y
|
||||
dasher = Dasher(self)
|
||||
|
||||
# KiCad only has clockwise arcs.
|
||||
arc = go.Arc(x1, y1, x2, y2, cx-x1, cy-y1, clockwise=True, aperture=ap.CircleAperture(dasher.width, unit=MM), unit=MM)
|
||||
if dasher.solid:
|
||||
yield arc
|
||||
|
||||
else:
|
||||
# use approximation from graphic object arc class
|
||||
for line in arc.approximate():
|
||||
dasher.segments.append((line.x1, line.y1, line.x2, line.y2))
|
||||
|
||||
for line in dasher:
|
||||
yield go.Line(x1, y1, x2, y2, aperture=ap.CircleAperture(dasher.width, unit=MM), unit=MM)
|
||||
|
||||
|
||||
@sexp_type('fp_poly')
|
||||
class Polygon:
|
||||
pts: PointList = field(default_factory=PointList)
|
||||
|
|
@ -113,6 +198,23 @@ class Polygon:
|
|||
locked: Flag() = False
|
||||
tstamp: Timestamp = None
|
||||
|
||||
def render(self):
|
||||
if len(self.pts.xy) < 2:
|
||||
return
|
||||
|
||||
dasher = Dasher(self)
|
||||
start = self.pts.xy[0]
|
||||
dasher.move(start.x, start.y)
|
||||
for point in self.pts.xy[1:]:
|
||||
dasher.line(point.x, point.y)
|
||||
|
||||
aperture = ap.CircleAperture(dasher.width, unit=MM)
|
||||
for x1, y1, x2, y2 in dasher:
|
||||
yield go.Line(x1, y1, x2, y2, aperture=aperture, unit=MM)
|
||||
|
||||
if self.fill == Atom.solid:
|
||||
yield go.Region([(pt.x, pt.y) for pt in self.pts.xy], unit=MM)
|
||||
|
||||
|
||||
@sexp_type('fp_curve')
|
||||
class Curve:
|
||||
|
|
@ -123,6 +225,9 @@ class Curve:
|
|||
locked: Flag() = False
|
||||
tstamp: Timestamp = None
|
||||
|
||||
def render(self):
|
||||
raise NotImplementedError('Bezier rendering is not yet supported. Please raise an issue and provide an example file.')
|
||||
|
||||
|
||||
@sexp_type('format')
|
||||
class DimensionFormat:
|
||||
|
|
@ -160,6 +265,9 @@ class Dimension:
|
|||
format: DimensionFormat = field(default_factory=DimensionFormat)
|
||||
style: DimensionStyle = field(default_factory=DimensionStyle)
|
||||
|
||||
def render(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
@sexp_type('drill')
|
||||
class Drill:
|
||||
|
|
@ -193,6 +301,14 @@ class CustomPadPrimitives:
|
|||
width: Named(float) = None
|
||||
fill: Named(YesNoAtom()) = True
|
||||
|
||||
def all(self):
|
||||
yield from self.lines
|
||||
yield from self.rectangles
|
||||
yield from self.circles
|
||||
yield from self.arcs
|
||||
yield from self.polygons
|
||||
yield from self.curves
|
||||
|
||||
|
||||
@sexp_type('chamfer')
|
||||
class Chamfer:
|
||||
|
|
@ -201,6 +317,7 @@ class Chamfer:
|
|||
bottom_left: Flag() = False
|
||||
bottom_right: Flag() = False
|
||||
|
||||
|
||||
@sexp_type('pad')
|
||||
class Pad:
|
||||
number: str = None
|
||||
|
|
@ -234,6 +351,70 @@ class Pad:
|
|||
options: OmitDefault(CustomPadOptions) = None
|
||||
primitives: OmitDefault(CustomPadPrimitives) = None
|
||||
|
||||
def render(self):
|
||||
if self.type in (Atom.connect, Atom.np_thru_hole):
|
||||
return
|
||||
|
||||
yield go.Flash(self.at.x, self.at.y, self.aperture().rotated(math.radians(self.at.rotation)), unit=MM)
|
||||
|
||||
def aperture(self):
|
||||
if self.shape == Atom.circle:
|
||||
return ap.CircleAperture(self.size.x, unit=MM)
|
||||
|
||||
elif self.shape == Atom.rect:
|
||||
return ap.RectangleAperture(self.size.x, self.size.y, unit=MM)
|
||||
|
||||
elif self.shape == Atom.oval:
|
||||
return ap.ObroundAperture(self.size.x, self.size.y, unit=MM)
|
||||
|
||||
elif self.shape == Atom.trapezoid:
|
||||
# KiCad's trapezoid aperture "rect_delta" param is just weird to the point that I think it's probably
|
||||
# bugged. If you have a size of 2mm by 2mm, and set this param to 1mm, the resulting pad extends past the
|
||||
# original bounding box, and the trapezoid's base and tip length are 3mm and 1mm.
|
||||
|
||||
x, y = self.size.x, self.size.y
|
||||
dx, dy = self.rect_delta.x, self.rect_delta.y
|
||||
|
||||
# Note: KiCad already uses MM units, so no conversion needed here.
|
||||
return ApertureMacroInstance(GenericMacros.isosceles_trapezoid,
|
||||
[x+dx, y+dy,
|
||||
2*max(dx, dy),
|
||||
0, 0, # no hole
|
||||
math.radians(self.at.rotation)])
|
||||
|
||||
elif self.shape == Atom.roundrect:
|
||||
x, y = self.size.x, self.size.y
|
||||
r = min(x, y) * self.roundrect_rratio
|
||||
return ApertureMacroInstance(GenericMacros.rounded_rect,
|
||||
[x, y,
|
||||
r,
|
||||
0, 0, # no hole
|
||||
math.radians(self.at.rotation)])
|
||||
|
||||
elif self.shape == Atom.custom:
|
||||
primitives = []
|
||||
# One round trip through the Gerbonara APIs, please!
|
||||
for obj in self.primitives.all():
|
||||
for gn_obj in obj.render():
|
||||
primitives += gn_obj._aperture_macro_primitives() # todo: precision params
|
||||
macro = ApertureMacro(primitives=primitives)
|
||||
return ApertureMacroInstance(macro)
|
||||
|
||||
def render_drill(self):
|
||||
if not self.drill:
|
||||
return
|
||||
|
||||
plated = self.type != Atom.np_thru_hole
|
||||
aperture = ap.ExcellonTool(self.drill.diameter, plated=plated, unit=MM)
|
||||
if self.drill.oval:
|
||||
w = self.drill.width / 2
|
||||
l = go.Line(-w, 0, w, 0, aperture=aperture, unit=MM)
|
||||
l.rotate(math.radians(self.at.rotation))
|
||||
l.offset(self.at.x, self.at.y)
|
||||
yield l
|
||||
else:
|
||||
yield go.Flash(self.at.x, self.at.y, aperture=aperture, unit=MM)
|
||||
|
||||
|
||||
@sexp_type('group')
|
||||
class Group:
|
||||
|
|
@ -304,13 +485,109 @@ class Footprint:
|
|||
if value not in SUPPORTED_FILE_FORMAT_VERSIONS:
|
||||
raise FormatError(f'File format version {value} is not supported. Supported versions are {", ".join(map(str, SUPPORTED_FILE_FORMAT_VERSIONS))}.')
|
||||
|
||||
@classmethod
|
||||
def open(cls, filename: str) -> 'Library':
|
||||
with open(filename) as f:
|
||||
return cls.parse(f.read())
|
||||
|
||||
def write(self, filename=None) -> None:
|
||||
def write(self, filename=None):
|
||||
with open(filename or self.original_filename, 'w') as f:
|
||||
f.write(build_sexp(sexp(self)))
|
||||
|
||||
@classmethod
|
||||
def open_pretty(kls, pretty_dir, fp_name, *args, **kwargs):
|
||||
pretty_dir = Path(pretty_dir) / f'{fp_name}.kicad_mod'
|
||||
return kls.open_mod(pretty_dir / mod_name, *args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def open_mod(kls, mod_file, *args, **kwargs):
|
||||
return kls.load(Path(mod_file).read_text(), *args, **kwargs, original_filename=mod_file)
|
||||
|
||||
@classmethod
|
||||
def open_system(kls, fp_path):
|
||||
raise NotImplementedError()
|
||||
|
||||
@classmethod
|
||||
def open_download(kls, fp_path):
|
||||
raise NotImplementedError()
|
||||
|
||||
@classmethod
|
||||
def load(kls, data, *args, **kwargs):
|
||||
return kls.parse(data, *args, **kwargs)
|
||||
|
||||
@property
|
||||
def single_sided(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
def objects(self, text=False, pads=True):
|
||||
return chain(
|
||||
(self.texts if text else []),
|
||||
self.lines,
|
||||
self.rectangles,
|
||||
self.circles,
|
||||
self.arcs,
|
||||
self.polygons,
|
||||
self.curves,
|
||||
(self.dimensions if text else []),
|
||||
(self.pads if pads else []),
|
||||
self.zones)
|
||||
|
||||
def render(self, layer_stack, layer_map, x=0, y=0, rotation=0, side=None):
|
||||
x += self.at.x
|
||||
y += self.at.y
|
||||
rotation += math.radians(self.at.rotation)
|
||||
flip = (side != 'top') if side else (self.layer != 'F.Cu')
|
||||
|
||||
for obj in self.objects(pads=False, text=False):
|
||||
if not (layer := layer_map.get(obj.layer)):
|
||||
continue
|
||||
|
||||
for fe in obj.render():
|
||||
fe.rotate(rotation)
|
||||
fe.offset(x, y, MM)
|
||||
layer_stack[layer].objects.append(fe)
|
||||
|
||||
for obj in self.pads:
|
||||
for glob in obj.layers or []:
|
||||
for layer in fnmatch.filter(layer_map, glob):
|
||||
for fe in obj.render():
|
||||
fe.rotate(rotation)
|
||||
fe.offset(x, y, MM)
|
||||
layer_stack[layer_map[layer]].objects.append(fe)
|
||||
|
||||
for obj in self.pads:
|
||||
for fe in obj.render_drill():
|
||||
fe.rotate(rotation)
|
||||
fe.offset(x, y, MM)
|
||||
|
||||
if obj.type == Atom.np_thru_hole:
|
||||
layer_stack.drill_npth.append(fe)
|
||||
else:
|
||||
layer_stack.drill_pth.append(fe)
|
||||
|
||||
LAYER_MAP = {
|
||||
'F.Cu': ('top', 'copper'),
|
||||
'B.Cu': ('bottom', 'copper'),
|
||||
'F.SilkS': ('top', 'silk'),
|
||||
'B.SilkS': ('bottom', 'silk'),
|
||||
'F.Paste': ('top', 'paste'),
|
||||
'B.Paste': ('bottom', 'paste'),
|
||||
'F.Mask': ('top', 'mask'),
|
||||
'B.Mask': ('bottom', 'mask'),
|
||||
'Edge.Cuts': ('mechanical', 'outline'),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class FootprintInstance(Positioned):
|
||||
sexp: Footprint = None
|
||||
|
||||
def render(self, layer_stack):
|
||||
x, y, rotation = self.abs_pos
|
||||
x, y = MM(x, self.unit), MM(y, self.unit)
|
||||
self.sexp.render(layer_stack, LAYER_MAP, x=x, y=y, rotation=rotation, side=self.side)
|
||||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
from ...layers import LayerStack
|
||||
fp = Footprint.open_mod(sys.argv[1])
|
||||
stack = LayerStack()
|
||||
FootprintInstance(0, 0, fp, unit=MM).render(stack)
|
||||
print(stack.to_pretty_svg())
|
||||
stack.save_to_directory('/tmp/testdir')
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,15 @@
|
|||
|
||||
import math
|
||||
|
||||
from .sexp import *
|
||||
from .base_types import *
|
||||
from .primitives import *
|
||||
|
||||
from ... import graphic_objects as go
|
||||
from ... import apertures as ap
|
||||
from ...newstroke import Newstroke
|
||||
from ...utils import rotate_point
|
||||
|
||||
@sexp_type('layer')
|
||||
class TextLayer:
|
||||
layer: str = ''
|
||||
|
|
@ -17,6 +24,40 @@ class Text:
|
|||
tstamp: Timestamp = None
|
||||
effects: TextEffect = field(default_factory=TextEffect)
|
||||
|
||||
def render(self):
|
||||
if not self.effects or self.effects.hide or not self.effects.font:
|
||||
return
|
||||
|
||||
font = Newstroke.load()
|
||||
strokes = list(font.render(self.text, size=self.effects.font.size.y))
|
||||
min_x = min(x for st in strokes for x, y in st)
|
||||
min_y = min(y for st in strokes for x, y in st)
|
||||
max_x = max(x for st in strokes for x, y in st)
|
||||
max_y = max(y for st in strokes for x, y in st)
|
||||
w = max_x - min_x
|
||||
h = max_y - min_y
|
||||
|
||||
offx = -min_x + {
|
||||
None: -w/2,
|
||||
Atom.right: -w,
|
||||
Atom.left: 0
|
||||
}[self.effects.justify.h if self.effects.justify else None]
|
||||
offy = {
|
||||
None: -h/2,
|
||||
Atom.top: -h,
|
||||
Atom.bottom: 0
|
||||
}[self.effects.justify.v if self.effects.justify else None]
|
||||
|
||||
aperture = ap.CircleAperture(self.effects.font.width or 0.2, unit=MM)
|
||||
for stroke in strokes:
|
||||
out = []
|
||||
for point in stroke:
|
||||
x, y = rotate_point(x, y, math.radians(self.at.rotation or 0))
|
||||
x, y = x+offx, y+offy
|
||||
out.append((x, y))
|
||||
for p1, p2 in zip(out[:-1], out[1:]):
|
||||
yield go.Line(*p1, *p2, aperture=ap, unit=MM)
|
||||
|
||||
|
||||
@sexp_type('gr_text_box')
|
||||
class TextBox:
|
||||
|
|
@ -32,16 +73,38 @@ class TextBox:
|
|||
stroke: Stroke = field(default_factory=Stroke)
|
||||
render_cache: RenderCache = None
|
||||
|
||||
def render(self):
|
||||
if not render_cache or not render_cache.polygons:
|
||||
raise ValueError('Text box with empty render cache')
|
||||
|
||||
for poly in render_cache.polygons:
|
||||
reg = go.Region([(p.x, p.y) for p in poly.pts.xy], unit=MM)
|
||||
|
||||
if self.stroke:
|
||||
if self.stroke.type not in (None, Atom.default, Atom.solid):
|
||||
raise ValueError('Dashed strokes are not supported on vector text')
|
||||
|
||||
yield from reg.outline_objects(aperture=CircleAperture(self.stroke.width, unit=MM))
|
||||
|
||||
yield reg
|
||||
|
||||
|
||||
@sexp_type('gr_line')
|
||||
class Line:
|
||||
start: Rename(XYCoord) = None
|
||||
end: Rename(XYCoord) = None
|
||||
angle: Named(float) = None
|
||||
angle: Named(float) = None # wat
|
||||
layer: Named(str) = None
|
||||
width: Named(float) = None
|
||||
tstamp: Timestamp = None
|
||||
|
||||
def render(self):
|
||||
if self.angle:
|
||||
raise NotImplementedError('Angles on lines are not implemented. Please raise an issue and provide an example file.')
|
||||
|
||||
ap = ap.CircleAperture(self.width, unit=MM)
|
||||
return go.Line(self.start.x, self.start.y, self.end.x, self.end.y, aperture=ap, unit=MM)
|
||||
|
||||
|
||||
@sexp_type('fill')
|
||||
class FillMode:
|
||||
|
|
@ -65,6 +128,17 @@ class Rectangle:
|
|||
fill: FillMode = False
|
||||
tstamp: Timestamp = None
|
||||
|
||||
def render(self):
|
||||
rect = go.Region.from_rectangle(self.start.x, self.start.y,
|
||||
self.end.x-self.start.x, self.end.y-self.start.y,
|
||||
unit=MM)
|
||||
|
||||
if self.fill:
|
||||
yield rect
|
||||
|
||||
if self.width:
|
||||
yield from rect.outline_objects(aperture=CircleAperture(self.width, unit=MM))
|
||||
|
||||
|
||||
@sexp_type('gr_circle')
|
||||
class Circle:
|
||||
|
|
@ -75,6 +149,17 @@ class Circle:
|
|||
fill: FillMode = False
|
||||
tstamp: Timestamp = None
|
||||
|
||||
def render(self):
|
||||
r = math.dist((self.center.x, self.center.y), (self.end.x, self.end.y))
|
||||
arc = go.Arc.from_circle(self.center.x, self.center.y, r, unit=MM)
|
||||
|
||||
if self.width:
|
||||
arc.aperture = ap.CircleAperture(self.width, unit=MM)
|
||||
yield arc
|
||||
|
||||
if self.fill:
|
||||
yield arc.to_region()
|
||||
|
||||
|
||||
@sexp_type('gr_arc')
|
||||
class Arc:
|
||||
|
|
@ -85,6 +170,19 @@ class Arc:
|
|||
width: Named(float) = None
|
||||
tstamp: Timestamp = None
|
||||
|
||||
def render(self):
|
||||
cx, cy = self.mid.x, self.mid.y
|
||||
x1, y1 = self.start.x, self.start.y
|
||||
x2, y2 = self.end.x, self.end.y
|
||||
arc = go.Arc(x1, y1, x2, y2, cx-x1, cy-y1, unit=MM)
|
||||
|
||||
if self.width:
|
||||
arc.aperture = CircleAperture(self.width, unit=MM)
|
||||
yield arc
|
||||
|
||||
if self.fill:
|
||||
yield arc.to_region()
|
||||
|
||||
|
||||
@sexp_type('gr_poly')
|
||||
class Polygon:
|
||||
|
|
@ -94,6 +192,15 @@ class Polygon:
|
|||
fill: FillMode= False
|
||||
tstamp: Timestamp = None
|
||||
|
||||
def render(self):
|
||||
reg = go.Region([(pt.x, pt.y) for pt in self.pts.xy], unit=MM)
|
||||
|
||||
if width:
|
||||
yield from reg.outline_objects(aperture=CircleAperture(self.width, unit=MM))
|
||||
|
||||
if self.fill:
|
||||
yield reg
|
||||
|
||||
|
||||
@sexp_type('gr_curve')
|
||||
class Curve:
|
||||
|
|
@ -102,10 +209,15 @@ class Curve:
|
|||
width: Named(float) = None
|
||||
tstamp: Timestamp = None
|
||||
|
||||
def render(self):
|
||||
raise NotImplementedError('Bezier rendering is not yet supported. Please raise an issue and provide an example file.')
|
||||
|
||||
|
||||
@sexp_type('gr_bbox')
|
||||
class AnnotationBBox:
|
||||
start: Rename(XYCoord) = None
|
||||
end: Rename(XYCoord) = None
|
||||
|
||||
|
||||
def render(self):
|
||||
return []
|
||||
|
||||
|
|
|
|||
|
|
@ -209,9 +209,9 @@ class _SexpTemplate:
|
|||
return [kls.name_atom]
|
||||
|
||||
@staticmethod
|
||||
def __map__(kls, value, parent=None):
|
||||
def __map__(kls, value, *args, parent=None, **kwargs):
|
||||
positional = iter(kls.positional)
|
||||
inst = kls()
|
||||
inst = kls(*args, **kwargs)
|
||||
|
||||
for v in value[1:]: # skip key
|
||||
if isinstance(v, Atom) and v in kls.keys:
|
||||
|
|
@ -248,8 +248,8 @@ class _SexpTemplate:
|
|||
yield out
|
||||
|
||||
@staticmethod
|
||||
def parse(kls, data):
|
||||
return kls.__map__(parse_sexp(data))
|
||||
def parse(kls, data, *args, **kwargs):
|
||||
return kls.__map__(parse_sexp(data), *args, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def sexp(self):
|
||||
|
|
|
|||
|
|
@ -19,9 +19,11 @@
|
|||
import math
|
||||
import copy
|
||||
from dataclasses import dataclass, astuple, field, fields
|
||||
from itertools import zip_longest
|
||||
|
||||
from .utils import MM, InterpMode, to_unit, rotate_point
|
||||
from . import graphic_primitives as gp
|
||||
from .aperture_macros import primitive as amp
|
||||
|
||||
|
||||
def convert(value, src, dst):
|
||||
|
|
@ -300,13 +302,22 @@ class Region(GraphicObject):
|
|||
self.outline = [ gp.rotate_point(x, y, angle, cx, cy) for x, y in self.outline ]
|
||||
self.arc_centers = [
|
||||
(arc[0], gp.rotate_point(*arc[1], angle, cx-p[0], cy-p[1])) if arc else None
|
||||
for p, arc in zip(self.outline, self.arc_centers) ]
|
||||
for p, arc in zip_longest(self.outline, self.arc_centers) ]
|
||||
|
||||
def _scale(self, factor):
|
||||
self.outline = [ (x*factor, y*factor) for x, y in self.outline ]
|
||||
self.arc_centers = [
|
||||
(arc[0], (arc[1][0]*factor, arc[1][1]*factor)) if arc else None
|
||||
for p, arc in zip(self.outline, self.arc_centers) ]
|
||||
for p, arc in zip_longest(self.outline, self.arc_centers) ]
|
||||
|
||||
@classmethod
|
||||
def from_rectangle(kls, x, y, w, h, unit=MM):
|
||||
return kls([
|
||||
(x, y),
|
||||
(x+w, y),
|
||||
(x+w, y+h),
|
||||
(x, y+h),
|
||||
], unit=unit)
|
||||
|
||||
def append(self, obj):
|
||||
if obj.unit != self.unit:
|
||||
|
|
@ -321,6 +332,46 @@ class Region(GraphicObject):
|
|||
else:
|
||||
self.arc_centers.append(None)
|
||||
|
||||
def close(self):
|
||||
if not self.outline:
|
||||
return
|
||||
|
||||
if self.outline[-1] != self.outline[0]:
|
||||
self.outline.append(self.outline[0])
|
||||
|
||||
def outline_objects(self, aperture=None):
|
||||
for p1, p2, arc in zip_longest(self.outline[:-1], self.outline[1:], self.arc_centers):
|
||||
if arc:
|
||||
clockwise, pc = arc
|
||||
yield Arc(*p1, *p2, *pc, clockwise, aperture=aperture, unit=self.unit, polarity_dark=self.polarity_dark)
|
||||
else:
|
||||
yield Line(*p1, *p2, aperture=aperture, unit=self.unit, polarity_dark=self.polarity_dark)
|
||||
|
||||
def _aperture_macro_primitives(self, max_error=1e-2, unit=MM):
|
||||
# unit is only for max_error, the resulting primitives will always be in MM
|
||||
|
||||
if len(self.outline) < 3:
|
||||
return
|
||||
|
||||
points = [self.outline[0]]
|
||||
for p1, p2, arc in zip_longest(self.outline[:-1], self.outline[1:], self.arc_centers):
|
||||
if arc:
|
||||
clockwise, pc = arc
|
||||
#r = math.hypot(*pc) # arc center is relative to p1.
|
||||
#d = math.dist(p1, p2)
|
||||
#err = r - math.sqrt(r**2 - (d/(2*n))**2)
|
||||
#n = math.ceil(1/(2*math.sqrt(r**2 - (r - max_err)**2)/d))
|
||||
arc = Arc(*p1, *p2, *pc, clockwise, unit=self.unit, polarity_dark=self.polarity_dark)
|
||||
for line in arc.approximate(max_error=max_error, unit=unit):
|
||||
points.append(line.p2)
|
||||
|
||||
else:
|
||||
points.append(p2)
|
||||
|
||||
if points[-1] != points[0]:
|
||||
points.append(points[0])
|
||||
yield amp.Outline(self.unit, int(self.polarity_dark), len(points)-1, *(coord for p in points for coord in p))
|
||||
|
||||
def to_primitives(self, unit=None):
|
||||
if unit == self.unit:
|
||||
yield gp.ArcPoly(outline=self.outline, arc_centers=self.arc_centers, polarity_dark=self.polarity_dark)
|
||||
|
|
@ -343,7 +394,7 @@ class Region(GraphicObject):
|
|||
|
||||
yield from gs.set_current_point(self.outline[0], unit=self.unit)
|
||||
|
||||
for point, arc_center in zip(self.outline[1:], self.arc_centers):
|
||||
for point, arc_center in zip_longest(self.outline[1:], self.arc_centers):
|
||||
if arc_center is None:
|
||||
yield from gs.set_interpolation_mode(InterpMode.LINEAR)
|
||||
|
||||
|
|
@ -446,6 +497,12 @@ class Line(GraphicObject):
|
|||
def to_primitives(self, unit=None):
|
||||
yield self.as_primitive(unit=unit)
|
||||
|
||||
def _aperture_macro_primitives(self):
|
||||
obj = self.converted(MM) # Gerbonara aperture macros use MM units.
|
||||
yield amp.VectorLine(int(self.polarity_dark), obj.width, obj.x1, obj.y1, obj.x2, obj.y2)
|
||||
yield amp.Circle(int(self.polarity_dark), obj.width, obj.x1, obj.y1)
|
||||
yield amp.Circle(int(self.polarity_dark), obj.width, obj.x2, obj.y2)
|
||||
|
||||
def to_statements(self, gs):
|
||||
yield from gs.set_polarity(self.polarity_dark)
|
||||
yield from gs.set_aperture(self.aperture)
|
||||
|
|
@ -504,6 +561,10 @@ class Arc(GraphicObject):
|
|||
#: Aperture for this arc. Should be a subclass of :py:class:`.CircleAperture`, whose diameter determines the line
|
||||
#: width.
|
||||
aperture : object
|
||||
|
||||
@classmethod
|
||||
def from_circle(kls, cx, cy, r, aperture, unit=MM):
|
||||
return kls(cx-r, cy, cx-r, cy, r, 0, aperture=aperture, unit=MM)
|
||||
|
||||
def _offset(self, dx, dy):
|
||||
self.x1 += dx
|
||||
|
|
@ -665,6 +726,17 @@ class Arc(GraphicObject):
|
|||
def to_primitives(self, unit=None):
|
||||
yield self.as_primitive(unit=unit)
|
||||
|
||||
def to_region(self):
|
||||
reg = Region(unit=self.unit, polarity_dark=self.polarity_dark)
|
||||
reg.append(self)
|
||||
reg.close()
|
||||
return reg
|
||||
|
||||
def _aperture_macro_primitives(self, max_error=1e-2, unit=MM):
|
||||
# unit is only for max_error, the resulting primitives will always be in MM
|
||||
for line in self.approximate(max_error=max_error, unit=unit):
|
||||
yield from line._aperture_macro_primitives()
|
||||
|
||||
def to_statements(self, gs):
|
||||
yield from gs.set_polarity(self.polarity_dark)
|
||||
yield from gs.set_aperture(self.aperture)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from pathlib import Path
|
|||
import unicodedata
|
||||
import re
|
||||
import ast
|
||||
from functools import lru_cache
|
||||
from importlib.resources import files
|
||||
|
||||
from . import data
|
||||
|
|
@ -21,7 +22,12 @@ class Newstroke:
|
|||
def __init__(self, newstroke_cpp=None):
|
||||
if newstroke_cpp is None:
|
||||
newstroke_cpp = files(data).joinpath('newstroke_font.cpp').read_bytes()
|
||||
self.glyphs = dict(self.load(newstroke_cpp))
|
||||
self.glyphs = dict(self.load_font(newstroke_cpp))
|
||||
|
||||
@classmethod
|
||||
@lru_cache
|
||||
def load(kls):
|
||||
return kls()
|
||||
|
||||
def render(self, text, size=1.0, space_width=DEFAULT_SPACE_WIDTH, char_gap=DEFAULT_CHAR_GAP):
|
||||
text = unicodedata.normalize('NFC', text)
|
||||
|
|
@ -47,7 +53,7 @@ class Newstroke:
|
|||
return [(x*sx+dx, y*sy+dy) for x, y in stroke]
|
||||
|
||||
|
||||
def load(self, newstroke_cpp):
|
||||
def load_font(self, newstroke_cpp):
|
||||
e = []
|
||||
for char, (width, strokes) in self.load_glyphs(newstroke_cpp):
|
||||
yield char, (width, strokes)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue