Basic KiCad footprint rendering works

This commit is contained in:
jaseg 2023-04-18 12:26:03 +02:00
parent 263033c9bd
commit 2c6c9a5cbc
8 changed files with 527 additions and 29 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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 []

View file

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

View file

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

View file

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