From c3ca4f95bd59f69d45e582a4149327f57a360760 Mon Sep 17 00:00:00 2001 From: jaseg Date: Sun, 30 Jan 2022 20:11:38 +0100 Subject: Rename gerbonara/gerber package to just gerbonara --- gerbonara/graphic_primitives.py | 403 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 403 insertions(+) create mode 100644 gerbonara/graphic_primitives.py (limited to 'gerbonara/graphic_primitives.py') diff --git a/gerbonara/graphic_primitives.py b/gerbonara/graphic_primitives.py new file mode 100644 index 0000000..65aa28c --- /dev/null +++ b/gerbonara/graphic_primitives.py @@ -0,0 +1,403 @@ + +import math +import itertools + +from dataclasses import dataclass, KW_ONLY, replace + + +@dataclass +class GraphicPrimitive: + _ : KW_ONLY + polarity_dark : bool = True + + +def rotate_point(x, y, angle, cx=0, cy=0): + """ rotate point (x,y) around (cx,cy) clockwise angle radians """ + + return (cx + (x - cx) * math.cos(-angle) - (y - cy) * math.sin(-angle), + cy + (x - cx) * math.sin(-angle) + (y - cy) * math.cos(-angle)) + +def min_none(a, b): + if a is None: + return b + if b is None: + return a + return min(a, b) + +def max_none(a, b): + if a is None: + return b + if b is None: + return a + return max(a, b) + +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)) + +def rad_to_deg(x): + return x/math.pi * 180 + +@dataclass +class Circle(GraphicPrimitive): + x : float + y : float + 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): + 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 """ + + # list of (x : float, y : float) tuples. Describes closed outline, i.e. first and last point are considered + # connected. + outline : [(float,)] + # must be either None (all segments are straight lines) or same length as outline. + # Straight line segments have None entry. + arc_centers : [(float,)] = None + + @property + def segments(self): + 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 + + def __len__(self): + return len(self.outline) + + def __bool__(self): + 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, tag, fg, bg): + 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): + x1 : float + y1 : float + x2 : float + y2 : float + width : float + + 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): + 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): + x1 : float + y1 : float + x2 : float + y2 : float + # absolute coordinates + cx : float + cy : float + clockwise : bool + 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, tag, fg, bg): + 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 + x : float + y : float + w : float + h : float + rotation : float # radians, around center! + + 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)), + ]) + + @property + def center(self): + return self.x + self.w/2, self.y + self.h/2 + + def to_svg(self, tag, fg, bg): + 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) + -- cgit