From 940cf9df6eb8f62359de014650f443a91b1af157 Mon Sep 17 00:00:00 2001 From: jaseg Date: Sun, 23 Jan 2022 17:54:47 +0100 Subject: Multi-quadrant code still borked --- gerbonara/gerber/cam.py | 9 ++++++++- gerbonara/gerber/excellon.py | 7 ++++--- gerbonara/gerber/graphic_objects.py | 32 ++++++++++++++++++++------------ gerbonara/gerber/graphic_primitives.py | 15 +++++++++++---- gerbonara/gerber/rs274x.py | 30 +++++++++++++++++++++++------- gerbonara/gerber/tests/test_rs274x.py | 23 ++++++++++++++--------- 6 files changed, 80 insertions(+), 36 deletions(-) diff --git a/gerbonara/gerber/cam.py b/gerbonara/gerber/cam.py index 554491d..7283316 100644 --- a/gerbonara/gerber/cam.py +++ b/gerbonara/gerber/cam.py @@ -168,6 +168,8 @@ class CamFile: max_x = svg_unit(max_x, arg_unit) max_y = svg_unit(max_y, arg_unit) + content_min_x, content_min_y = min_x, min_y + content_w, content_h = max_x - min_x, max_y - min_y if margin: margin = svg_unit(margin, arg_unit) min_x -= margin @@ -201,7 +203,7 @@ class CamFile: tags.append(polyline.to_svg(tag, fg, bg)) # setup viewport transform flipping y axis - xform = f'translate({min_x} {min_y+h}) scale(1 -1) translate({-min_x} {-min_y})' + xform = f'translate({content_min_x} {content_min_y+content_h}) scale(1 -1) translate({-content_min_x} {-content_min_y})' svg_unit = 'in' if svg_unit == 'inch' else 'mm' # TODO export apertures as where reasonable. @@ -231,5 +233,10 @@ class CamFile: max_x = max(x1 for (x0, y0), (x1, y1) in bounds) max_y = max(y1 for (x0, y0), (x1, y1) in bounds) + #for p in self.objects: + # bb = (o_min_x, o_min_y), (o_max_x, o_max_y) = p.bounding_box(unit) + # if o_min_x == min_x or o_min_y == min_y or o_max_x == max_x or o_max_y == max_y: + # print('\033[91m bounds\033[0m', bb, p) + return ((min_x, min_y), (max_x, max_y)) diff --git a/gerbonara/gerber/excellon.py b/gerbonara/gerber/excellon.py index 2e0add3..b5c9221 100755 --- a/gerbonara/gerber/excellon.py +++ b/gerbonara/gerber/excellon.py @@ -108,7 +108,7 @@ class ExcellonFile(CamFile): self.generator_hints = generator_hints or [] # This is a purely informational goodie from the parser. Use it as you wish. def __bool__(self): - return bool(self.objects) + return not self.is_empty @property def is_plated(self): @@ -257,8 +257,9 @@ class ExcellonFile(CamFile): def is_nonplated(self): return not any(obj.plated for obj in self.objects) - def empty(self): - return self.objects.empty() + @property + def is_empty(self): + return not self.objects def __len__(self): return len(self.objects) diff --git a/gerbonara/gerber/graphic_objects.py b/gerbonara/gerber/graphic_objects.py index 1f357da..131bf57 100644 --- a/gerbonara/gerber/graphic_objects.py +++ b/gerbonara/gerber/graphic_objects.py @@ -226,7 +226,8 @@ class Line(GerberObject): 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) + w = self.aperture.equivalent_width(unit) if self.aperture else 0.1 # for debugging + yield gp.Line(*conv.p1, *conv.p2, w, polarity_dark=self.polarity_dark) def to_statements(self, gs): yield from gs.set_polarity(self.polarity_dark) @@ -277,16 +278,22 @@ class Arc(GerberObject): return abs(r1 - r2) def sweep_angle(self): - f = math.atan2(self.x2, self.y2) - math.atan2(self.x1, self.y1) - f = (f + math.pi) % (2*math.pi) - math.pi - - if self.clockwise: - f = -f - - if f > math.pi: - f = 2*math.pi - f - - return f + cx, cy = self.cx + self.x1, self.cy + self.y1 + x1, y1 = self.x1 - cx, self.y1 - cy + x2, y2 = self.x2 - cx, self.y2 - cy + + a1, a2 = math.atan2(y1, x1), math.atan2(y2, x2) + f = abs(a2 - a1) + if not self.clockwise: + if a2 > a1: + return a2 - a1 + else: + return 2*math.pi - abs(a2 - a1) + else: + if a1 > a2: + return a1 - a2 + else: + return 2*math.pi - abs(a1 - a2) @property def p1(self): @@ -329,11 +336,12 @@ class Arc(GerberObject): def to_primitives(self, unit=None): conv = self.converted(unit) + w = self.aperture.equivalent_width(unit) if self.aperture else 0.1 # for debugging yield gp.Arc(x1=conv.x1, y1=conv.y1, x2=conv.x2, y2=conv.y2, cx=conv.cx, cy=conv.cy, clockwise=self.clockwise, - width=self.aperture.equivalent_width(unit), + width=w, polarity_dark=self.polarity_dark) def to_statements(self, gs): diff --git a/gerbonara/gerber/graphic_primitives.py b/gerbonara/gerber/graphic_primitives.py index 07ceb5c..401156e 100644 --- a/gerbonara/gerber/graphic_primitives.py +++ b/gerbonara/gerber/graphic_primitives.py @@ -89,8 +89,12 @@ def arc_bounds(x1, y1, x2, y2, cx, cy, clockwise): # # 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 @@ -212,7 +216,7 @@ class ArcPoly(GraphicPrimitive): 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+x1, cy+y1), clockwise)) + 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)) @@ -277,7 +281,8 @@ class Polyline: (x0, y0), *rest = self.coords d = f'M {x0:.6} {y0:.6} ' + ' '.join(f'L {x:.6} {y:.6}' for x, y in rest) - return tag('path', d=d, style=f'fill: none; stroke: {color}; stroke-width: {self.width:.6}; stroke-linecap: round') + 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-linecap: round') @dataclass class Line(GraphicPrimitive): @@ -293,8 +298,9 @@ class Line(GraphicPrimitive): 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: {self.width:.6}; stroke-linecap: round') + style=f'fill: none; stroke: {color}; stroke-width: {width}; stroke-linecap: round') @dataclass class Arc(GraphicPrimitive): @@ -330,8 +336,9 @@ class Arc(GraphicPrimitive): 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: {self.width:.6}; stroke-linecap: round; fill: none') + 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})' diff --git a/gerbonara/gerber/rs274x.py b/gerbonara/gerber/rs274x.py index cba08fc..db93fb5 100644 --- a/gerbonara/gerber/rs274x.py +++ b/gerbonara/gerber/rs274x.py @@ -54,7 +54,7 @@ class GerberFile(CamFile): """ def __init__(self, objects=None, comments=None, import_settings=None, filename=None, generator_hints=None, - layer_hints=None): + layer_hints=None, file_attrs=None): super().__init__(filename=filename) self.objects = objects or [] self.comments = comments or [] @@ -62,6 +62,7 @@ class GerberFile(CamFile): self.layer_hints = layer_hints or [] self.import_settings = import_settings self.apertures = [] # FIXME get rid of this? apertures are already in the objects. + self.file_attrs = file_attrs or {} def to_excellon(self): new_objs = [] @@ -125,7 +126,6 @@ class GerberFile(CamFile): seen_macro_names.add(new_name) def dilate(self, offset, unit=MM, polarity_dark=True): - self.apertures = [ aperture.dilated(offset, unit) for aperture in self.apertures ] offset_circle = CircleAperture(offset, unit=unit) @@ -166,6 +166,9 @@ class GerberFile(CamFile): def generate_statements(self, settings, drop_comments=True): yield 'G04 Gerber file generated by Gerbonara*' + for name, value in self.file_attrs.items(): + attrdef = ','.join([name, *map(str, value)]) + yield f'%TF{attrdef}*%' yield '%MOMM*%' if (settings.unit == 'mm') else '%MOIN*%' zeros = 'T' if settings.zeros == 'trailing' else 'L' # default to leading if "None" is specified @@ -224,6 +227,16 @@ class GerberFile(CamFile): settings.number_format = (5,6) return '\n'.join(self.generate_statements(settings)) + @property + def is_empty(self): + return not self.objects + + def __len__(self): + return len(self.objects) + + def __bool__(self): + return not self.is_empty + def offset(self, dx=0, dy=0, unit=MM): # TODO round offset to file resolution @@ -275,9 +288,7 @@ class GraphicsState: self.image_axes = 'AXBY' # AS axis mapping; deprecated self._mat = None self.file_settings = file_settings - if aperture_map is not None: - self.aperture_map = aperture_map - self.aperture_map = {} + self.aperture_map = aperture_map or {} def __setattr__(self, name, value): # input validation @@ -408,7 +419,11 @@ class GraphicsState: arc = lambda cx, cy: go.Arc(*old_point, *new_point, cx, cy, clockwise=clockwise, aperture=(self.aperture if aperture else None), polarity_dark=self.polarity_dark, unit=self.file_settings.unit, attrs=attrs) + print('arcs:') arcs = [ arc(cx, cy), arc(-cx, cy), arc(cx, -cy), arc(-cx, -cy) ] + for a in arcs: + print(f'{a.sweep_angle()=} {a.numeric_error()=} {a}') + print(GerberFile(arcs).to_svg()) arcs = [ a for a in arcs if a.sweep_angle() <= math.pi/2 ] arcs = sorted(arcs, key=lambda a: a.numeric_error()) return arcs[0] @@ -585,6 +600,7 @@ class GerberParser: self.target.apertures = list(self.aperture_map.values()) self.target.import_settings = self.file_settings self.target.unit = self.file_settings.unit + self.target.file_attrs = self.file_attrs if not self.eof_found: warnings.warn('File is missing mandatory M02 EOF marker. File may be truncated.', SyntaxWarning) @@ -863,7 +879,7 @@ class GerberParser: warnings.warn(f'Deprecated {match["mode"]} notation mode statement found. This deprecated since 2012.', DeprecationWarning) self.target.comments.append('Replaced deprecated {match["mode"]} notation mode statement with FS statement') - def _parse_attribtue(self, match): + def _parse_attribute(self, match): if match['type'] == 'TD': if match['value']: raise SyntaxError('TD attribute deletion command must not contain attribute fields') @@ -886,7 +902,7 @@ class GerberParser: target = {'TF': self.file_attrs, 'TO': self.object_attrs, 'TA': self.aperture_attrs}[match['type']] target[match['name']] = match['value'].split(',') - if 'eagle' in self.file_attrs.get('.GenerationSoftware', '').lower() or match['eagle_garbage']: + if 'EAGLE' in self.file_attrs.get('.GenerationSoftware', []) or match['eagle_garbage']: self.generator_hints.append('eagle') def _parse_eof(self, _match): diff --git a/gerbonara/gerber/tests/test_rs274x.py b/gerbonara/gerber/tests/test_rs274x.py index 06150de..b7ddbd4 100644 --- a/gerbonara/gerber/tests/test_rs274x.py +++ b/gerbonara/gerber/tests/test_rs274x.py @@ -456,19 +456,22 @@ def test_svg_export(reference, tmpfile): @filter_syntax_warnings @pytest.mark.parametrize('reference', REFERENCE_FILES, indirect=True) def test_bounding_box(reference, tmpfile): + if reference.name == 'MinnowMax_assy.art': + # This leads to worst-case performance in resvg, this testcase takes over 1h to finish. So skip. + pytest.skip() # skip this check on files that contain lines with a zero-size aperture at the board edge if any(reference.match(f'*/{f}') for f in HAS_ZERO_SIZE_APERTURES): pytest.skip() - # skip this file because it does not contain any graphical objects - if reference.match('*/multiline_read.ger'): - pytest.skip() - margin = 1.0 # inch dpi = 200 margin_px = int(dpi*margin) # intentionally round down to avoid aliasing artifacts grb = GerberFile.open(reference) + + if grb.is_empty: + pytest.skip() + out_svg = tmpfile('Output', '.svg') with open(out_svg, 'w') as f: f.write(str(grb.to_svg(margin=margin, arg_unit='inch', fg='white', bg='black'))) @@ -484,11 +487,13 @@ def test_bounding_box(reference, tmpfile): rows = img.sum(axis=0) col_prefix, col_suffix = np.argmax(cols > 0), np.argmax(cols[::-1] > 0) row_prefix, row_suffix = np.argmax(rows > 0), np.argmax(rows[::-1] > 0) + print('cols:', col_prefix, col_suffix) + print('rows:', row_prefix, row_suffix) # Check that all margins are completely black and that the content touches the margins. Allow for some tolerance to - # allow for antialiasing artifacts. - assert margin_px-1 <= col_prefix <= margin_px+1 - assert margin_px-1 <= col_suffix <= margin_px+1 - assert margin_px-1 <= row_prefix <= margin_px+1 - assert margin_px-1 <= row_suffix <= margin_px+1 + # allow for antialiasing artifacts and for things like very thin features. + assert margin_px-2 <= col_prefix <= margin_px+2 + assert margin_px-2 <= col_suffix <= margin_px+2 + assert margin_px-2 <= row_prefix <= margin_px+2 + assert margin_px-2 <= row_suffix <= margin_px+2 -- cgit