From 5885b60f14c35a65b67071a439a53aaacf39b594 Mon Sep 17 00:00:00 2001 From: jaseg Date: Wed, 5 Jan 2022 12:43:34 +0100 Subject: Add a bunch of 2d to_poly / bounding_box functions (untested) --- gerbonara/gerber/apertures.py | 49 +++---- gerbonara/gerber/graphic_objects.py | 148 ++++++++++++------- gerbonara/gerber/graphic_primitives.py | 259 ++++++++++++++++++++++++++++----- gerbonara/gerber/rs274x.py | 60 +++++--- 4 files changed, 384 insertions(+), 132 deletions(-) diff --git a/gerbonara/gerber/apertures.py b/gerbonara/gerber/apertures.py index 7c98775..104b021 100644 --- a/gerbonara/gerber/apertures.py +++ b/gerbonara/gerber/apertures.py @@ -7,9 +7,12 @@ from .aperture_macros.parse import GenericMacros from . import graphic_primitives as gp -def _flash_hole(self, x, y): +def _flash_hole(self, x, y, unit=None): if self.hole_rect_h is not None: - return self.primitives(x, y), Rectangle((x, y), (self.hole_dia, self.hole_rect_h), rotation=self.rotation, polarity_dark=False) + return [*self.primitives(x, y, unit), + Rectangle((x, y), + (self.convert(self.hole_dia, unit), self.convert(self.hole_rect_h, unit)), + rotation=self.rotation, polarity_dark=False)] else: return self.primitives(x, y), Circle((x, y), self.hole_dia, polarity_dark=False) @@ -71,11 +74,10 @@ class Aperture: return out - def flash(self, x, y): - return self.primitives(x, y) + def flash(self, x, y, unit=None): + return self.primitives(x, y, unit) - @property - def equivalent_width(self): + def equivalent_width(self, unit=None): raise ValueError('Non-circular aperture used in interpolation statement, line width is not properly defined.') def to_gerber(self, settings=None): @@ -108,17 +110,16 @@ class CircleAperture(Aperture): hole_rect_h : Length(float) = None rotation : float = 0 # radians; for rectangular hole; see hack in Aperture.to_gerber - def primitives(self, x, y, rotation): - return [ gp.Circle(x, y, self.diameter/2) ] + def primitives(self, x, y, unit=None): + return [ gp.Circle(x, y, self.convert(self.diameter/2, unit)) ] def __str__(self): return f'' flash = _flash_hole - @property - def equivalent_width(self): - return self.diameter + def equivalent_width(self, unit=None): + return self.convert(self.diameter, unit) def dilated(self, offset, unit='mm'): offset = self.convert_from(offset, unit) @@ -150,17 +151,16 @@ class RectangleAperture(Aperture): hole_rect_h : Length(float) = None rotation : float = 0 # radians - def primitives(self, x, y): - return [ gp.Rectangle(x, y, self.w, self.h, rotation=self.rotation) ] + def primitives(self, x, y, unit=None): + return [ gp.Rectangle(x, y, self.convert(self.w, unit), self.convert(self.h, unit), rotation=self.rotation) ] def __str__(self): return f'' flash = _flash_hole - @property - def equivalent_width(self): - return math.sqrt(self.w**2 + self.h**2) + def equivalent_width(self, unit=None): + return self.convert(math.sqrt(self.w**2 + self.h**2), unit) def dilated(self, offset, unit='mm'): offset = self.convert_from(offset, unit) @@ -200,8 +200,8 @@ class ObroundAperture(Aperture): hole_rect_h : Length(float) = None rotation : float = 0 - def primitives(self, x, y): - return [ gp.Obround(x, y, self.w, self.h, rotation=self.rotation) ] + def primitives(self, x, y, unit=None): + return [ gp.Obround(x, y, self.convert(self.w, unit), self.convert(self.h, unit), rotation=self.rotation) ] def __str__(self): return f'' @@ -246,8 +246,8 @@ class PolygonAperture(Aperture): rotation : float = 0 hole_dia : Length(float) = None - def primitives(self, x, y): - return [ gp.RegularPolygon(x, y, diameter, n_vertices, rotation=self.rotation) ] + def primitives(self, x, y, unit=None): + return [ gp.RegularPolygon(x, y, self.convert(diameter, unit), n_vertices, rotation=self.rotation) ] def __str__(self): return f'<{self.n_vertices}-gon aperture d={self.diameter:.3}' @@ -279,16 +279,13 @@ class ApertureMacroInstance(Aperture): parameters : [float] rotation : float = 0 - def __post__init__(self, macro): - self._primitives = macro.to_graphic_primitives(parameters) - @property def gerber_shape_code(self): return self.macro.name - def primitives(self, x, y): - # FIXME return graphical primitives not macro primitives here - return [ primitive.with_offset(x, y).rotated(self.rotation, cx=0, cy=0) for primitive in self._primitives ] + def primitives(self, x, y, unit=None): + return [ primitive.with_offset(x, y).rotated(self.rotation, cx=0, cy=0) + for primitive in self.macro.to_graphic_primitives(self.parameters, unit=unit) ] def dilated(self, offset, unit='mm'): return replace(self, macro=self.macro.dilated(offset, unit)) diff --git a/gerbonara/gerber/graphic_objects.py b/gerbonara/gerber/graphic_objects.py index 5c523d5..d9736cf 100644 --- a/gerbonara/gerber/graphic_objects.py +++ b/gerbonara/gerber/graphic_objects.py @@ -5,29 +5,69 @@ from dataclasses import dataclass, KW_ONLY, astuple, replace from . import graphic_primitives as gp from .gerber_statements import * + +def convert(value, src, dst): + if src == dst or src is None or dst is None or value is None: + return value + elif dst == 'mm': + return value * 25.4 + else: + return value / 25.4 + +class Length: + def __init__(self, obj_type): + self.type = obj_type + @dataclass class GerberObject: _ : KW_ONLY polarity_dark : bool = True unit : str = None - def to_primitives(self): + def converted(self, unit): + return replace(self, + **{ + f.name: convert(getattr(self, f.name), self.unit, unit) + for f in fields(self) + }) + + def _conv(self, value, unit): + return convert(value, src=unit, dst=self.unit) + + def with_offset(self, dx, dy, unit='mm'): + dx, dy = self._conv(dx, unit), self._conv(dy, unit) + return self._with_offset(dx, dy) + + def rotate(self, rotation, cx=0, cy=0, unit='mm'): + cx, cy = self._conv(cx, unit), self._conv(cy, unit) + return self._rotate(cx, cy) + + def bounding_box(self, unit=None): + bboxes = [ p.bounding_box for p in self.to_primitives(unit) ] + min_x = min(min_x for (min_x, _min_y), _ in bboxes) + min_y = min(min_y for (_min_x, min_y), _ in bboxes) + max_x = max(max_x for _, (max_x, _max_y) in bboxes) + max_y = max(max_y for _, (_max_x, max_y) in bboxes) + return ((min_x, min_y), (max_x, max_y)) + + def to_primitives(self, unit=None): raise NotImplementedError() @dataclass class Flash(GerberObject): - x : float - y : float + x : Length(float) + y : Length(float) aperture : object - def with_offset(self, dx, dy): + def _with_offset(self, dx, dy): return replace(self, x=self.x+dx, y=self.y+dy) - def rotate(self, rotation, cx=0, cy=0): + def _rotate(self, rotation, cx=0, cy=0): self.x, self.y = gp.rotate_point(self.x, self.y, rotation, cx, cy) - def to_primitives(self): - yield from self.aperture.flash(self.x, self.y) + def to_primitives(self, unit=None): + conv = self.converted(unit) + yield from self.aperture.flash(conv.x, conv.y, unit) def to_statements(self, gs): yield from gs.set_polarity(self.polarity_dark) @@ -48,31 +88,33 @@ class Region(GerberObject): def __bool__(self): return bool(self.poly) - def with_offset(self, dx, dy): + def _with_offset(self, dx, dy): return Region([ (x+dx, y+dy) for x, y in self.poly.outline ], self.poly.arc_centers, polarity_dark=self.polarity_dark, unit=self.unit) - def rotate(self, angle, cx=0, cy=0): + def _rotate(self, angle, cx=0, cy=0): self.poly.outline = [ gp.rotate_point(x, y, angle, cx, cy) for x, y in self.poly.outline ] self.poly.arc_centers = [ - gp.rotate_point(*center, angle, cx, cy) if center else None - for center in self.poly.arc_centers ] + (arc[0], gp.rotate_point(*arc[1], angle, cx, cy)) if arc else None + for arc in self.poly.arc_centers ] def append(self, obj): + if obj.unit != self.unit: + raise ValueError('Cannot append Polyline with "{obj.unit}" coords to Region with "{self.unit}" coords.') if not self.poly.outline: self.poly.outline.append(obj.p1) self.poly.outline.append(obj.p2) if isinstance(obj, Arc): - self.poly.arc_centers.append(obj.center) + self.poly.arc_centers.append((obj.clockwise, obj.center)) else: self.poly.arc_centers.append(None) - def to_primitives(self): + def to_primitives(self, unit=None): self.poly.polarity_dark = polarity_dark - yield self.poly + yield self.poly.converted(unit) def to_statements(self, gs): yield from gs.set_polarity(self.polarity_dark) @@ -87,9 +129,9 @@ class Region(GerberObject): gs.update_point(*point, unit=self.unit) else: - cx, cy = arc_center + clockwise, (cx, cy) = arc_center x2, y2 = point - yield from gs.set_interpolation_mode(CircularCCWModeStmt) + yield from gs.set_interpolation_mode(CircularCWModeStmt if clockwise else CircularCCWModeStmt) yield InterpolateStmt(x2, y2, cx-x2, cy-y2, unit=self.unit) gs.update_point(x2, y2, unit=self.unit) @@ -99,16 +141,16 @@ class Region(GerberObject): @dataclass class Line(GerberObject): # Line with *round* end caps. - x1 : float - y1 : float - x2 : float - y2 : float + x1 : Length(float) + y1 : Length(float) + x2 : Length(float) + y2 : Length(float) aperture : object - def with_offset(self, dx, dy): + def _with_offset(self, dx, dy): return replace(self, x1=self.x1+dx, y1=self.y1+dy, x2=self.x2+dx, y2=self.y2+dy) - def rotate(self, rotation, cx=0, cy=0): + def _rotate(self, rotation, cx=0, cy=0): self.x1, self.y1 = gp.rotate_point(self.x1, self.y1, rotation, cx, cy) self.x2, self.y2 = gp.rotate_point(self.x2, self.y2, rotation, cx, cy) @@ -120,8 +162,9 @@ class Line(GerberObject): def p2(self): return self.x2, self.y2 - def to_primitives(self): - yield gp.Line(*self.p1, *self.p2, self.aperture.equivalent_width, polarity_dark=self.polarity_dark) + def to_primitives(self, unit=None): + conv = self.converted(unit) + yield gp.Line(*conv.p1, *conv.p2, self.aperture.equivalent_width(unit), polarity_dark=self.polarity_dark) def to_statements(self, gs): yield from gs.set_polarity(self.polarity_dark) @@ -134,32 +177,33 @@ class Line(GerberObject): @dataclass class Drill(GerberObject): - x : float - y : float - diameter : float + x : Length(float) + y : Length(float) + diameter : Length(float) - def with_offset(self, dx, dy): + def _with_offset(self, dx, dy): return replace(self, x=self.x+dx, y=self.y+dy) - def rotate(self, angle, cx=0, cy=0): + def _rotate(self, angle, cx=0, cy=0): self.x, self.y = gp.rotate_point(self.x, self.y, angle, cx, cy) - def to_primitives(self): - yield gp.Circle(self.x, self.y, self.diameter/2) + def to_primitives(self, unit=None): + conv = self.converted(unit) + yield gp.Circle(conv.x, conv.y, conv.diameter/2) @dataclass class Slot(GerberObject): - x1 : float - y1 : float - x2 : float - y2 : float - width : float + x1 : Length(float) + y1 : Length(float) + x2 : Length(float) + y2 : Length(float) + width : Length(float) - def with_offset(self, dx, dy): + def _with_offset(self, dx, dy): return replace(self, x1=self.x1+dx, y1=self.y1+dy, x2=self.x2+dx, y2=self.y2+dy) - def rotate(self, rotation, cx=0, cy=0): + def _rotate(self, rotation, cx=0, cy=0): if cx is None: cx = (self.x1 + self.x2) / 2 cy = (self.y1 + self.y2) / 2 @@ -174,22 +218,23 @@ class Slot(GerberObject): def p2(self): return self.x2, self.y2 - def to_primitives(self): - yield gp.Line(*self.p1, *self.p2, self.width, polarity_dark=self.polarity_dark) + def to_primitives(self, unit=None): + conv = self.converted(unit) + yield gp.Line(*conv.p1, *conv.p2, conv.width, polarity_dark=self.polarity_dark) @dataclass class Arc(GerberObject): - x1 : float - y1 : float - x2 : float - y2 : float - cx : float - cy : float - flipped : bool + x1 : Length(float) + y1 : Length(float) + x2 : Length(float) + y2 : Length(float) + cx : Length(float) + cy : Length(float) + clockwise : bool aperture : object - def with_offset(self, dx, dy): + def _with_offset(self, dx, dy): return replace(self, x1=self.x1+dx, y1=self.y1+dy, x2=self.x2+dx, y2=self.y2+dy) @property @@ -204,15 +249,16 @@ class Arc(GerberObject): def center(self): return self.cx + self.x1, self.cy + self.y1 - def rotate(self, rotation, cx=0, cy=0): + def _rotate(self, rotation, cx=0, cy=0): # rotate center first since we need old x1, y1 here new_cx, new_cy = gp.rotate_point(*self.center, rotation, cx, cy) self.x1, self.y1 = gp.rotate_point(self.x1, self.y1, rotation, cx, cy) self.x2, self.y2 = gp.rotate_point(self.x2, self.y2, rotation, cx, cy) self.cx, self.cy = new_cx - self.x1, new_cy - self.y1 - def to_primitives(self): - yield gp.Arc(*astuple(self)[:7], width=self.aperture.equivalent_width, polarity_dark=self.polarity_dark) + def to_primitives(self, unit=None): + conv = self.converted(unit) + yield gp.Arc(*astuple(conv)[:7], width=self.aperture.equivalent_width(unit), polarity_dark=self.polarity_dark) def to_statements(self, gs): yield from gs.set_polarity(self.polarity_dark) diff --git a/gerbonara/gerber/graphic_primitives.py b/gerbonara/gerber/graphic_primitives.py index 966cac1..3052322 100644 --- a/gerbonara/gerber/graphic_primitives.py +++ b/gerbonara/gerber/graphic_primitives.py @@ -10,7 +10,6 @@ from .gerber_statements import * class GraphicPrimitive: _ : KW_ONLY polarity_dark : bool = True - unit : str = None def rotate_point(x, y, angle, cx=0, cy=0): @@ -19,6 +18,26 @@ def rotate_point(x, y, angle, cx=0, cy=0): 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)) @dataclass class Circle(GraphicPrimitive): @@ -26,9 +45,12 @@ class Circle(GraphicPrimitive): y : float r : float # Here, we use radius as common in modern computer graphics, not diameter as gerber uses. - def bounds(self): + 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): + return 'circle', (), dict(cx=x, cy=y, r=r) + @dataclass class Obround(GraphicPrimitive): @@ -38,30 +60,121 @@ class Obround(GraphicPrimitive): h : float rotation : float # radians! - def decompose(self): - ''' decompose obround to two circles and one rectangle ''' - - cx = self.x + self.w/2 - cy = self.y + self.h/2 - + def to_line(self): if self.w > self.h: - x = self.x + self.h/2 - yield Circle(x, cy, self.h/2) - yield Circle(x + self.w, cy, self.h/2) - yield Rectangle(x, self.y, self.w - self.h, self.h) + w, a, b = self.h, self.w, 0 + else: + w, a, b = self.w, 0, self.h + 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) + + def bounding_box(self): + return self.to_line().bounding_box() + + def to_svg(self): + return self.to_line().to_svg() + + +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). + + # Center arc on cx, cy + 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 - elif self.h > self.w: - y = self.y + self.w/2 - yield Circle(cx, y, self.w/2) - yield Circle(cx, y + self.h, self.w/2) - yield Rectangle(self.x, y, self.w, self.h - self.w) + return (min_x+cx, min_y+cy), (max_x+cx, max_y+cy) - else: - yield Circle(cx, cy, self.w/2) - def bounds(self): - return ((self.x-self.w/2, self.y-self.h/2), (self.x+self.w/2, self.y+self.h/2)) +def point_distance(a, b): + return math.sqrt((b[0] - a[0])**2 + (b[1] - a[1])**2) +def point_line_distance(l1, l2, p): + x1, y1 = l1 + x2, y2 = l2 + x0, y0 = p + return abs((x2-x1)*(y1-y0) - (x1-x0)*(y2-y1))/point_distance(l1, l2) + +def svg_arc(old, new, center, clockwise): + r = point_distance(old, new) + d = point_line_distance(old, new, center) + sweep_flag = int(clockwise) + large_arc = int((d > 0) == clockwise) # FIXME check signs + return f'A {r:.6} {r:.6} {large_arc} {sweep_flag} {new[0]:.6} {new[1]:.6}' @dataclass class ArcPoly(GraphicPrimitive): @@ -72,15 +185,23 @@ class ArcPoly(GraphicPrimitive): outline : [(float,)] # list of radii of segments, must be either None (all segments are straight lines) or same length as outline. # Straight line segments have None entry. - arc_centers : [(float,)] + arc_centers : [(float,)] = None @property def segments(self): - return itertools.zip_longest(self.outline[:-1], self.outline[1:], self.radii or []) + ol = self.outline + return itertools.zip_longest(ol, ol[1:] + [ol[0]], self.arc_centers) + + def bounding_box(self): + bbox = (None, None), (None, None) + for (x1, y1), (x2, y2), arc in self.segments: + if arc: + clockwise, center = arc + bbox = add_bounds(bbox, arc_bounds(x1, y1, x2, y2, *center, clockwise)) - def bounds(self): - for (x1, y1), (x2, y2), radius in self.segments: - return + else: + line_bounds = (min(x1, x2), min(y1, y2)), (max(x1, x2), max(y1, y2)) + bbox = add_bounds(bbox, line_bounds) def __len__(self): return len(self.outline) @@ -88,6 +209,21 @@ class ArcPoly(GraphicPrimitive): def __bool__(self): return bool(len(self)) + def _path_d(self): + if len(self.outline) == 0: + return + + yield f'M {outline[0][0]:.6}, {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): + return 'path', [], {'d': ' '.join(self._path_d())} + @dataclass class Line(GraphicPrimitive): @@ -97,7 +233,14 @@ class Line(GraphicPrimitive): y2 : float width : float - # FIXME bounds + 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): + return 'path', [], dict( + d=f'M {self.x1:.6} {self.y1:.6} L {self.x2:.6} {self.y2:.6}', + style=f'stroke-width: {self.width:.6}; stroke-linecap: round') @dataclass class Arc(GraphicPrimitive): @@ -107,10 +250,36 @@ class Arc(GraphicPrimitive): y2 : float cx : float cy : float - flipped : bool + clockwise : bool width : float - # FIXME bounds + def bounding_box(self): + r = self.w/2 + endpoints = add_bounds(Circle(self.x1, self.y1, r).bounding_box(), Circle(self.x2, self.y2, r).bounding_box()) + + arc_r = point_distance((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, cx, cy, self.clockwise) + return add_bounds(endpoints, arc) # FIXME add "include_center" switch + + def to_svg(self): + arc = svg_arc((self.x1, self.y1), (self.x2, self.y2), (self.cx, self.cy), self.clockwise) + return 'path', [], dict( + d=f'M {self.x1:.6} {self.y1:.6} {arc}', + style=f'stroke-width: {self.width:.6}; stroke-linecap: round') + +def svg_rotation(angle_rad): + return f'rotation({angle_rad/math.pi*180:.4})' @dataclass class Rectangle(GraphicPrimitive): @@ -121,13 +290,29 @@ class Rectangle(GraphicPrimitive): h : float rotation : float # radians, around center! - def bounds(self): - return ((self.x, self.y), (self.x+self.w, self.y+self.h)) + 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): + x, y = self.x - self.w/2, self.y - self.h/2 + return 'rect', [], dict(x=x, y=y, w=self.w, h=self.h, transform=svg_rotation(self.rotation)) + class RegularPolygon(GraphicPrimitive): x : float @@ -136,13 +321,19 @@ class RegularPolygon(GraphicPrimitive): n : int rotation : float # radians! - def decompose(self): - ''' convert n-sided gerber polygon to normal Region defined by outline ''' + def to_arc_poly(self): + ''' convert n-sided gerber polygon to normal ArcPoly defined by outline ''' delta = 2*math.pi / self.n - yield Region([ + 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): + return self.to_arc_poly().to_svg() + diff --git a/gerbonara/gerber/rs274x.py b/gerbonara/gerber/rs274x.py index 38dfe06..f53c78a 100644 --- a/gerbonara/gerber/rs274x.py +++ b/gerbonara/gerber/rs274x.py @@ -59,6 +59,18 @@ def points_close(a, b): else: return math.isclose(a[0], b[0]) and math.isclose(a[1], b[1]) +def Tag: + def __init__(self, name, children=None, **attrs): + self.name, self.children, self.attrs = name, children, attrs + + def __str__(self): + opening = ' '.join([self.name] + [f'{key}="{value}"' for key, value in self.attrs.items()]) + if self.children: + children = '\n'.join(textwrap.indent(str(c), ' ') for c in children) + return f'<{opening}>\n{children}\n' + else: + return f'<{opening}/>' + class GerberFile(CamFile): """ A class representing a single gerber file @@ -71,6 +83,27 @@ class GerberFile(CamFile): self.comments = [] self.objects = [] + def to_svg(self, tag=Tag, margin=0, margin_unit='mm', svg_unit='mm'): + + (min_x, min_y), (max_x, max_y) = self.bounding_box(svg_unit) + + if margin: + margin = convert(margin, margin_unit, svg_unit) + min_x -= margin + min_y -= margin + max_x += margin + max_y += margin + + w, h = max_x - min_x, max_y - min_y + + primitives = [ + [ tag(*prim.to_svg()) for prim in obj.to_primitives(unit=svg_unit) ] + for obj in self.objects ] + + # FIXME setup viewport transform flipping y axis + + return tag('svg', [defs, *primitives], width=w, height=h, viewBox=f'{min_x} {min_y} {w} {h}') + def merge(self, other): """ Merge other GerberFile into this one """ self.comments += other.comments @@ -158,8 +191,8 @@ class GerberFile(CamFile): return (x1 - x0, y1 - y0) @property - def bounding_box(self): - bounds = [ p.bounding_box for p in self.pDeprecatedrimitives ] + def bounding_box(self, unit='mm'): + bounds = [ p.bounding_box(unit) for p in self.objects ] min_x = min(x0 for (x0, y0), (x1, y1) in bounds) min_y = min(y0 for (x0, y0), (x1, y1) in bounds) @@ -227,27 +260,14 @@ class GerberFile(CamFile): def offset(self, dx=0, dy=0, unit='mm'): # TODO round offset to file resolution - dx, dy = self.convert_length(dx, unit), self.convert_length(dy, unit) #print(f'offset {dx},{dy} file unit') #for obj in self.objects: # print(' ', obj) - self.objects = [ obj.with_offset(dx, dy) for obj in self.objects ] + self.objects = [ obj.with_offset(dx, dy, unit) for obj in self.objects ] #print('after:') #for obj in self.objects: # print(' ', obj) - def convert_length(self, value, unit='mm'): - """ Convert length into file unit """ - - if unit == 'mm': - if self.unit == 'inch': - return value / 25.4 - elif unit == 'inch': - if self.unit == 'mm': - return value * 25.4 - - return value - def rotate(self, angle:'radian', center=(0,0), unit='mm'): """ Rotate file contents around given point. @@ -261,8 +281,6 @@ class GerberFile(CamFile): if math.isclose(angle % (2*math.pi), 0): return - center = self.convert_length(center[0], unit), self.convert_length(center[1], unit) - # First, rotate apertures. We do this separately from rotating the individual objects below to rotate each # aperture exactly once. for ap in self.apertures: @@ -273,7 +291,7 @@ class GerberFile(CamFile): # print(' ', obj) for obj in self.objects: - obj.rotate(angle, *center) + obj.rotate(angle, *center, unit) #print('after') #for obj in self.objects: @@ -414,9 +432,9 @@ class GraphicsState: polarity_dark=self.polarity_dark, unit=self.file_settings.unit) def _create_arc(self, old_point, new_point, control_point, aperture=True): - direction = 'ccw' if self.interpolation_mode == CircularCCWModeStmt else 'cw' + clockwise = self.interpolation_mode == CircularCWModeStmt return go.Arc(*old_point, *new_point,* self.map_coord(*control_point, relative=True), - flipped=(direction == 'cw'), aperture=(self.aperture if aperture else None), + clockwise=clockwise, aperture=(self.aperture if aperture else None), polarity_dark=self.polarity_dark, unit=self.file_settings.unit) def update_point(self, x, y, unit=None): -- cgit