From 95da4820337911f20dcc54c42820805fbc35ee13 Mon Sep 17 00:00:00 2001 From: jaseg Date: Fri, 22 Sep 2023 18:50:31 +0200 Subject: WIP --- docs/index.rst | 3 + gerbonara/cad/kicad/symbols.py | 2 +- gerbonara/graphic_objects.py | 156 +++++++++++++------------------ gerbonara/graphic_primitives.py | 102 ++++++++++++++++---- gerbonara/tests/test_kicad_footprints.py | 2 +- gerbonara/utils.py | 67 +++++++++++-- 6 files changed, 213 insertions(+), 119 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index 45dbc1b..71d91ec 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -48,6 +48,7 @@ Features cli api-concepts + examples file-api object-api apertures @@ -74,6 +75,8 @@ Then, you are ready to read and write gerber files: w, h = stack.outline.size('mm') print(f'Board size is {w:.1f} mm x {h:.1f} mm') +You can find some more elaborate examples in this doc's :ref:`Examples section`. + Command-Line Interface ====================== diff --git a/gerbonara/cad/kicad/symbols.py b/gerbonara/cad/kicad/symbols.py index 45f8ec0..972dd55 100644 --- a/gerbonara/cad/kicad/symbols.py +++ b/gerbonara/cad/kicad/symbols.py @@ -285,7 +285,7 @@ class Arc: x2, y2 = self.mid.x-x1, self.mid.y-x2 x3, y3 = (self.end.x - x1)/2, (self.end.y - y1)/2 clockwise = math.atan2(x2*y3-x3*y2, x2*x3+y2*y3) > 0 - return arc_bounds(x1, y1, self.end.x, self.end.y, cx-x1, cy-y1, clockwise) + return arc_bounds(x1, y1, self.end.x, self.end.y, cx, cy, clockwise) def to_svg(self, colorscheme=Colorscheme.KiCad): diff --git a/gerbonara/graphic_objects.py b/gerbonara/graphic_objects.py index 2b9dc3e..14cfc66 100644 --- a/gerbonara/graphic_objects.py +++ b/gerbonara/graphic_objects.py @@ -19,9 +19,9 @@ import math import copy from dataclasses import dataclass, astuple, field, fields -from itertools import zip_longest +from itertools import zip_longest, pairwise, islice, cycle -from .utils import MM, InterpMode, to_unit, rotate_point, sum_bounds +from .utils import MM, InterpMode, to_unit, rotate_point, sum_bounds, approximate_arc, sweep_angle from . import graphic_primitives as gp from .aperture_macros import primitive as amp @@ -278,9 +278,15 @@ class Region(GraphicObject): * A region is always exactly one connected component. * A region must not overlap itself anywhere. * A region cannot have holes. + * The last outline point of the region must be equal to the first. There is one exception from the last two rules: To emulate a region with a hole in it, *cut-ins* are allowed. At a cut-in, the region is allowed to touch (but never overlap!) itself. + + When ``arc_centers`` is empty, this region has only straight outline segments. When ``arc_centers`` is not empty, + the i-th entry defines the i-th outline segment, with a ``None`` entry designating a straight line segment. + An arc is defined by a ``(clockwise, (cx, cy))`` tuple, where ``clockwise`` can be ``True`` for a clockwise arc, or + ``False`` for a counter-clockwise arc. ``cx`` and ``cy`` are the absolute coordinates of the arc's center. """ def __init__(self, outline=None, arc_centers=None, *, unit=MM, polarity_dark=True): @@ -304,8 +310,8 @@ class Region(GraphicObject): def _rotate(self, angle, cx=0, cy=0): 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_longest(self.outline, self.arc_centers) ] + (arc[0], gp.rotate_point(*arc[1], angle, cx, cy)) if arc else None + for arc in self.arc_centers ] def _scale(self, factor): self.outline = [ (x*factor, y*factor) for x, y in self.outline ] @@ -322,6 +328,10 @@ class Region(GraphicObject): (x, y+h), ], unit=unit) + @classmethod + def from_arc_poly(kls, arc_poly, polarity_dark=True, unit=MM): + return kls(arc_poly.outline, arc_poly.arc_centers, polarity_dark=polarity_dark, unit=unit) + def append(self, obj): if obj.unit != self.unit: obj = obj.converted(self.unit) @@ -331,48 +341,49 @@ class Region(GraphicObject): self.outline.append(obj.p2) if isinstance(obj, Arc): - self.arc_centers.append((obj.clockwise, obj.center_relative)) + self.arc_centers.append((obj.clockwise, obj.center)) else: self.arc_centers.append(None) - def close(self): - if not self.outline: - return + def iter_segments(self, tolerance=1e-6): + for points, arc in zip_longest(pairwise(self.outline), self.arc_centers): + if arc: + if points: + yield *points, arc + else: + yield self.outline[-1], self.outline[0], arc + return + else: + if not points: + break + yield *points, (None, (None, None)) - if self.outline[-1] != self.outline[0]: - self.outline.append(self.outline[0]) + # Close outline if necessary. + if math.dist(self.outline[0], self.outline[-1]) > tolerance: + yield self.outline[-1], self.outline[0], (None, (None, None)) def outline_objects(self, aperture=None): - for p1, p2, arc in zip_longest(self.outline, 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) + for p1, p2, (clockwise, center) in self.iter_segments(): + if center: + yield Arc(*p1, *p2, *center, 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): + def _aperture_macro_primitives(self, max_error=1e-2, clip_max_error=True, unit=MM): # unit is only for max_error, the resulting primitives will always be in MM if len(self.outline) < 2: 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, aperture=None) - for line in arc.approximate(max_error=max_error, unit=unit): - points.append(line.p2) - + points = [] + for p1, p2, (clockwise, center) in self.iter_segments(): + if center: + for p in approximate_arc(*center, *p1, *p2, clockwise, + max_error=max_error, clip_max_error=clip_max_error): + points.append(p) + points.pop() else: - points.append(p2) - - if points[-1] != points[0]: - points.append(points[0]) + points.append(p1) yield amp.Outline(self.unit, int(self.polarity_dark), len(points)-1, tuple(coord for p in points for coord in p)) @@ -389,6 +400,9 @@ class Region(GraphicObject): yield gp.ArcPoly(conv_outline, conv_arc, polarity_dark=self.polarity_dark) def to_statements(self, gs): + if len(self.outline) < 3: + return + yield from gs.set_polarity(self.polarity_dark) yield 'G36*' # Repeat interpolation mode at start of region statement to work around gerbv bug. Without this, gerbv will @@ -398,32 +412,24 @@ class Region(GraphicObject): yield from gs.set_current_point(self.outline[0], unit=self.unit) - for point, arc_center in zip_longest(self.outline[1:], self.arc_centers): - if point is None and arc_center is None: + for previous_point, point, (clockwise, center) in self.iter_segments(): + if point is None and center is None: break - if arc_center is None: - yield from gs.set_interpolation_mode(InterpMode.LINEAR) + x = gs.file_settings.write_gerber_value(point[0], self.unit) + y = gs.file_settings.write_gerber_value(point[1], self.unit) - x = gs.file_settings.write_gerber_value(point[0], self.unit) - y = gs.file_settings.write_gerber_value(point[1], self.unit) + if clockwise is None: + yield from gs.set_interpolation_mode(InterpMode.LINEAR) yield f'X{x}Y{y}D01*' - gs.update_point(*point, unit=self.unit) - else: - clockwise, (cx, cy) = arc_center - x2, y2 = point yield from gs.set_interpolation_mode(InterpMode.CIRCULAR_CW if clockwise else InterpMode.CIRCULAR_CCW) - - x = gs.file_settings.write_gerber_value(x2, self.unit) - y = gs.file_settings.write_gerber_value(y2, self.unit) - # TODO are these coordinates absolute or relative now?! - i = gs.file_settings.write_gerber_value(cx, self.unit) - j = gs.file_settings.write_gerber_value(cy, self.unit) + i = gs.file_settings.write_gerber_value(center[0]-previous_point[0], self.unit) + j = gs.file_settings.write_gerber_value(center[1]-previous_point[1], self.unit) yield f'X{x}Y{y}I{i}J{j}D01*' - gs.update_point(x2, y2, unit=self.unit) + gs.update_point(*point, unit=self.unit) yield 'G37*' @@ -605,22 +611,8 @@ class Arc(GraphicObject): :returns: Angle in clockwise radian between ``0`` and ``2*math.pi`` :rtype: float """ - cx, cy = self.cx + self.x1, self.cy + self.y1 - x1, y1 = self.x1 - cx, self.y1 - cy - x2, y2 = self.x2 - cx, self.y2 - cy - - a1, a2 = math.atan2(y1, x1), math.atan2(y2, x2) - f = abs(a2 - a1) - if not self.clockwise: - if a2 > a1: - return a2 - a1 - else: - return 2*math.pi - abs(a2 - a1) - else: - if a1 > a2: - return a1 - a2 - else: - return 2*math.pi - abs(a1 - a2) + + return sweep_angle(self.cx+self.x1, self.cy+self.y1, self.x1, self.y1, self.x2, self.y2, self.clockwise) @property def p1(self): @@ -677,34 +669,16 @@ class Arc(GraphicObject): :returns: list of :py:class:`~.graphic_objects.Line` instances. :rtype: list """ - # TODO the max_angle calculation below is a bit off -- we over-estimate the error, and thus produce finer - # results than necessary. Fix this. - - r = math.hypot(self.cx, self.cy) max_error = self.unit(max_error, unit) - if clip_max_error: - # 1 - math.sqrt(1 - 0.5*math.sqrt(2)) - max_error = min(max_error, r*0.4588038998538031) - - elif max_error >= r: - return [Line(*self.p1, *self.p2, aperture=self.aperture, polarity_dark=self.polarity_dark, unit=self.unit)] - - # see https://www.mathopenref.com/sagitta.html - l = math.sqrt(r**2 - (r - max_error)**2) - - angle_max = math.asin(l/r) - sweep_angle = self.sweep_angle() - num_segments = math.ceil(sweep_angle / angle_max) - angle = sweep_angle / num_segments - - if not self.clockwise: - angle = -angle - - cx, cy = self.center - points = [ rotate_point(self.x1, self.y1, i*angle, cx, cy) for i in range(num_segments + 1) ] - return [ Line(*p1, *p2, aperture=self.aperture, polarity_dark=self.polarity_dark, unit=self.unit) - for p1, p2 in zip(points[0::], points[1::]) ] + return [Line(*p1, *p2, aperture=self.aperture, polarity_dark=self.polarity_dark, unit=self.unit) + for p1, p2 in pairwise(approximate_arc( + self.cx+self.x1, self.cy+self.y1, + self.x1, self.y1, + self.x2, self.y2, + self.clockwise, + max_error=max_error, + clip_max_error=clip_max_error))] def _rotate(self, rotation, cx=0, cy=0): # rotate center first since we need old x1, y1 here @@ -726,7 +700,7 @@ class Arc(GraphicObject): w = self.aperture.equivalent_width(unit) if self.aperture else 0 return gp.Arc(x1=conv.x1, y1=conv.y1, x2=conv.x2, y2=conv.y2, - cx=conv.cx, cy=conv.cy, + cx=conv.cx+conv.x1, cy=conv.cy+conv.y1, clockwise=self.clockwise, width=w, polarity_dark=self.polarity_dark) diff --git a/gerbonara/graphic_primitives.py b/gerbonara/graphic_primitives.py index ea8fd9f..e136efb 100644 --- a/gerbonara/graphic_primitives.py +++ b/gerbonara/graphic_primitives.py @@ -19,7 +19,7 @@ import math import itertools -from dataclasses import dataclass, replace +from dataclasses import dataclass, replace, field from .utils import * @@ -79,6 +79,10 @@ class Circle(GraphicPrimitive): color = fg if self.polarity_dark else bg return tag('circle', cx=prec(self.x), cy=prec(self.y), r=prec(self.r), fill=color) + def to_arc_poly(self): + return ArcPoly([(self.x-self.r, self.y), (self.x+self.r, self.y)], + [(True, (self.x, self.y)), (True, (self.x, self.y))]) + @dataclass(frozen=True) class ArcPoly(GraphicPrimitive): @@ -88,28 +92,51 @@ class ArcPoly(GraphicPrimitive): #: connected. outline : list #: Must be either None (all segments are straight lines) or same length as outline. - #: Straight line segments have None entry. - arc_centers : list = None + #: Straight line segments have None entry. Arc segments have (clockwise, (cx, cy)) tuple with cx, cy being absolute + #: coords. + arc_centers : list = field(default_factory=list) @property def segments(self): """ Return an iterator through all *segments* of this polygon. For each outline segment (line or arc), this - iterator will yield a ``(p1, p2, center)`` tuple. If the segment is a straight line, ``center`` will be - ``None``. + iterator will yield a ``(p1, p2, (clockwise, center))`` tuple. If the segment is a straight line, ``clockwise`` + will be ``None``. """ - ol = self.outline - return itertools.zip_longest(ol, ol[1:] + [ol[0]], self.arc_centers or []) + for points, arc in itertools.zip_longest(itertools.pairwise(self.outline), self.arc_centers): + if arc: + if points: + yield *points, arc + else: + yield self.outline[-1], self.outline[0], arc + return + else: + if not points: + break + yield *points, (None, (None, None)) + + # Close outline if necessary. + if math.dist(self.outline[0], self.outline[-1]) > 1e-6: + yield self.outline[-1], self.outline[0], (None, (None, None)) + + def approximate_arcs(self, max_error=1e-2, clip_max_error=True): + outline = [] + for p1, p2, (clockwise, center) in self.segments(): + if clockwise is None: + outline.append(p1) + else: + outline.extend(approximate_arc(cx, cy, x1, y1, x2, y2, clockwise, + max_error=max_error, clip_max_error=clip_max_error)) + outline.pop() # remove arc end point + return type(self)(outline) def bounding_box(self): bbox = (None, None), (None, None) - for (x1, y1), (x2, y2), arc in self.segments: - if arc: - clockwise, (cx, cy) = arc - bbox = add_bounds(bbox, arc_bounds(x1, y1, x2, y2, cx, cy, clockwise)) - - else: + for (x1, y1), (x2, y2), (clockwise, (cx, cy)) in self.segments: + if clockwise is None: line_bounds = (min(x1, x2), min(y1, y2)), (max(x1, x2), max(y1, y2)) bbox = add_bounds(bbox, line_bounds) + else: + bbox = add_bounds(bbox, arc_bounds(x1, y1, x2, y2, cx, cy, clockwise)) return bbox @classmethod @@ -149,6 +176,9 @@ class ArcPoly(GraphicPrimitive): color = fg if self.polarity_dark else bg return tag('path', d=' '.join(self.path_d()), fill=color) + def to_arc_poly(self): + return self + @dataclass(frozen=True) class Line(GraphicPrimitive): @@ -191,6 +221,24 @@ class Line(GraphicPrimitive): return tag('path', d=f'M {float(self.x1):.6} {float(self.y1):.6} L {float(self.x2):.6} {float(self.y2):.6}', fill='none', stroke=color, stroke_width=str(width)) + def to_arc_poly(self): + l = math.dist((self.x1, self.y1), (self.x2, self.y2)) + dx, dy = self.x2-self.x1, self.y2-self.y1 + nx, ny = -dy/l, dx/l + rx, ry = nx*self.width/2, ny*self.width/2 + return ArcPoly([ + (self.x1+rx, self.y1+ry), + (self.x1-rx, self.y1-ry), + (self.x2-rx, self.y2-ry), + (self.x2+rx, self.y2+ry), + ], [ + (True, (self.x1, self.y1)), + None, + (True, (self.x2, self.y2)), + None, + ]) + + @dataclass(frozen=True) class Arc(GraphicPrimitive): """ Circular arc with line width ``width`` going from ``(x1, y1)`` to ``(x2, y2)`` around center at ``(cx, cy)``. """ @@ -202,9 +250,9 @@ class Arc(GraphicPrimitive): x2 : float #: End Y coodinate y2 : float - #: Center X coordinate relative to ``x1`` + #: Center X coordinate (absolute) cx : float - #: Center Y coordinate relative to ``y1`` + #: Center Y coordinate (absolute) cy : float #: ``True`` if this arc is clockwise from start to end. Selects between the large arc and the small arc given this #: start, end and center @@ -214,11 +262,10 @@ class Arc(GraphicPrimitive): @property def is_circle(self): - return math.isclose(self.x1, self.x2) and math.isclose(self.y1, self.y2) + return math.isclose(self.x1, self.x2, abs_tol=1e-6) and math.isclose(self.y1, self.y2, abs_tol=1e-6) def flip(self): - return replace(self, x1=self.x2, y1=self.y2, x2=self.x1, y2=self.y1, - cx=(self.x1 + self.cx) - self.x2, cy=(self.y1 + self.cy) - self.y2, clockwise=not self.clockwise) + return replace(self, x1=self.x2, y1=self.y2, x2=self.x1, y2=self.y1, clockwise=not self.clockwise) def bounding_box(self): r = self.width/2 @@ -232,6 +279,25 @@ class Arc(GraphicPrimitive): return tag('path', d=f'M {float(self.x1):.6} {float(self.y1):.6} {arc}', fill='none', stroke=color, stroke_width=width) + def to_arc_poly(self): + r = math.dist((self.x1, self.y1), (self.cx, self.cy)) + dx1, dy1 = self.x1-self.cx, self.y1-self.cy + nx1, ny1 = dx1/r * self.width/2, dy1/r * self.width/2 + dx2, dy2 = self.x2-self.cx, self.y2-self.cy + nx2, ny2 = dx2/r * self.width/2, dy2/r * self.width/2 + return ArcPoly([ + (self.x1+nx1, self.y1+nx1), + (self.x1-nx1, self.y1-nx1), + (self.x2-nx2, self.y2-nx2), + (self.x2+nx2, self.y2+nx2), + ], [ + (self.clockwise, (self.x1, self.y1)), + (self.clockwise, (self.cx, self.cy)), + (self.clockwise, (self.x2, self.y2)), + (self.clockwise, (self.cx, self.cy)), + ]) + + @dataclass(frozen=True) class Rectangle(GraphicPrimitive): #: **Center** X coordinate diff --git a/gerbonara/tests/test_kicad_footprints.py b/gerbonara/tests/test_kicad_footprints.py index 07d64a7..663037b 100644 --- a/gerbonara/tests/test_kicad_footprints.py +++ b/gerbonara/tests/test_kicad_footprints.py @@ -142,7 +142,7 @@ def _parse_path_d(path): cx = mx - nx*nl cy = my - ny*nl - (min_x, min_y), (max_x, max_y) = arc_bounds(last_x, last_y, ax, ay, cx-last_x, cy-last_y, clockwise=(not sweep)) + (min_x, min_y), (max_x, max_y) = arc_bounds(last_x, last_y, ax, ay, cx, cy, clockwise=(not sweep)) min_x -= sr min_y -= sr max_x += sr diff --git a/gerbonara/utils.py b/gerbonara/utils.py index c7336e6..892b217 100644 --- a/gerbonara/utils.py +++ b/gerbonara/utils.py @@ -244,6 +244,59 @@ def rotate_point(x, y, angle, cx=0, cy=0): cy + (x - cx) * math.sin(-angle) + (y - cy) * math.cos(-angle)) +def sweep_angle(cx, cy, x1, y1, x2, y2, clockwise): + """ Calculate absolute sweep angle of arc. This is always a positive number. + + :returns: Angle in clockwise radian between ``0`` and ``2*math.pi`` + :rtype: float + """ + x1, y1 = x1-cx, y1-cy + x2, y2 = x2-cx, y2-cy + + a1, a2 = math.atan2(y1, x1), math.atan2(y2, x2) + f = abs(a2 - a1) + if not clockwise: + if a2 > a1: + return a2 - a1 + else: + return 2*math.pi - abs(a2 - a1) + else: + if a1 > a2: + return a1 - a2 + else: + return 2*math.pi - abs(a1 - a2) + + +def approximate_arc(cx, cy, x1, y1, x2, y2, clockwise, max_error=1e-2, clip_max_error=True): + # TODO the max_angle calculation below is a bit off -- we over-estimate the error, and thus produce finer + # results than necessary. Fix this. + + r = math.dist((x1, y1), (cx, cy)) + + if clip_max_error: + # 1 - math.sqrt(1 - 0.5*math.sqrt(2)) + max_error = min(max_error, r*0.4588038998538031) + + elif max_error >= r: + yield (x1, y1) + yield (x2, y2) + return + + # see https://www.mathopenref.com/sagitta.html + l = math.sqrt(r**2 - (r - max_error)**2) + + angle_max = math.asin(l/r) + sweep_angle = sweep_angle(cx, cy, x1, y1, x2, y2, clockwise) + num_segments = math.ceil(sweep_angle / angle_max) + angle = sweep_angle / num_segments + + if not clockwise: + angle = -angle + + for i in range(num_segments + 1): + yield rotate_point(x1, y1, i*angle, cx, cy) + + def min_none(a, b): """ Like the ``min(..)`` builtin, but if either value is ``None``, returns the other. """ if a is None: @@ -340,11 +393,9 @@ def arc_bounds(x1, y1, x2, y2, cx, cy, clockwise): # This solution manages to handle circular arcs given in gerber format (with explicit center and endpoints, plus # sweep direction instead of a format with e.g. angles and radius) without any trigonometric functions (e.g. atan2). # - # cx, cy are relative to p1. + # cx, cy are in absolute coordinates. # Center arc on cx, cy - cx += x1 - cy += y1 x1 -= cx x2 -= cx y1 -= cy @@ -461,25 +512,25 @@ def point_line_distance(l1, l2, p): def svg_arc(old, new, center, clockwise): - """ Format an SVG circular arc "A" path data entry given an arc in Gerber notation (i.e. with center relative to - first point). + """ Format an SVG circular arc "A" path data entry given an arc in Gerber notation (but with center in absolute + coordinates). :rtype: str """ - r = float(math.hypot(*center)) + r = float(math.dist(old, center)) # invert sweep flag since the svg y axis is mirrored sweep_flag = int(not clockwise) # In the degenerate case where old == new, we always take the long way around. To represent this "full-circle arc" # in SVG, we have to split it into two. if math.isclose(math.dist(old, new), 0): - intermediate = old[0] + 2*center[0], old[1] + 2*center[1] + intermediate = old[0] + 2*(center[0]-old[0]), old[1] + 2*(center[1]-old[1]) # Note that we have to preserve the sweep flag to avoid causing self-intersections by flipping the direction of # a circular cutin return f'A {r:.6} {r:.6} 0 1 {sweep_flag} {float(intermediate[0]):.6} {float(intermediate[1]):.6} ' +\ f'A {r:.6} {r:.6} 0 1 {sweep_flag} {float(new[0]):.6} {float(new[1]):.6}' else: # normal case - d = point_line_distance(old, new, (old[0]+center[0], old[1]+center[1])) + d = point_line_distance(old, new, center[0], center[1]) large_arc = int((d < 0) == clockwise) return f'A {r:.6} {r:.6} 0 {large_arc} {sweep_flag} {float(new[0]):.6} {float(new[1]):.6}' -- cgit