From ac66fd9d6b3561c1a0fb52ac2e196157bcf5f4fd Mon Sep 17 00:00:00 2001 From: jaseg Date: Thu, 3 Feb 2022 01:02:45 +0100 Subject: More doc! --- docs/aperture-macros.rst | 3 - docs/graphic-primitive-api.rst | 27 ++- gerbonara/aperture_macros/primitive.py | 2 +- gerbonara/apertures.py | 9 +- gerbonara/cam.py | 52 +++++- gerbonara/graphic_objects.py | 3 +- gerbonara/graphic_primitives.py | 329 +++++++++------------------------ gerbonara/rs274x.py | 4 +- gerbonara/utils.py | 256 +++++++++++++++++++++---- 9 files changed, 377 insertions(+), 308 deletions(-) diff --git a/docs/aperture-macros.rst b/docs/aperture-macros.rst index 1284c49..439b675 100644 --- a/docs/aperture-macros.rst +++ b/docs/aperture-macros.rst @@ -4,9 +4,6 @@ Aperture Macros .. autoclass:: gerbonara.aperture_macros.parse.ApertureMacro :members: -.. autoclass:: gerbonara.aperture_macros.parse.GenericMacros - :members: - .. autoclass:: gerbonara.aperture_macros.expression.Expression :members: diff --git a/docs/graphic-primitive-api.rst b/docs/graphic-primitive-api.rst index d506e87..ff22b76 100644 --- a/docs/graphic-primitive-api.rst +++ b/docs/graphic-primitive-api.rst @@ -1,17 +1,22 @@ Graphic Primitives ================== -.. autoclass:: gerbonara.graphic_primitives.GraphicPrimitive - :members: +Graphic prmitives are the core of Gerbonara's rendering interface. Individual graphic objects such as a Gerber +:py:class:`.Region` as well as entire layers such as a :py:class:`.GerberFile` can be rendered into a list of graphic +primitives. This rendering step resolves aperture definitions, calculates out aperture macros, converts units into a +given target unit, and maps complex shapes to a small number of subclasses of :py:class:`.GraphicPrimitive`. -.. autoclass:: gerbonara.graphic_primitives.Circle - :members: +All graphic primitives have a :py:attr:`~.GraphicPrimitive.polarity_dark` attribute. Its meaning is identical with +:py:attr:`.GraphicObject.polarity_dark`. -.. autoclass:: gerbonara.graphic_primitives.Obround +.. autoclass:: gerbonara.graphic_primitives.GraphicPrimitive :members: -.. autoclass:: gerbonara.graphic_primitives.ArcPoly - :members: +The five types of Graphic Primitives +------------------------------------ + +Stroked lines +~~~~~~~~~~~~~ .. autoclass:: gerbonara.graphic_primitives.Line :members: @@ -19,9 +24,15 @@ Graphic Primitives .. autoclass:: gerbonara.graphic_primitives.Arc :members: +Filled shapes +~~~~~~~~~~~~~ + +.. autoclass:: gerbonara.graphic_primitives.Circle + :members: + .. autoclass:: gerbonara.graphic_primitives.Rectangle :members: -.. autoclass:: gerbonara.graphic_primitives.RegularPolygon +.. autoclass:: gerbonara.graphic_primitives.ArcPoly :members: diff --git a/gerbonara/aperture_macros/primitive.py b/gerbonara/aperture_macros/primitive.py index 8732520..47ae87b 100644 --- a/gerbonara/aperture_macros/primitive.py +++ b/gerbonara/aperture_macros/primitive.py @@ -159,7 +159,7 @@ class Polygon(Primitive): rotation += deg_to_rad(calc.rotation) x, y = gp.rotate_point(calc.x, calc.y, rotation, 0, 0) x, y = x+offset[0], y+offset[1] - return [ gp.RegularPolygon(calc.x, calc.y, calc.diameter/2, calc.n_vertices, rotation, + return [ gp.ArcPoly.from_regular_polygon(calc.x, calc.y, calc.diameter/2, calc.n_vertices, rotation, polarity_dark=(bool(calc.exposure) == polarity_dark)) ] def dilate(self, offset, unit): diff --git a/gerbonara/apertures.py b/gerbonara/apertures.py index e22b702..d717d36 100644 --- a/gerbonara/apertures.py +++ b/gerbonara/apertures.py @@ -10,10 +10,9 @@ from . import graphic_primitives as gp def _flash_hole(self, x, y, unit=None, polarity_dark=True): if getattr(self, 'hole_rect_h', None) is not None: + w, h = self.unit.convert_to(unit, self.hole_dia), self.unit.convert_to(unit, self.hole_rect_h) return [*self._primitives(x, y, unit, polarity_dark), - gp.Rectangle((x, y), - (self.unit.convert_to(unit, self.hole_dia), self.unit.convert_to(unit, self.hole_rect_h)), - rotation=self.rotation, polarity_dark=(not polarity_dark))] + gp.Rectangle(x, y, w, h, rotation=self.rotation, polarity_dark=(not polarity_dark))] elif self.hole_dia is not None: return [*self._primitives(x, y, unit, polarity_dark), gp.Circle(x, y, self.unit.convert_to(unit, self.hole_dia/2), polarity_dark=(not polarity_dark))] @@ -312,7 +311,7 @@ class ObroundAperture(Aperture): rotation : float = 0 def _primitives(self, x, y, unit=None, polarity_dark=True): - return [ gp.Obround(x, y, self.unit.convert_to(unit, self.w), self.unit.convert_to(unit, self.h), + return [ gp.Line.from_obround(x, y, self.unit.convert_to(unit, self.w), self.unit.convert_to(unit, self.h), rotation=self.rotation, polarity_dark=polarity_dark) ] def __str__(self): @@ -370,7 +369,7 @@ class PolygonAperture(Aperture): self.n_vertices = int(self.n_vertices) def _primitives(self, x, y, unit=None, polarity_dark=True): - return [ gp.RegularPolygon(x, y, self.unit.convert_to(unit, self.diameter)/2, self.n_vertices, + return [ gp.ArcPoly.from_regular_polygon(x, y, self.unit.convert_to(unit, self.diameter)/2, self.n_vertices, rotation=self.rotation, polarity_dark=polarity_dark) ] def __str__(self): diff --git a/gerbonara/cam.py b/gerbonara/cam.py index ccf1d2a..cf1d78a 100644 --- a/gerbonara/cam.py +++ b/gerbonara/cam.py @@ -198,13 +198,55 @@ class FileSettings: return format(value, f'0{integer_digits+decimal_digits+1}.{decimal_digits}f') +class Polyline: + """ Class that is internally used to generate compact SVG renderings. Collectes a number of subsequent + :py:class:`~.graphic_objects.Line` and :py:class:`~.graphic_objects.Arc` instances into one SVG . """ + + def __init__(self, *lines): + self.coords = [] + self.polarity_dark = None + self.width = None + + for line in lines: + self.append(line) + + def append(self, line): + assert isinstance(line, Line) + if not self.coords: + self.coords.append((line.x1, line.y1)) + self.coords.append((line.x2, line.y2)) + self.polarity_dark = line.polarity_dark + self.width = line.width + return True + + else: + x, y = self.coords[-1] + if self.polarity_dark == line.polarity_dark and self.width == line.width \ + and math.isclose(line.x1, x) and math.isclose(line.y1, y): + self.coords.append((line.x2, line.y2)) + return True + + else: + return False + + def to_svg(self, fg='black', bg='white', tag=Tag): + color = fg if self.polarity_dark else bg + if not self.coords: + return None + + (x0, y0), *rest = self.coords + d = f'M {x0:.6} {y0:.6} ' + ' '.join(f'L {x:.6} {y:.6}' for x, y in rest) + width = f'{self.width:.6}' if not math.isclose(self.width, 0) else '0.01mm' + return tag('path', d=d, style=f'fill: none; stroke: {color}; stroke-width: {width}; stroke-linejoin: round; stroke-linecap: round') + + class CamFile: def __init__(self, original_path=None, layer_name=None, import_settings=None): self.original_path = original_path self.layer_name = layer_name self.import_settings = import_settings - def to_svg(self, tag=Tag, margin=0, arg_unit=MM, svg_unit=MM, force_bounds=None, fg='black', bg='white'): + def to_svg(self, margin=0, arg_unit=MM, svg_unit=MM, force_bounds=None, fg='black', bg='white', tag=Tag): if force_bounds is None: (min_x, min_y), (max_x, max_y) = self.bounding_box(svg_unit, default=((0, 0), (0, 0))) @@ -252,15 +294,15 @@ class CamFile: polyline = gp.Polyline(primitive) else: if not polyline.append(primitive): - tags.append(polyline.to_svg(tag, fg, bg)) + tags.append(polyline.to_svg(fg, bg, tag=tag)) polyline = gp.Polyline(primitive) else: if polyline: - tags.append(polyline.to_svg(tag, fg, bg)) + tags.append(polyline.to_svg(fg, bg, tag=tag)) polyline = None - tags.append(primitive.to_svg(tag, fg, bg)) + tags.append(primitive.to_svg(fg, bg, tag=tag)) if polyline: - tags.append(polyline.to_svg(tag, fg, bg)) + tags.append(polyline.to_svg(fg, bg, tag=tag)) # setup viewport transform flipping y axis xform = f'translate({content_min_x} {content_min_y+content_h}) scale(1 -1) translate({-content_min_x} {-content_min_y})' diff --git a/gerbonara/graphic_objects.py b/gerbonara/graphic_objects.py index cdf593f..c29db5c 100644 --- a/gerbonara/graphic_objects.py +++ b/gerbonara/graphic_objects.py @@ -262,7 +262,8 @@ class Region(GraphicObject): def append(self, obj): if obj.unit != self.unit: - raise ValueError('Cannot append Polyline with "{obj.unit}" coords to Region with "{self.unit}" coords.') + obj = obj.converted(self.unit) + if not self.poly.outline: self.poly.outline.append(obj.p1) self.poly.outline.append(obj.p2) diff --git a/gerbonara/graphic_primitives.py b/gerbonara/graphic_primitives.py index 889aa92..4d81792 100644 --- a/gerbonara/graphic_primitives.py +++ b/gerbonara/graphic_primitives.py @@ -4,210 +4,70 @@ import itertools from dataclasses import dataclass, KW_ONLY, replace +from .utils import * + @dataclass class GraphicPrimitive: _ : KW_ONLY polarity_dark : bool = True + def bounding_box(self): + """ Return the axis-aligned bounding box of this feature. + + :returns: ``((min_x, min_Y), (max_x, max_y))`` + :rtype: tuple + """ -def rotate_point(x, y, angle, cx=0, cy=0): - """ rotate point (x,y) around (cx,cy) clockwise angle radians """ + raise NotImplementedError() - return (cx + (x - cx) * math.cos(-angle) - (y - cy) * math.sin(-angle), - cy + (x - cx) * math.sin(-angle) + (y - cy) * math.cos(-angle)) + def to_svg(self, fg='black', bg='white', tag=Tag): + """ Render this primitive into its SVG representation. -def min_none(a, b): - if a is None: - return b - if b is None: - return a - return min(a, b) + :param str fg: Foreground color. Must be an SVG color name. + :param str bg: Background color. Must be an SVG color name. + :param function tag: Tag constructor to use. -def max_none(a, b): - if a is None: - return b - if b is None: - return a - return max(a, b) + :rtype: str + """ -def add_bounds(b1, b2): - (min_x_1, min_y_1), (max_x_1, max_y_1) = b1 - (min_x_2, min_y_2), (max_x_2, max_y_2) = b2 - min_x, min_y = min_none(min_x_1, min_x_2), min_none(min_y_1, min_y_2) - max_x, max_y = max_none(max_x_1, max_x_2), max_none(max_y_1, max_y_2) - return ((min_x, min_y), (max_x, max_y)) + raise NotImplementedError() -def rad_to_deg(x): - return x/math.pi * 180 @dataclass class Circle(GraphicPrimitive): + #: Center X coordinate x : float + #: Center y coordinate y : float + #: Radius, not diameter like in :py:class:`.apertures.CircleAperture` r : float # Here, we use radius as common in modern computer graphics, not diameter as gerber uses. def bounding_box(self): return ((self.x-self.r, self.y-self.r), (self.x+self.r, self.y+self.r)) - def to_svg(self, tag, fg, bg): + def to_svg(self, fg='black', bg='white', tag=Tag): color = fg if self.polarity_dark else bg return tag('circle', cx=self.x, cy=self.y, r=self.r, style=f'fill: {color}') -@dataclass -class Obround(GraphicPrimitive): - x : float - y : float - w : float - h : float - rotation : float # radians! - - def to_line(self): - if self.w > self.h: - w, a, b = self.h, self.w-self.h, 0 - else: - w, a, b = self.w, 0, self.h-self.w - return Line( - *rotate_point(self.x-a/2, self.y-b/2, self.rotation, self.x, self.y), - *rotate_point(self.x+a/2, self.y+b/2, self.rotation, self.x, self.y), - w, polarity_dark=self.polarity_dark) - - def bounding_box(self): - return self.to_line().bounding_box() - - def to_svg(self, tag, fg, bg): - return self.to_line().to_svg(tag, fg, bg) - - -def arc_bounds(x1, y1, x2, y2, cx, cy, clockwise): - # This is one of these problems typical for computer geometry where out of nowhere a seemingly simple task just - # happens to be anything but in practice. - # - # Online there are a number of algorithms to be found solving this problem. Often, they solve the more general - # problem for elliptic arcs. We can keep things simple here since we only have circular arcs. - # - # 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. - - # Center arc on cx, cy - cx += x1 - cy += y1 - x1 -= cx - x2 -= cx - y1 -= cy - y2 -= cy - clockwise = bool(clockwise) # bool'ify for XOR/XNOR below - - # Calculate radius - r = math.sqrt(x1**2 + y1**2) - - # Calculate in which half-planes (north/south, west/east) P1 and P2 lie. - # Note that we assume the y axis points upwards, as in Gerber and maths. - # SVG has its y axis pointing downwards. - p1_west = x1 < 0 - p1_north = y1 > 0 - p2_west = x2 < 0 - p2_north = y2 > 0 - - # Calculate bounding box of P1 and P2 - min_x = min(x1, x2) - min_y = min(y1, y2) - max_x = max(x1, x2) - max_y = max(y1, y2) - - # North - # ^ - # | - # |(0,0) - # West <-----X-----> East - # | - # +Y | - # ^ v - # | South - # | - # +-----> +X - # - # Check whether the arc sweeps over any coordinate axes. If it does, add the intersection point to the bounding box. - # Note that, since this intersection point is at radius r, it has coordinate e.g. (0, r) for the north intersection. - # Since we know that the points lie on either side of the coordinate axis, the '0' coordinate of the intersection - # point will not change the bounding box in that axis--only its 'r' coordinate matters. We also know that the - # absolute value of that coordinate will be greater than or equal to the old coordinate in that direction since the - # intersection with the axis is the point where the full circle is tangent to the AABB. Thus, we can blindly set the - # corresponding coordinate of the bounding box without min()/max()'ing first. - - # Handle north/south halfplanes - if p1_west != p2_west: # arc starts in west half-plane, ends in east half-plane - if p1_west == clockwise: # arc is clockwise west -> east or counter-clockwise east -> west - max_y = r # add north to bounding box - else: # arc is counter-clockwise west -> east or clockwise east -> west - min_y = -r # south - else: # Arc starts and ends in same halfplane west/east - # Since both points are on the arc (at same radius) in one halfplane, we can use the y coord as a proxy for - # angle comparisons. - small_arc_is_north_to_south = y1 > y2 - small_arc_is_clockwise = small_arc_is_north_to_south == p1_west - if small_arc_is_clockwise != clockwise: - min_y, max_y = -r, r # intersect aabb with both north and south - - # Handle west/east halfplanes - if p1_north != p2_north: - if p1_north == clockwise: - max_x = r # east - else: - min_x = -r # west - else: - small_arc_is_west_to_east = x1 < x2 - small_arc_is_clockwise = small_arc_is_west_to_east == p1_north - if small_arc_is_clockwise != clockwise: - min_x, max_x = -r, r # intersect aabb with both north and south - - return (min_x+cx, min_y+cy), (max_x+cx, max_y+cy) - - -def point_line_distance(l1, l2, p): - # https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line - x1, y1 = l1 - x2, y2 = l2 - x0, y0 = p - length = math.dist(l1, l2) - if math.isclose(length, 0): - return math.dist(l1, p) - return ((x2-x1)*(y1-y0) - (x1-x0)*(y2-y1)) / length - -def svg_arc(old, new, center, clockwise): - r = math.hypot(*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] - # 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} {intermediate[0]:.6} {intermediate[1]:.6} ' +\ - f'A {r:.6} {r:.6} 0 1 {sweep_flag} {new[0]:.6} {new[1]:.6}' - - else: # normal case - d = point_line_distance(old, new, (old[0]+center[0], old[1]+center[1])) - large_arc = int((d < 0) == clockwise) - return f'A {r:.6} {r:.6} 0 {large_arc} {sweep_flag} {new[0]:.6} {new[1]:.6}' - @dataclass class ArcPoly(GraphicPrimitive): - """ Polygon whose sides may be either straight lines or circular arcs """ + """ Polygon whose sides may be either straight lines or circular arcs. """ - # list of (x : float, y : float) tuples. Describes closed outline, i.e. first and last point are considered - # connected. + #: list of (x : float, y : float) tuples. Describes closed outline, i.e. the first and last point are considered + #: connected. outline : list - # must be either None (all segments are straight lines) or same length as outline. - # Straight line segments have None entry. + #: Must be either None (all segments are straight lines) or same length as outline. + #: Straight line segments have None entry. arc_centers : list = None @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``. + """ ol = self.outline return itertools.zip_longest(ol, ol[1:] + [ol[0]], self.arc_centers or []) @@ -223,10 +83,24 @@ class ArcPoly(GraphicPrimitive): bbox = add_bounds(bbox, line_bounds) return bbox + @classmethod + def from_regular_polygon(kls, x:float, y:float, r:float, n:int, rotation:float=0, polarity_dark:bool=True): + """ Convert an n-sided gerber polygon to a normal ArcPoly defined by outline """ + + delta = 2*math.pi / self.n + + return kls([ + (self.x + math.cos(self.rotation + i*delta) * self.r, + self.y + math.sin(self.rotation + i*delta) * self.r) + for i in range(self.n) ], polarity_dark=polarity_dark) + def __len__(self): + """ Return the number of points on this polygon's outline (which is also the number of segments because the + polygon is closed). """ return len(self.outline) def __bool__(self): + """ Return ``True`` if this polygon has any outline points. """ return bool(len(self)) def _path_d(self): @@ -242,61 +116,44 @@ class ArcPoly(GraphicPrimitive): clockwise, center = arc yield svg_arc(old, new, center, clockwise) - def to_svg(self, tag, fg, bg): + def to_svg(self, fg='black', bg='white', tag=Tag): color = fg if self.polarity_dark else bg return tag('path', d=' '.join(self._path_d()), style=f'fill: {color}') -class Polyline: - def __init__(self, *lines): - self.coords = [] - self.polarity_dark = None - self.width = None - - for line in lines: - self.append(line) - - def append(self, line): - assert isinstance(line, Line) - if not self.coords: - self.coords.append((line.x1, line.y1)) - self.coords.append((line.x2, line.y2)) - self.polarity_dark = line.polarity_dark - self.width = line.width - return True - - else: - x, y = self.coords[-1] - if self.polarity_dark == line.polarity_dark and self.width == line.width \ - and math.isclose(line.x1, x) and math.isclose(line.y1, y): - self.coords.append((line.x2, line.y2)) - return True - - else: - return False - - def to_svg(self, tag, fg, bg): - color = fg if self.polarity_dark else bg - if not self.coords: - return None - - (x0, y0), *rest = self.coords - d = f'M {x0:.6} {y0:.6} ' + ' '.join(f'L {x:.6} {y:.6}' for x, y in rest) - width = f'{self.width:.6}' if not math.isclose(self.width, 0) else '0.01mm' - return tag('path', d=d, style=f'fill: none; stroke: {color}; stroke-width: {width}; stroke-linejoin: round; stroke-linecap: round') @dataclass class Line(GraphicPrimitive): + """ Straight line with round end caps. """ + #: Start X coordinate. As usual in modern graphics APIs, this is at the center of the half-circle capping off this + #: line. x1 : float + #: Start Y coordinate y1 : float + #: End X coordinate x2 : float + #: End Y coordinate y2 : float + #: Line width width : float + @classmethod + def from_obround(kls, x:float, y:float, w:float, h:float, rotation:float=0, polarity_dark:bool=True): + """ Convert a gerber obround into a :py:class:`~.graphic_primitives.Line`. """ + if self.w > self.h: + w, a, b = self.h, self.w-self.h, 0 + else: + w, a, b = self.w, 0, self.h-self.w + + return kls( + *rotate_point(self.x-a/2, self.y-b/2, self.rotation, self.x, self.y), + *rotate_point(self.x+a/2, self.y+b/2, self.rotation, self.x, self.y), + w, polarity_dark=self.polarity_dark) + def bounding_box(self): r = self.width / 2 return add_bounds(Circle(self.x1, self.y1, r).bounding_box(), Circle(self.x2, self.y2, r).bounding_box()) - def to_svg(self, tag, fg, bg): + def to_svg(self, fg='black', bg='white', tag=Tag): color = fg if self.polarity_dark else bg width = f'{self.width:.6}' if not math.isclose(self.width, 0) else '0.01mm' return tag('path', d=f'M {self.x1:.6} {self.y1:.6} L {self.x2:.6} {self.y2:.6}', @@ -304,14 +161,23 @@ class Line(GraphicPrimitive): @dataclass class Arc(GraphicPrimitive): + """ Circular arc with line width ``width`` going from ``(x1, y1)`` to ``(x2, y2)`` around center at ``(cx, cy)``. """ + #: Start X coodinate x1 : float + #: Start Y coodinate y1 : float + #: End X coodinate x2 : float + #: End Y coodinate y2 : float - # absolute coordinates + #: Center X coordinate relative to ``x1`` cx : float + #: Center Y coordinate relative to ``y1`` 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 clockwise : bool + #: Line width of this arc. width : float def bounding_box(self): @@ -333,24 +199,25 @@ class Arc(GraphicPrimitive): arc = arc_bounds(x1, y1, x2, y2, self.cx, self.cy, self.clockwise) return add_bounds(endpoints, arc) # FIXME add "include_center" switch - def to_svg(self, tag, fg, bg): + def to_svg(self, fg='black', bg='white', tag=Tag): color = fg if self.polarity_dark else bg arc = svg_arc((self.x1, self.y1), (self.x2, self.y2), (self.cx, self.cy), self.clockwise) width = f'{self.width:.6}' if not math.isclose(self.width, 0) else '0.01mm' return tag('path', d=f'M {self.x1:.6} {self.y1:.6} {arc}', style=f'fill: none; stroke: {color}; stroke-width: {width}; stroke-linecap: round; fill: none') -def svg_rotation(angle_rad, cx=0, cy=0): - return f'rotate({float(rad_to_deg(angle_rad)):.4} {float(cx):.6} {float(cy):.6})' - @dataclass class Rectangle(GraphicPrimitive): - # coordinates are center coordinates + #: **Center** X coordinate x : float + #: **Center** Y coordinate y : float + #: width w : float + #: height h : float - rotation : float # radians, around center! + #: rotation around center in radians + rotation : float def bounding_box(self): return self.to_arc_poly().bounding_box() @@ -367,37 +234,9 @@ class Rectangle(GraphicPrimitive): (x + (cw+sh), y - (ch+sw)), ]) - @property - def center(self): - return self.x + self.w/2, self.y + self.h/2 - - def to_svg(self, tag, fg, bg): + def to_svg(self, fg='black', bg='white', tag=Tag): color = fg if self.polarity_dark else bg x, y = self.x - self.w/2, self.y - self.h/2 return tag('rect', x=x, y=y, width=self.w, height=self.h, transform=svg_rotation(self.rotation, self.x, self.y), style=f'fill: {color}') -@dataclass -class RegularPolygon(GraphicPrimitive): - x : float - y : float - r : float - n : int - rotation : float # radians! - - def to_arc_poly(self): - ''' convert n-sided gerber polygon to normal ArcPoly defined by outline ''' - - delta = 2*math.pi / self.n - - return ArcPoly([ - (self.x + math.cos(self.rotation + i*delta) * self.r, - self.y + math.sin(self.rotation + i*delta) * self.r) - for i in range(self.n) ]) - - def bounding_box(self): - return self.to_arc_poly().bounding_box() - - def to_svg(self, tag, fg, bg): - return self.to_arc_poly().to_svg(tag, fg, bg) - diff --git a/gerbonara/rs274x.py b/gerbonara/rs274x.py index 837440f..dbda955 100644 --- a/gerbonara/rs274x.py +++ b/gerbonara/rs274x.py @@ -24,12 +24,10 @@ import re import math import warnings from pathlib import Path -from itertools import count, chain -from io import StringIO import dataclasses from .cam import CamFile, FileSettings -from .utils import sq_distance, rotate_point, MM, Inch, units, InterpMode, UnknownStatementWarning +from .utils import MM, Inch, units, InterpMode, UnknownStatementWarning from .aperture_macros.parse import ApertureMacro, GenericMacros from . import graphic_primitives as gp from . import graphic_objects as go diff --git a/gerbonara/utils.py b/gerbonara/utils.py index 71251de..d1f9d61 100644 --- a/gerbonara/utils.py +++ b/gerbonara/utils.py @@ -30,9 +30,11 @@ from enum import Enum from math import radians, sin, cos, sqrt, atan2, pi class UnknownStatementWarning(Warning): + """ Gerbonara found an unknown Gerber or Excellon statement. """ pass class RegexMatcher: + """ Internal parsing helper """ def __init__(self): self.mapping = {} @@ -51,13 +53,27 @@ class RegexMatcher: else: return False + class LengthUnit: + """ Convenience length unit class. Used in :py:class:`.GraphicObject` and :py:class:`.Aperture` to store lenght + information. Provides a number of useful unit conversion functions. + + Singleton, use only global instances ``utils.MM`` and ``utils.Inch``. + """ + def __init__(self, name, shorthand, this_in_mm): self.name = name self.shorthand = shorthand self.factor = this_in_mm def convert_from(self, unit, value): + """ Convert ``value`` from ``unit`` into this unit. + + :param unit: ``MM``, ``Inch`` or one of the strings ``"mm"`` or ``"inch"`` + :param float value: + :rtype: float + """ + if isinstance(unit, str): unit = units[unit] @@ -67,6 +83,8 @@ class LengthUnit: return value * unit.factor / self.factor def convert_to(self, unit, value): + """ :py:meth:`.LengthUnit.convert_from` but in reverse. """ + if isinstance(unit, str): unit = to_unit(unit) @@ -76,9 +94,17 @@ class LengthUnit: return unit.convert_from(self, value) def format(self, value): + """ Return a human-readdable string representing value in this unit. + + :param float value: + :returns: something like "3mm" + :rtype: str + """ + return f'{value:.3f}{self.shorthand}' if value is not None else '' def __call__(self, value, unit): + """ Convenience alias for :py:meth:`.LengthUnit.convert_from` """ return self.convert_from(unit, value) def __eq__(self, other): @@ -105,12 +131,41 @@ MILLIMETERS_PER_INCH = 25.4 Inch = LengthUnit('inch', 'in', MILLIMETERS_PER_INCH) MM = LengthUnit('millimeter', 'mm', 1) units = {'inch': Inch, 'mm': MM, None: None} -to_unit = lambda name: units[name.lower() if name else None] + +def _raise_error(*args, **kwargs): + raise SystemError('LengthUnit is a singleton. Use gerbonara.utils.MM or gerbonara.utils.Inch. Please do not invent ' + 'your own length units, the imperial system is already messed up enough.') +LengthUnit.__init__ = _raise_error + +def to_unit(name): + """ Convert string ``name`` into a registered length unit. Returns ``None`` if the argument cannot be converted. + + :param str name: ``'mm'`` or ``'inch'`` + :returns: ``MM``, ``Inch`` or ``None`` + :rtype: :py:class:`.LengthUnit` or ``None`` + """ + + if name is None: + return None + + if isinstance(name, LengthUnit): + return name + + if isinstance(name, str): + name = name.lower() + if name in units: + return units[name] + + raise ValueError(f'Invalid unit {name!r}. Should be either "mm", "inch" or None for no unit.') class InterpMode(Enum): + """ Gerber / Excellon interpolation mode. """ + #: straight line LINEAR = 0 + #: clockwise circular arc CIRCULAR_CW = 1 + #: counterclockwise circular arc CIRCULAR_CCW = 2 @@ -151,56 +206,53 @@ def decimal_string(value, precision=6, padding=False): else: return int(floatstr) -def validate_coordinates(position): - if position is not None: - if len(position) != 2: - raise TypeError('Position must be a tuple (n=2) of coordinates') - else: - for coord in position: - if not (isinstance(coord, int) or isinstance(coord, float)): - raise TypeError('Coordinates must be integers or floats') -def rotate_point(point, angle, center=(0.0, 0.0)): - """ Rotate a point about another point. +def rotate_point(x, y, angle, cx=0, cy=0): + """ Rotate point (x,y) around (cx,cy) by ``angle`` radians clockwise. """ - Parameters - ----------- - point : tuple(, ) - Point to rotate about origin or center point + return (cx + (x - cx) * math.cos(-angle) - (y - cy) * math.sin(-angle), + cy + (x - cx) * math.sin(-angle) + (y - cy) * math.cos(-angle)) - angle : float - Angle to rotate the point [degrees] - center : tuple(, ) - Coordinates about which the point is rotated. Defaults to the origin. +def min_none(a, b): + """ Like the ``min(..)`` builtin, but if either value is ``None``, returns the other. """ + if a is None: + return b + if b is None: + return a + return min(a, b) - Returns - ------- - rotated_point : tuple(, ) - `point` rotated about `center` by `angle` degrees. - """ - angle = radians(angle) - cos_angle = cos(angle) - sin_angle = sin(angle) +def max_none(a, b): + """ Like the ``max(..)`` builtin, but if either value is ``None``, returns the other. """ + if a is None: + return b + if b is None: + return a + return max(a, b) - return ( - cos_angle * (point[0] - center[0]) - sin_angle * (point[1] - center[1]) + center[0], - sin_angle * (point[0] - center[0]) + cos_angle * (point[1] - center[1]) + center[1]) -def nearly_equal(point1, point2, ndigits = 6): - '''Are the points nearly equal''' +def add_bounds(b1, b2): + """ Add/union two bounding boxes. - return round(point1[0] - point2[0], ndigits) == 0 and round(point1[1] - point2[1], ndigits) == 0 + :param tuple b1: ``((min_x, min_y), (max_x, max_y))`` + :param tuple b2: ``((min_x, min_y), (max_x, max_y))`` + :returns: ``((min_x, min_y), (max_x, max_y))`` + :rtype: tuple + """ -def sq_distance(point1, point2): + (min_x_1, min_y_1), (max_x_1, max_y_1) = b1 + (min_x_2, min_y_2), (max_x_2, max_y_2) = b2 + min_x, min_y = min_none(min_x_1, min_x_2), min_none(min_y_1, min_y_2) + max_x, max_y = max_none(max_x_1, max_x_2), max_none(max_y_1, max_y_2) + return ((min_x, min_y), (max_x, max_y)) - diff1 = point1[0] - point2[0] - diff2 = point1[1] - point2[1] - return diff1 * diff1 + diff2 * diff2 class Tag: + """ Helper class to ease creation of SVG. All API functions that create SVG allow you to substitute this with your + own implementation by passing a ``tag`` parameter. """ + def __init__(self, name, children=None, root=False, **attrs): self.name, self.attrs = name, attrs self.children = children or [] @@ -216,3 +268,133 @@ class Tag: return f'{prefix}<{opening}/>' +def arc_bounds(x1, y1, x2, y2, cx, cy, clockwise): + """ Calculate bounding box of a circular arc given in Gerber notation (i.e. with center relative to first point). + + :returns: ``((x_min, y_min), (x_max, y_max))`` + """ + # This is one of these problems typical for computer geometry where out of nowhere a seemingly simple task just + # happens to be anything but in practice. + # + # Online there are a number of algorithms to be found solving this problem. Often, they solve the more general + # problem for elliptic arcs. We can keep things simple here since we only have circular arcs. + # + # 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. + + # Center arc on cx, cy + cx += x1 + cy += y1 + x1 -= cx + x2 -= cx + y1 -= cy + y2 -= cy + clockwise = bool(clockwise) # bool'ify for XOR/XNOR below + + # Calculate radius + r = math.sqrt(x1**2 + y1**2) + + # Calculate in which half-planes (north/south, west/east) P1 and P2 lie. + # Note that we assume the y axis points upwards, as in Gerber and maths. + # SVG has its y axis pointing downwards. + p1_west = x1 < 0 + p1_north = y1 > 0 + p2_west = x2 < 0 + p2_north = y2 > 0 + + # Calculate bounding box of P1 and P2 + min_x = min(x1, x2) + min_y = min(y1, y2) + max_x = max(x1, x2) + max_y = max(y1, y2) + + # North + # ^ + # | + # |(0,0) + # West <-----X-----> East + # | + # +Y | + # ^ v + # | South + # | + # +-----> +X + # + # Check whether the arc sweeps over any coordinate axes. If it does, add the intersection point to the bounding box. + # Note that, since this intersection point is at radius r, it has coordinate e.g. (0, r) for the north intersection. + # Since we know that the points lie on either side of the coordinate axis, the '0' coordinate of the intersection + # point will not change the bounding box in that axis--only its 'r' coordinate matters. We also know that the + # absolute value of that coordinate will be greater than or equal to the old coordinate in that direction since the + # intersection with the axis is the point where the full circle is tangent to the AABB. Thus, we can blindly set the + # corresponding coordinate of the bounding box without min()/max()'ing first. + + # Handle north/south halfplanes + if p1_west != p2_west: # arc starts in west half-plane, ends in east half-plane + if p1_west == clockwise: # arc is clockwise west -> east or counter-clockwise east -> west + max_y = r # add north to bounding box + else: # arc is counter-clockwise west -> east or clockwise east -> west + min_y = -r # south + else: # Arc starts and ends in same halfplane west/east + # Since both points are on the arc (at same radius) in one halfplane, we can use the y coord as a proxy for + # angle comparisons. + small_arc_is_north_to_south = y1 > y2 + small_arc_is_clockwise = small_arc_is_north_to_south == p1_west + if small_arc_is_clockwise != clockwise: + min_y, max_y = -r, r # intersect aabb with both north and south + + # Handle west/east halfplanes + if p1_north != p2_north: + if p1_north == clockwise: + max_x = r # east + else: + min_x = -r # west + else: + small_arc_is_west_to_east = x1 < x2 + small_arc_is_clockwise = small_arc_is_west_to_east == p1_north + if small_arc_is_clockwise != clockwise: + min_x, max_x = -r, r # intersect aabb with both north and south + + return (min_x+cx, min_y+cy), (max_x+cx, max_y+cy) + + +def point_line_distance(l1, l2, p): + """ Calculate distance between infinite line through l1 and l2, and point p. """ + # https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line + x1, y1 = l1 + x2, y2 = l2 + x0, y0 = p + length = math.dist(l1, l2) + if math.isclose(length, 0): + return math.dist(l1, p) + return ((x2-x1)*(y1-y0) - (x1-x0)*(y2-y1)) / length + + +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). + + :rtype: str + """ + r = math.hypot(*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] + # 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} {intermediate[0]:.6} {intermediate[1]:.6} ' +\ + f'A {r:.6} {r:.6} 0 1 {sweep_flag} {new[0]:.6} {new[1]:.6}' + + else: # normal case + d = point_line_distance(old, new, (old[0]+center[0], old[1]+center[1])) + large_arc = int((d < 0) == clockwise) + return f'A {r:.6} {r:.6} 0 {large_arc} {sweep_flag} {new[0]:.6} {new[1]:.6}' + + +def svg_rotation(angle_rad, cx=0, cy=0): + return f'rotate({float(math.degrees(angle_rad)):.4} {float(cx):.6} {float(cy):.6})' + -- cgit