From 2ce55ebdca8433aab058d8dfdcd3aa580f78ce27 Mon Sep 17 00:00:00 2001 From: jaseg Date: Sun, 8 Mar 2026 14:19:29 +0100 Subject: [PATCH] Fix arcpoly conversion bugs --- src/gerbonara/graphic_objects.py | 5 +-- src/gerbonara/graphic_primitives.py | 52 ++++++++++++++++++----------- tests/test_primitives.py | 22 ++++++++++++ 3 files changed, 58 insertions(+), 21 deletions(-) diff --git a/src/gerbonara/graphic_objects.py b/src/gerbonara/graphic_objects.py index 056e8cf..06fce26 100644 --- a/src/gerbonara/graphic_objects.py +++ b/src/gerbonara/graphic_objects.py @@ -337,8 +337,9 @@ class Region(GraphicObject): ], 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 from_arc_poly(kls, arc_poly, polarity_dark=None, unit=MM): + polarity = arc_poly.polarity_dark if polarity_dark is None else polarity_dark + return kls(arc_poly.outline, arc_poly.arc_centers, polarity_dark=polarity, unit=unit) def append(self, obj): if obj.unit != self.unit: diff --git a/src/gerbonara/graphic_primitives.py b/src/gerbonara/graphic_primitives.py index 99e570a..25ef3de 100644 --- a/src/gerbonara/graphic_primitives.py +++ b/src/gerbonara/graphic_primitives.py @@ -81,7 +81,8 @@ class Circle(GraphicPrimitive): 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))]) + [(True, (self.x, self.y)), (True, (self.x, self.y))], + polarity_dark=self.polarity_dark) @dataclass(frozen=True) @@ -218,24 +219,30 @@ class Line(GraphicPrimitive): 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 {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)) + fill='none', stroke=color, stroke_width=str(width), stroke_linecap='round') def to_arc_poly(self): l = math.dist((self.x1, self.y1), (self.x2, self.y2)) + if math.isclose(l, 0): + # degenerate case: a zero-length line becomes a circle. + return ArcPoly([(self.x1-self.width/2, self.y1), (self.x1+self.width/2, self.y1)], + [(True, (self.x1, self.y1)), (True, (self.x1, self.y1))], + polarity_dark=self.polarity_dark) + 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), + (self.x2-rx, self.y2-ry), + (self.x1-rx, self.y1-ry), + (self.x1+rx, self.y1+ry), ], [ - (True, (self.x1, self.y1)), - None, (True, (self.x2, self.y2)), None, - ]) + (True, (self.x1, self.y1)), + None, + ], polarity_dark=self.polarity_dark) @dataclass(frozen=True) @@ -276,25 +283,32 @@ class Arc(GraphicPrimitive): 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 {float(self.x1):.6} {float(self.y1):.6} {arc}', - fill='none', stroke=color, stroke_width=width) + fill='none', stroke=color, stroke_width=width, stroke_linecap='round') def to_arc_poly(self): r = math.dist((self.x1, self.y1), (self.cx, self.cy)) + + if math.isclose(r, 0): + # degenerate case: a zero-radius arc becomes a circle. + return ArcPoly([(self.x1-self.width/2, self.y1), (self.x1+self.width/2, self.y1)], + [(True, (self.x1, self.y1)), (True, (self.x1, self.y1))], + polarity_dark=self.polarity_dark) + 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)), + return ArcPoly([ # vertices + (self.x1+nx1, self.y1+ny1), + (self.x1-nx1, self.y1-ny1), + (self.x2-nx2, self.y2-ny2), + (self.x2+nx2, self.y2+ny2), + ], [ # arc segments (direction, center) + (not self.clockwise, (self.x1, self.y1)), (self.clockwise, (self.cx, self.cy)), (self.clockwise, (self.x2, self.y2)), - (self.clockwise, (self.cx, self.cy)), - ]) + (not self.clockwise, (self.cx, self.cy)), + ], polarity_dark=self.polarity_dark) @dataclass(frozen=True) @@ -323,7 +337,7 @@ class Rectangle(GraphicPrimitive): (x - (cw+sh), y + (ch+sw)), (x + (cw+sh), y + (ch+sw)), (x + (cw+sh), y - (ch+sw)), - ]) + ], polarity_dark=self.polarity_dark) def to_svg(self, fg='black', bg='white', tag=Tag): color = fg if self.polarity_dark else bg diff --git a/tests/test_primitives.py b/tests/test_primitives.py index 020acab..d7fbf25 100644 --- a/tests/test_primitives.py +++ b/tests/test_primitives.py @@ -47,8 +47,25 @@ def object_test(tmpfile, img_support, epsilon=1e-4): out_svg = tmpfile('SVG Output', '.svg') with open(out_svg, 'w') as f: + # Use inch units here to make sure we and gerbv agree on the exact pixel size of the output since both calculate + # it from the DPI setting. f.write(str(gbr.to_svg(force_bounds=bounds, arg_unit='inch', fg='black', bg='white'))) + # test primitive to_arc_poly + arc_poly_gbr = GerberFile() + for obj in gbr.objects: + for primitive in obj.to_primitives(MM): + poly = primitive.to_arc_poly() + arc_poly_gbr.objects.append(Region.from_arc_poly(poly)) + + arc_poly_svg = tmpfile('ArcPoly SVG Output', '.svg') + with open(arc_poly_svg, 'w') as f: + f.write(str(arc_poly_gbr.to_svg(force_bounds=bounds, arg_unit='inch', fg='black', bg='white'))) + + arc_poly_png = tmpfile('ArcPoly conversion render', '.png') + img_support.svg_to_png(arc_poly_svg, arc_poly_png, dpi=300, bg='white') + + # Reference export via gerber through GerbV out_gbr = tmpfile('GBR Output', '.gbr') gbr.save(out_gbr) @@ -70,6 +87,11 @@ def object_test(tmpfile, img_support, epsilon=1e-4): assert mean < epsilon assert hist[3:].sum() < epsilon*hist.size + mean, _max, hist = img_support.image_difference(out_png, arc_poly_png, diff_out=tmpfile('ArcPoly Difference', '.png')) + assert hist[9] < 1 + assert mean < epsilon + assert hist[3:].sum() < epsilon*hist.size + @pytest.mark.parametrize('angle_deg', [0, 5, -5, 10, -10, 15, -15, 30, -30, 45, -45, 60, -60, 75, -75, 90, -90, 120, -120, 180, 153, 155, 157]) def test_line(angle_deg, tmpfile, img_support): with object_test(tmpfile, img_support) as gbr: