From 2c6c9a5cbc6d389a17c0cc15173c6e626fd5d5c6 Mon Sep 17 00:00:00 2001 From: jaseg Date: Tue, 18 Apr 2023 12:26:03 +0200 Subject: Basic KiCad footprint rendering works --- gerbonara/aperture_macros/parse.py | 11 ++ gerbonara/aperture_macros/primitive.py | 11 +- gerbonara/cad/kicad/base_types.py | 31 ++- gerbonara/cad/kicad/footprints.py | 291 +++++++++++++++++++++++++++- gerbonara/cad/kicad/graphical_primitives.py | 116 ++++++++++- gerbonara/cad/kicad/sexp_mapper.py | 8 +- gerbonara/graphic_objects.py | 78 +++++++- gerbonara/newstroke.py | 10 +- 8 files changed, 527 insertions(+), 29 deletions(-) diff --git a/gerbonara/aperture_macros/parse.py b/gerbonara/aperture_macros/parse.py index f0ff8d6..868f7ae 100644 --- a/gerbonara/aperture_macros/parse.py +++ b/gerbonara/aperture_macros/parse.py @@ -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]), diff --git a/gerbonara/aperture_macros/primitive.py b/gerbonara/aperture_macros/primitive.py index f9b5a78..5b93971 100644 --- a/gerbonara/aperture_macros/primitive.py +++ b/gerbonara/aperture_macros/primitive.py @@ -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, diff --git a/gerbonara/cad/kicad/base_types.py b/gerbonara/cad/kicad/base_types.py index 1b3a327..8f3036c 100644 --- a/gerbonara/cad/kicad/base_types.py +++ b/gerbonara/cad/kicad/base_types.py @@ -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) diff --git a/gerbonara/cad/kicad/footprints.py b/gerbonara/cad/kicad/footprints.py index f76fd3f..4b95d4e 100644 --- a/gerbonara/cad/kicad/footprints.py +++ b/gerbonara/cad/kicad/footprints.py @@ -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') diff --git a/gerbonara/cad/kicad/graphical_primitives.py b/gerbonara/cad/kicad/graphical_primitives.py index 391b38b..ed40c96 100644 --- a/gerbonara/cad/kicad/graphical_primitives.py +++ b/gerbonara/cad/kicad/graphical_primitives.py @@ -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 [] + diff --git a/gerbonara/cad/kicad/sexp_mapper.py b/gerbonara/cad/kicad/sexp_mapper.py index cb7c99f..1d0f942 100644 --- a/gerbonara/cad/kicad/sexp_mapper.py +++ b/gerbonara/cad/kicad/sexp_mapper.py @@ -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): diff --git a/gerbonara/graphic_objects.py b/gerbonara/graphic_objects.py index d035fb8..79acd13 100644 --- a/gerbonara/graphic_objects.py +++ b/gerbonara/graphic_objects.py @@ -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) diff --git a/gerbonara/newstroke.py b/gerbonara/newstroke.py index f9c4038..b48476a 100644 --- a/gerbonara/newstroke.py +++ b/gerbonara/newstroke.py @@ -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) -- cgit