import math 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 """ raise NotImplementedError() def to_svg(self, fg='black', bg='white', tag=Tag): """ Render this primitive into its SVG representation. :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. :rtype: str """ raise NotImplementedError() @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, 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 ArcPoly(GraphicPrimitive): """ Polygon whose sides may be either straight lines or circular arcs. """ #: 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. 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 []) 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: line_bounds = (min(x1, x2), min(y1, y2)), (max(x1, x2), max(y1, y2)) 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): if len(self.outline) == 0: return yield f'M {self.outline[0][0]:.6} {self.outline[0][1]:.6}' for old, new, arc in self.segments: if not arc: yield f'L {new[0]:.6} {new[1]:.6}' else: clockwise, center = arc yield svg_arc(old, new, center, clockwise) 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}') @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, 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}', style=f'fill: none; stroke: {color}; stroke-width: {width}; stroke-linecap: round') @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 #: 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): r = self.width/2 endpoints = add_bounds(Circle(self.x1, self.y1, r).bounding_box(), Circle(self.x2, self.y2, r).bounding_box()) arc_r = math.dist((self.cx, self.cy), (self.x1, self.y1)) # extend C -> P1 line by line width / 2 along radius dx, dy = self.x1 - self.cx, self.y1 - self.cy x1 = self.x1 + dx/arc_r * r y1 = self.y1 + dy/arc_r * r # same for C -> P2 dx, dy = self.x2 - self.cx, self.y2 - self.cy x2 = self.x2 + dx/arc_r * r y2 = self.y2 + dy/arc_r * r 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, 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') @dataclass class Rectangle(GraphicPrimitive): #: **Center** X coordinate x : float #: **Center** Y coordinate y : float #: width w : float #: height h : float #: rotation around center in radians rotation : float def bounding_box(self): return self.to_arc_poly().bounding_box() def to_arc_poly(self): sin, cos = math.sin(self.rotation), math.cos(self.rotation) sw, cw = sin*self.w/2, cos*self.w/2 sh, ch = sin*self.h/2, cos*self.h/2 x, y = self.x, self.y return ArcPoly([ (x - (cw+sh), y - (ch+sw)), (x - (cw+sh), y + (ch+sw)), (x + (cw+sh), y + (ch+sw)), (x + (cw+sh), y - (ch+sw)), ]) 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}')