From f4b2e74923cc95c683cd7f5c4732d92e4aafd3ba Mon Sep 17 00:00:00 2001 From: jaseg Date: Thu, 30 Dec 2021 16:40:14 +0100 Subject: Fix rotation bugs, all tests run through --- gerbonara/gerber/aperture_macros/parse.py | 25 +-- gerbonara/gerber/aperture_macros/primitive.py | 9 +- gerbonara/gerber/apertures.py | 20 +-- gerbonara/gerber/cam.py | 14 +- gerbonara/gerber/gerber_statements.py | 4 +- gerbonara/gerber/graphic_objects.py | 35 +++-- gerbonara/gerber/graphic_primitives.py | 11 +- gerbonara/gerber/rs274x.py | 75 +++++---- gerbonara/gerber/tests/conftest.py | 2 - gerbonara/gerber/tests/image_support.py | 50 +++++- gerbonara/gerber/tests/test_rs274x.py | 211 ++++++++++++++++++++------ 11 files changed, 321 insertions(+), 135 deletions(-) diff --git a/gerbonara/gerber/aperture_macros/parse.py b/gerbonara/gerber/aperture_macros/parse.py index f1e2150..6eed381 100644 --- a/gerbonara/gerber/aperture_macros/parse.py +++ b/gerbonara/gerber/aperture_macros/parse.py @@ -114,10 +114,11 @@ class ApertureMacro: return [ primitive.to_graphic_primitives(offset, rotation, variables, unit) for primitive in self.primitives ] def rotated(self, angle): - copy = copy.deepcopy(self) - for primitive in copy.primitives: - primitive.rotation += rad_to_deg(angle) - return copy + dup = copy.deepcopy(self) + for primitive in dup.primitives: + # aperture macro primitives use degree counter-clockwise, our API uses radians clockwise + primitive.rotation -= rad_to_deg(angle) + return dup cons, var = ConstantExpression, VariableExpression @@ -127,26 +128,28 @@ class GenericMacros: _generic_hole = lambda n: [ ap.Circle(None, [0, var(n), 0, 0]), - ap.CenterLine(None, [0, var(n), var(n+1), 0, 0, var(n+2) * deg_per_rad])] + ap.CenterLine(None, [0, var(n), var(n+1), 0, 0, var(n+2) * -deg_per_rad])] # Initialize all these with "None" units so they inherit file units, and do not convert their arguments. + # NOTE: All generic macros have rotation values specified in **clockwise radians** like the rest of the user-facing + # API. circle = ApertureMacro('GNC', [ - ap.Circle(None, [1, var(1), 0, 0, var(4) * deg_per_rad]), + ap.Circle(None, [1, var(1), 0, 0, var(4) * -deg_per_rad]), *_generic_hole(2)]) rect = ApertureMacro('GNR', [ - ap.CenterLine(None, [1, var(1), var(2), 0, 0, var(5) * deg_per_rad]), + ap.CenterLine(None, [1, var(1), var(2), 0, 0, var(5) * -deg_per_rad]), *_generic_hole(3) ]) # w must be larger than h obround = ApertureMacro('GNO', [ - ap.CenterLine(None, [1, var(1), var(2), 0, 0, var(5) * deg_per_rad]), - ap.Circle(None, [1, var(2), +var(1)/2, 0, var(5) * deg_per_rad]), - ap.Circle(None, [1, var(2), -var(1)/2, 0, var(5) * deg_per_rad]), + ap.CenterLine(None, [1, var(1), var(2), 0, 0, var(5) * -deg_per_rad]), + ap.Circle(None, [1, var(2), +var(1)/2, 0, var(5) * -deg_per_rad]), + ap.Circle(None, [1, var(2), -var(1)/2, 0, var(5) * -deg_per_rad]), *_generic_hole(3) ]) polygon = ApertureMacro('GNP', [ - ap.Polygon(None, [1, var(2), 0, 0, var(1), var(3) * deg_per_rad]), + ap.Polygon(None, [1, var(2), 0, 0, var(1), var(3) * -deg_per_rad]), ap.Circle(None, [0, var(4), 0, 0])]) diff --git a/gerbonara/gerber/aperture_macros/primitive.py b/gerbonara/gerber/aperture_macros/primitive.py index 4d3e597..a587d7e 100644 --- a/gerbonara/gerber/aperture_macros/primitive.py +++ b/gerbonara/gerber/aperture_macros/primitive.py @@ -42,7 +42,7 @@ class Primitive: def to_gerber(self, unit=None): return f'{self.code},' + ','.join( - getattr(self, name).to_gerber(unit) for name in type(self).__annotations__) + '*' + getattr(self, name).to_gerber(unit) for name in type(self).__annotations__) def __str__(self): attrs = ','.join(str(getattr(self, name)).strip('<>') for name in type(self).__annotations__) @@ -75,7 +75,12 @@ class Circle(Primitive): # center x/y x : UnitExpression y : UnitExpression - rotation : Expression = ConstantExpression(0.0) + rotation : Expression = None + + def __init__(self, unit, args): + super().__init__(unit, args) + if self.rotation is None: + self.rotation = ConstantExpression(0) def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None): with self.Calculator(variable_binding, unit) as calc: diff --git a/gerbonara/gerber/apertures.py b/gerbonara/gerber/apertures.py index 0b43822..9e5c23a 100644 --- a/gerbonara/gerber/apertures.py +++ b/gerbonara/gerber/apertures.py @@ -89,7 +89,7 @@ class CircleAperture(Aperture): return self.to_macro(self.rotation) def to_macro(self): - return ApertureMacroInstance(GenericMacros.circle, *self.params) + return ApertureMacroInstance(GenericMacros.circle, self.params) @property def params(self): @@ -122,12 +122,13 @@ class RectangleAperture(Aperture): if math.isclose(self.rotation % math.pi, 0): return self elif math.isclose(self.rotation % math.pi, math.pi/2): - return replace(self, w=self.h, h=self.w, **self._rotate_hole_90()) + return replace(self, w=self.h, h=self.w, **self._rotate_hole_90(), rotation=0) else: # odd angle return self.to_macro() def to_macro(self): - return ApertureMacroInstance(GenericMacros.rect, *self.params) + return ApertureMacroInstance(GenericMacros.rect, + [self.w, self.h, self.hole_dia or 0, self.hole_rect_h or 0, self.rotation]) @property def params(self): @@ -156,14 +157,15 @@ class ObroundAperture(Aperture): if math.isclose(self.rotation % math.pi, 0): return self elif math.isclose(self.rotation % math.pi, math.pi/2): - return replace(self, w=self.h, h=self.w, **self._rotate_hole_90()) + return replace(self, w=self.h, h=self.w, **self._rotate_hole_90(), rotation=0) else: return self.to_macro() - def to_macro(self, rotation:'radians'=0): + def to_macro(self): # generic macro only supports w > h so flip x/y if h > w - inst = self if self.w > self.h else replace(self, w=self.h, h=self.w, **_rotate_hole_90(self)) - return ApertureMacroInstance(GenericMacros.obround, *inst.params) + inst = self if self.w > self.h else replace(self, w=self.h, h=self.w, **_rotate_hole_90(self), rotation=self.rotation-90) + return ApertureMacroInstance(GenericMacros.obround, + [inst.w, ints.h, inst.hole_dia, inst.hole_rect_h, inst.rotation]) @property def params(self): @@ -190,7 +192,7 @@ class PolygonAperture(Aperture): return self def to_macro(self): - return ApertureMacroInstance(GenericMacros.polygon, *self.params) + return ApertureMacroInstance(GenericMacros.polygon, self.params) @property def params(self): @@ -226,7 +228,7 @@ class ApertureMacroInstance(Aperture): return self.to_macro() def to_macro(self): - return replace(self, macro=macro.rotated(self.rotation)) + return replace(self, macro=self.macro.rotated(self.rotation), rotation=0) def __eq__(self, other): return hasattr(other, 'macro') and self.macro == other.macro and \ diff --git a/gerbonara/gerber/cam.py b/gerbonara/gerber/cam.py index d00194b..fa38382 100644 --- a/gerbonara/gerber/cam.py +++ b/gerbonara/gerber/cam.py @@ -31,19 +31,19 @@ class FileSettings: `zeros='trailing'` ''' notation : str = 'absolute' - units : str = 'inch' - angle_units : str = 'degrees' + unit : str = 'inch' + angle_unit : str = 'degree' zeros : bool = None number_format : tuple = (2, 5) # input validation def __setattr__(self, name, value): - if name == 'units' and value not in ['inch', 'mm']: - raise ValueError(f'Units must be either "inch" or "mm", not {value}') + if name == 'unit' and value not in ['inch', 'mm']: + raise ValueError(f'Unit must be either "inch" or "mm", not {value}') elif name == 'notation' and value not in ['absolute', 'incremental']: raise ValueError(f'Notation must be either "absolute" or "incremental", not {value}') - elif name == 'angle_units' and value not in ('degrees', 'radians'): - raise ValueError(f'Angle units may be "degrees" or "radians", not {value}') + elif name == 'angle_unit' and value not in ('degree', 'radian'): + raise ValueError(f'Angle unit may be "degree" or "radian", not {value}') elif name == 'zeros' and value not in [None, 'leading', 'trailing']: raise ValueError(f'zeros must be either "leading" or "trailing" or None, not {value}') elif name == 'number_format': @@ -60,7 +60,7 @@ class FileSettings: return deepcopy(self) def __str__(self): - return f'' + return f'' def parse_gerber_value(self, value): if not value: diff --git a/gerbonara/gerber/gerber_statements.py b/gerbonara/gerber/gerber_statements.py index 06aee7a..9d318a3 100644 --- a/gerbonara/gerber/gerber_statements.py +++ b/gerbonara/gerber/gerber_statements.py @@ -51,7 +51,7 @@ class UnitStmt(ParamStmt): """ MO - Coordinate unit mode statement """ def to_gerber(self, settings): - return '%MOMM*%' if settings.units == 'mm' else '%MOIN*%' + return '%MOMM*%' if settings.unit == 'mm' else '%MOIN*%' def __str__(self): return ('' % mode_str) @@ -96,7 +96,7 @@ class ApertureMacroStmt(ParamStmt): self.macro = macro def to_gerber(self, settings=None): - unit = settings.units if settings else None + unit = settings.unit if settings else None return f'%AM{self.macro.name}*\n{self.macro.to_gerber(unit=unit)}*\n%' def __str__(self): diff --git a/gerbonara/gerber/graphic_objects.py b/gerbonara/gerber/graphic_objects.py index 97c664c..284a5f9 100644 --- a/gerbonara/gerber/graphic_objects.py +++ b/gerbonara/gerber/graphic_objects.py @@ -1,6 +1,6 @@ import math -from dataclasses import dataclass, KW_ONLY, astuple +from dataclasses import dataclass, KW_ONLY, astuple, replace from . import graphic_primitives as gp from .gerber_statements import * @@ -22,7 +22,7 @@ class Flash(GerberObject): def with_offset(self, dx, dy): return replace(self, x=self.x+dx, y=self.y+dy) - def rotate(self, rotation, cx=None, cy=None): + 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): @@ -48,11 +48,15 @@ class Region(GerberObject): return bool(self.poly) def with_offset(self, dx, dy): - return Region([ (x+dx, y+dy) for x, y in outline ], radii, polarity_dark=self.polarity_dark) + return Region([ (x+dx, y+dy) for x, y in self.poly.outline ], + self.poly.arc_centers, + polarity_dark=self.polarity_dark) 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(x, y, angle, cx, cy) for x, y in self.poly.arc_centers ] + self.poly.arc_centers = [ + gp.rotate_point(*center, angle, cx, cy) if center else None + for center in self.poly.arc_centers ] def append(self, obj): if not self.poly.outline: @@ -69,6 +73,7 @@ class Region(GerberObject): yield self.poly def to_statements(self, gs): + yield from gs.set_polarity(self.polarity_dark) yield RegionStartStmt() yield from gs.set_current_point(self.poly.outline[0]) @@ -99,10 +104,7 @@ class Line(GerberObject): 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=None, cy=None): - if cx is None: - cx = (self.x1 + self.x2) / 2 - cy = (self.y1 + self.y2) / 2 + 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) @@ -118,6 +120,7 @@ class Line(GerberObject): yield gp.Line(*self.p1, *self.p2, self.aperture.equivalent_width, polarity_dark=self.polarity_dark) def to_statements(self, gs): + yield from gs.set_polarity(self.polarity_dark) yield from gs.set_aperture(self.aperture) yield from gs.set_interpolation_mode(LinearModeStmt) yield from gs.set_current_point(self.p1) @@ -134,7 +137,7 @@ class Drill(GerberObject): def with_offset(self, dx, dy): return replace(self, x=self.x+dx, y=self.y+dy) - def rotate(self, angle, cx=None, cy=None): + 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): @@ -152,7 +155,7 @@ class Slot(GerberObject): 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=None, cy=None): + def rotate(self, rotation, cx=0, cy=0): if cx is None: cx = (self.x1 + self.x2) / 2 cy = (self.y1 + self.y2) / 2 @@ -183,7 +186,7 @@ class Arc(GerberObject): aperture : object def with_offset(self, dx, dy): - return replace(self, x=self.x+dx, y=self.y+dy) + return replace(self, x1=self.x1+dx, y1=self.y1+dy, x2=self.x2+dx, y2=self.y2+dy) @property def p1(self): @@ -195,18 +198,20 @@ class Arc(GerberObject): @property def center(self): - return self.x1 + self.cx, self.y1 + self.cy + return self.cx + self.x1, self.cy + self.y1 - def rotate(self, rotation, cx=None, cy=None): - cx, cy = gp.rotate_point(*self.center, rotation, cx, cy) + 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 = cx - self.x1, cy - self.y1 + 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_statements(self, gs): + yield from gs.set_polarity(self.polarity_dark) yield from gs.set_aperture(self.aperture) yield from gs.set_interpolation_mode(CircularCCWModeStmt) yield from gs.set_current_point(self.p1) diff --git a/gerbonara/gerber/graphic_primitives.py b/gerbonara/gerber/graphic_primitives.py index c917365..de1a0bc 100644 --- a/gerbonara/gerber/graphic_primitives.py +++ b/gerbonara/gerber/graphic_primitives.py @@ -12,12 +12,11 @@ class GraphicPrimitive: polarity_dark : bool = True -def rotate_point(x, y, angle, cx=None, cy=None): - if cx is None: - return (x, y) - else: - return (cx + (x - cx) * math.cos(angle) - (y - cy) * math.sin(angle), - cy + (x - cx) * math.sin(angle) + (y - cy) * math.cos(angle)) +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)) @dataclass diff --git a/gerbonara/gerber/rs274x.py b/gerbonara/gerber/rs274x.py index 654c389..b5fbfac 100644 --- a/gerbonara/gerber/rs274x.py +++ b/gerbonara/gerber/rs274x.py @@ -25,6 +25,7 @@ import json import os import re import sys +import math import warnings import functools from pathlib import Path @@ -79,7 +80,7 @@ class GerberFile(CamFile): for m in [ GenericMacros.circle, GenericMacros.rect, GenericMacros.obround, GenericMacros.polygon] } for ap in new_apertures: if isinstance(aperture, apertures.ApertureMacroInstance): - macro_grb = ap.macro.to_gerber() # use native units to compare macros + macro_grb = ap.macro.to_gerber() # use native unit to compare macros if macro_grb in macros: ap.macro = macros[macro_grb] else: @@ -149,10 +150,10 @@ class GerberFile(CamFile): for number, aperture in enumerate(self.apertures, start=10): if isinstance(aperture, apertures.ApertureMacroInstance): - macro_grb = aperture.macro.to_gerber() # use native units to compare macros + macro_grb = aperture._rotated().macro.to_gerber() # use native unit to compare macros if macro_grb not in processed_macros: processed_macros.add(macro_grb) - yield ApertureMacroStmt(aperture.macro) + yield ApertureMacroStmt(aperture._rotated().macro) yield ApertureDefStmt(number, aperture) @@ -170,9 +171,9 @@ class GerberFile(CamFile): def __str__(self): return f'' - def save(self, filename): + def save(self, filename, settings=None): with open(filename, 'w', encoding='utf-8') as f: # Encoding is specified as UTF-8 by spec. - f.write(self.to_gerber()) + f.write(self.to_gerber(settings)) def to_gerber(self, settings=None): # Use given settings, or use same settings as original file if not given, or use defaults if not imported from a @@ -183,30 +184,60 @@ class GerberFile(CamFile): settings.number_format = (5,6) return '\n'.join(stmt.to_gerber(settings) for stmt in self.generate_statements()) - def offset(self, dx=0, dy=0): + 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 ] + #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:'radians', center=(0,0)): + def rotate(self, angle:'radian', center=(0,0), unit='mm'): """ Rotate file contents around given point. Arguments: - angle -- Rotation angle in radians counter-clockwise. + angle -- Rotation angle in radian clockwise. center -- Center of rotation (default: document origin (0, 0)) - Note that when rotating by odd angles other than 0, 90, 180 or 270 degrees this method may replace standard + Note that when rotating by odd angles other than 0, 90, 180 or 270 degree this method may replace standard rect and oblong apertures by macro apertures. Existing macro apertures are re-written. """ - if angle % (2*math.pi) == 0: + 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: ap.rotation += angle + #print(f'rotate {angle} @ {center}') + #for obj in self.objects: + # print(' ', obj) + for obj in self.objects: - obj.rotate(rotation, *center) + obj.rotate(angle, *center) + + #print('after') + #for obj in self.objects: + # print(' ', obj) def invert_polarity(self): for obj in self.objects: @@ -221,11 +252,11 @@ class GraphicsState: interpolation_mode : InterpolationModeStmt = LinearModeStmt multi_quadrant_mode : bool = None # used only for syntax checking aperture_mirroring = (False, False) # LM mirroring (x, y) - aperture_rotation = 0 # LR rotation in degrees, ccw + aperture_rotation = 0 # LR rotation in degree, ccw aperture_scale = 1 # LS scale factor, NOTE: same for both axes # The following are deprecated file-wide settings. We normalize these during parsing. image_offset : (float, float) = (0, 0) - image_rotation: int = 0 # IR image rotation in degrees ccw, one of 0, 90, 180 or 270; deprecated + image_rotation: int = 0 # IR image rotation in degree ccw, one of 0, 90, 180 or 270; deprecated image_mirror : tuple = (False, False) # IM image mirroring, (x, y); deprecated image_scale : tuple = (1.0, 1.0) # SF image scaling (x, y); deprecated image_axes : str = 'AXBY' # AS axis mapping; deprecated @@ -317,11 +348,9 @@ class GraphicsState: if i is not None or j is not None: raise SyntaxError("i/j coordinates given for linear D01 operation (which doesn't take i/j)") - #print('interpolate line') return self._create_line(old_point, self.map_coord(*self.point), aperture) else: - #print('interpolate arc') if i is None and j is None: warnings.warn('Linear segment implied during arc interpolation mode through D01 w/o I, J values', SyntaxWarning) @@ -468,11 +497,9 @@ class GerberParser: # multiple statements from one line. if line.strip() and self.eof_found: warnings.warn('Data found in gerber file after EOF.', SyntaxWarning) - print('line', line) for name, le_regex in self.STATEMENT_REGEXES.items(): if (match := le_regex.match(line)): - #print(f'match {name}') getattr(self, f'_parse_{name}')(match.groupdict()) line = line[match.end(0):] break @@ -483,6 +510,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 if not self.eof_found: warnings.warn('File is missing mandatory M02 EOF marker. File may be truncated.', SyntaxWarning) @@ -505,7 +533,6 @@ class GerberParser: y = self.file_settings.parse_gerber_value(match['y']) i = self.file_settings.parse_gerber_value(match['i']) j = self.file_settings.parse_gerber_value(match['j']) - print(f'coord x={x} y={y} i={i} j={j}') if not (op := match['operation']): if self.last_operation == 'D01': @@ -528,10 +555,8 @@ class GerberParser: raise SyntaxError('Circular arc interpolation in multi-quadrant mode (G74) is not implemented.') if self.current_region is None: - #print('D01 outside region') self.target.objects.append(self.graphics_state.interpolate(x, y, i, j)) else: - #print(f'D01 inside region {id(self.current_region)} of length {len(self.current_region)}') self.current_region.append(self.graphics_state.interpolate(x, y, i, j)) else: @@ -586,7 +611,7 @@ class GerberParser: def _parse_aperture_macro(self, match): self.aperture_macros[match['name']] = ApertureMacro.parse_macro( - match['name'], match['macro'], self.file_settings.units) + match['name'], match['macro'], self.file_settings.unit) def _parse_format_spec(self, match): # This is a common problem in Eagle files, so just suppress it @@ -599,9 +624,9 @@ class GerberParser: def _parse_unit_mode(self, match): if match['unit'] == 'MM': - self.file_settings.units = 'mm' + self.file_settings.unit = 'mm' else: - self.file_settings.units = 'inch' + self.file_settings.unit = 'inch' def _parse_load_polarity(self, match): self.graphics_state.polarity_dark = match['polarity'] == 'D' @@ -678,19 +703,17 @@ class GerberParser: def _parse_region_start(self, _match): self.current_region = go.Region(polarity_dark=self.graphics_state.polarity_dark) - #print(f'Region start of {id(self.current_region)}') def _parse_region_end(self, _match): if self.current_region is None: raise SyntaxError('Region end command (G37) outside of region') if self.current_region: # ignore empty regions - #print(f'Region end of {id(self.current_region)}') self.target.objects.append(self.current_region) self.current_region = None def _parse_old_unit(self, match): - self.file_settings.units = 'inch' if match['mode'] == 'G70' else 'mm' + self.file_settings.unit = 'inch' if match['mode'] == 'G70' else 'mm' warnings.warn(f'Deprecated {match["mode"]} unit mode statement found. This deprecated since 2012.', DeprecationWarning) self.target.comments.append('Replaced deprecated {match["mode"]} unit mode statement with MO statement') diff --git a/gerbonara/gerber/tests/conftest.py b/gerbonara/gerber/tests/conftest.py index c8fd475..c6a1221 100644 --- a/gerbonara/gerber/tests/conftest.py +++ b/gerbonara/gerber/tests/conftest.py @@ -8,8 +8,6 @@ def pytest_assertrepr_compare(op, left, right): diff = left if isinstance(left, ImageDifference) else right return [ f'Image difference assertion failed.', - f' Reference: {diff.ref_path}', - f' Actual: {diff.act_path}', f' Calculated difference: {diff}', ] # store report in node object so tmp_gbr can determine if the test failed. diff --git a/gerbonara/gerber/tests/image_support.py b/gerbonara/gerber/tests/image_support.py index 8ce0d72..49217c2 100644 --- a/gerbonara/gerber/tests/image_support.py +++ b/gerbonara/gerber/tests/image_support.py @@ -3,6 +3,9 @@ from pathlib import Path import tempfile import os from functools import total_ordering +import shutil +import bs4 +from contextlib import contextmanager import numpy as np from PIL import Image @@ -35,9 +38,9 @@ def run_cargo_cmd(cmd, args, **kwargs): return subprocess.run([str(Path.home() / '.cargo' / 'bin' / cmd), *args], **kwargs) def svg_to_png(in_svg, out_png): - run_cargo_cmd('resvg', [in_svg, out_png], check=True, stdout=subprocess.DEVNULL) + run_cargo_cmd('resvg', ['--dpi', '200', in_svg, out_png], check=True, stdout=subprocess.DEVNULL) -def gbr_to_svg(in_gbr, out_svg, origin=(0, 0), size=(10, 10)): +def gbr_to_svg(in_gbr, out_svg, origin=(0, 0), size=(6, 6)): x, y = origin w, h = size cmd = ['gerbv', '-x', 'svg', @@ -47,18 +50,51 @@ def gbr_to_svg(in_gbr, out_svg, origin=(0, 0), size=(10, 10)): '-o', str(out_svg), str(in_gbr)] subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) -def gerber_difference(reference, actual, diff_out=None): +@contextmanager +def svg_soup(filename): + with open(filename, 'r') as f: + soup = bs4.BeautifulSoup(f.read(), 'xml') + + yield soup + + with open(filename, 'w') as f: + f.write(str(soup)) + +def cleanup_clips(soup): + for group in soup.find_all('g'): + # gerbv uses Cairo's SVG canvas. Cairo's SVG canvas is kind of broken. It has no support for unit + # handling at all, which means the output files just end up being in pixels at 72 dpi. Further, it + # seems gerbv's aperture macro rendering interacts poorly with Cairo's SVG export. gerbv renders + # aperture macros into a new surface, which for some reason gets clipped by Cairo to the given + # canvas size. This is just wrong, so we just nuke the clip path from these SVG groups here. + # + # Apart from being graphically broken, this additionally causes very bad rendering performance. + del group['clip-path'] # remove broken clip + +def gerber_difference(reference, actual, diff_out=None, svg_transform=None, size=(10,10)): with tempfile.NamedTemporaryFile(suffix='.svg') as act_svg,\ tempfile.NamedTemporaryFile(suffix='.svg') as ref_svg: - gbr_to_svg(reference, ref_svg.name) - gbr_to_svg(actual, act_svg.name) + gbr_to_svg(reference, ref_svg.name, size=size) + gbr_to_svg(actual, act_svg.name, size=size) + + with svg_soup(ref_svg.name) as soup: + if svg_transform is not None: + soup.find('g', attrs={'id': 'surface1'})['transform'] = svg_transform + cleanup_clips(soup) + + with svg_soup(act_svg.name) as soup: + cleanup_clips(soup) + + # FIXME DEBUG + shutil.copyfile(act_svg.name, '/tmp/test-act.svg') + shutil.copyfile(ref_svg.name, '/tmp/test-ref.svg') return svg_difference(ref_svg.name, act_svg.name, diff_out=diff_out) def svg_difference(reference, actual, diff_out=None): - with tempfile.NamedTemporaryFile(suffix='.png') as ref_png,\ - tempfile.NamedTemporaryFile(suffix='.png') as act_png: + with tempfile.NamedTemporaryFile(suffix='-ref.png') as ref_png,\ + tempfile.NamedTemporaryFile(suffix='-act.png') as act_png: svg_to_png(reference, ref_png.name) svg_to_png(actual, act_png.name) diff --git a/gerbonara/gerber/tests/test_rs274x.py b/gerbonara/gerber/tests/test_rs274x.py index 28ee891..d91609f 100644 --- a/gerbonara/gerber/tests/test_rs274x.py +++ b/gerbonara/gerber/tests/test_rs274x.py @@ -4,22 +4,28 @@ # Author: Hamilton Kibbe import os import re -import pytest +import math import functools import tempfile import shutil from argparse import Namespace +from itertools import chain from pathlib import Path +import pytest + from ..rs274x import GerberFile +from ..cam import FileSettings from .image_support import gerber_difference +deg_to_rad = lambda a: a/180 * math.pi + fail_dir = Path('gerbonara_test_failures') @pytest.fixture(scope='session', autouse=True) def clear_failure_dir(request): - for f in fail_dir.glob('*.gbr'): + for f in chain(fail_dir.glob('*.gbr'), fail_dir.glob('*.png')): f.unlink() reference_path = lambda reference: Path(__file__).parent / 'resources' / reference @@ -42,61 +48,170 @@ def temp_files(request): shutil.copy(tmp_out_gbr.name, perm_path_gbr) shutil.copy(tmp_out_png.name, perm_path_png) print(f'Failing output saved to {perm_path_gbr}') - print(f'Difference image saved to {perm_path_png}') print(f'Reference file is {reference_path(request.node.funcargs["reference"])}') + print(f'Difference image saved to {perm_path_png}') + print(f'gerbv command line:') + print(f'gerbv {perm_path_gbr} {reference_path(request.node.funcargs["reference"])}') + +to_gerbv_svg_units = lambda val, unit='mm': val*72 if unit == 'inch' else val/25.4*72 + +REFERENCE_FILES = [ l.strip() for l in ''' + board_outline.GKO + example_outline_with_arcs.gbr + example_two_square_boxes.gbr + example_coincident_hole.gbr + example_cutin.gbr + example_cutin_multiple.gbr + example_flash_circle.gbr + example_flash_obround.gbr + example_flash_polygon.gbr + example_flash_rectangle.gbr + example_fully_coincident.gbr + example_guess_by_content.g0 + example_holes_dont_clear.gbr + example_level_holes.gbr + example_not_overlapping_contour.gbr + example_not_overlapping_touching.gbr + example_overlapping_contour.gbr + example_overlapping_touching.gbr + example_simple_contour.gbr + example_single_contour_1.gbr + example_single_contour_2.gbr + example_single_contour_3.gbr + example_am_exposure_modifier.gbr + bottom_copper.GBL + bottom_mask.GBS + bottom_silk.GBO + eagle_files/copper_bottom_l4.gbr + eagle_files/copper_inner_l2.gbr + eagle_files/copper_inner_l3.gbr + eagle_files/copper_top_l1.gbr + eagle_files/profile.gbr + eagle_files/silkscreen_bottom.gbr + eagle_files/silkscreen_top.gbr + eagle_files/soldermask_bottom.gbr + eagle_files/soldermask_top.gbr + eagle_files/solderpaste_bottom.gbr + eagle_files/solderpaste_top.gbr + multiline_read.ger + test_fine_lines_x.gbr + test_fine_lines_y.gbr + top_copper.GTL + top_mask.GTS + top_silk.GTO +'''.splitlines() if l ] + +MIN_REFERENCE_FILES = [ + 'example_two_square_boxes.gbr', + 'example_outline_with_arcs.gbr', + 'example_flash_circle.gbr', + 'example_flash_polygon.gbr', + 'example_flash_rectangle.gbr', + 'example_simple_contour.gbr', + 'example_am_exposure_modifier.gbr', + 'bottom_copper.GBL', + 'bottom_silk.GBO', + 'eagle_files/copper_bottom_l4.gbr' + ] @pytest.mark.filterwarnings('ignore:Deprecated.*statement found.*:DeprecationWarning') @pytest.mark.filterwarnings('ignore::SyntaxWarning') -@pytest.mark.parametrize('reference', [ l.strip() for l in ''' -board_outline.GKO -example_outline_with_arcs.gbr -example_two_square_boxes.gbr -example_coincident_hole.gbr -example_cutin.gbr -example_cutin_multiple.gbr -example_flash_circle.gbr -example_flash_obround.gbr -example_flash_polygon.gbr -example_flash_rectangle.gbr -example_fully_coincident.gbr -example_guess_by_content.g0 -example_holes_dont_clear.gbr -example_level_holes.gbr -example_not_overlapping_contour.gbr -example_not_overlapping_touching.gbr -example_overlapping_contour.gbr -example_overlapping_touching.gbr -example_simple_contour.gbr -example_single_contour_1.gbr -example_single_contour_2.gbr -example_single_contour_3.gbr -example_am_exposure_modifier.gbr -bottom_copper.GBL -bottom_mask.GBS -bottom_silk.GBO -eagle_files/copper_bottom_l4.gbr -eagle_files/copper_inner_l2.gbr -eagle_files/copper_inner_l3.gbr -eagle_files/copper_top_l1.gbr -eagle_files/profile.gbr -eagle_files/silkscreen_bottom.gbr -eagle_files/silkscreen_top.gbr -eagle_files/soldermask_bottom.gbr -eagle_files/soldermask_top.gbr -eagle_files/solderpaste_bottom.gbr -eagle_files/solderpaste_top.gbr -multiline_read.ger -test_fine_lines_x.gbr -test_fine_lines_y.gbr -top_copper.GTL -top_mask.GTS -top_silk.GTO -'''.splitlines() if l ]) +@pytest.mark.parametrize('reference', REFERENCE_FILES) def test_round_trip(temp_files, reference): tmp_gbr, tmp_png = temp_files ref = reference_path(reference) + GerberFile.open(ref).save(tmp_gbr) + mean, max = gerber_difference(ref, tmp_gbr, diff_out=tmp_png) assert mean < 1e-6 assert max < 0.1 +TEST_ANGLES = [90, 180, 270, 30, 1.5, 10, 360, 1024, -30, -90] +TEST_OFFSETS = [(0, 0), (100, 0), (0, 100), (2, 0), (10, 100)] + +@pytest.mark.filterwarnings('ignore:Deprecated.*statement found.*:DeprecationWarning') +@pytest.mark.filterwarnings('ignore::SyntaxWarning') +@pytest.mark.parametrize('reference', MIN_REFERENCE_FILES) +@pytest.mark.parametrize('angle', TEST_ANGLES) +def test_rotation(temp_files, reference, angle): + if 'flash_rectangle' in reference and angle == 1024: + # gerbv's rendering of this is broken, the hole is missing. + return + + tmp_gbr, tmp_png = temp_files + ref = reference_path(reference) + + f = GerberFile.open(ref) + f.rotate(deg_to_rad(angle)) + f.save(tmp_gbr) + + cx, cy = 0, to_gerbv_svg_units(10, unit='inch') + mean, max = gerber_difference(ref, tmp_gbr, diff_out=tmp_png, svg_transform=f'rotate({angle} {cx} {cy})') + assert mean < 1e-3 # relax mean criterion compared to above. + assert max < 0.9 + +@pytest.mark.filterwarnings('ignore:Deprecated.*statement found.*:DeprecationWarning') +@pytest.mark.filterwarnings('ignore::SyntaxWarning') +@pytest.mark.parametrize('reference', MIN_REFERENCE_FILES) +@pytest.mark.parametrize('angle', TEST_ANGLES) +@pytest.mark.parametrize('center', [(0, 0), (-10, -10), (10, 10), (10, 0), (0, -10), (-10, 10), (10, 20)]) +def test_rotation_center(temp_files, reference, angle, center): + tmp_gbr, tmp_png = temp_files + ref = reference_path(reference) + + f = GerberFile.open(ref) + f.rotate(deg_to_rad(angle), center=center) + f.save(tmp_gbr) + + # calculate circle center in SVG coordinates + size = (10, 10) # inches + cx, cy = to_gerbv_svg_units(center[0]), to_gerbv_svg_units(10, 'inch')-to_gerbv_svg_units(center[1], 'mm') + mean, max = gerber_difference(ref, tmp_gbr, diff_out=tmp_png, + svg_transform=f'rotate({angle} {cx} {cy})', + size=size) + assert mean < 1e-3 + assert max < 0.9 + +@pytest.mark.filterwarnings('ignore:Deprecated.*statement found.*:DeprecationWarning') +@pytest.mark.filterwarnings('ignore::SyntaxWarning') +@pytest.mark.parametrize('reference', MIN_REFERENCE_FILES) +@pytest.mark.parametrize('offset', TEST_OFFSETS) +def test_offset(temp_files, reference, offset): + tmp_gbr, tmp_png = temp_files + ref = reference_path(reference) + + f = GerberFile.open(ref) + f.offset(*offset) + f.save(tmp_gbr, settings=FileSettings(unit=f.unit, number_format=(4,7))) + + # flip y offset since svg's y axis is flipped compared to that of gerber + dx, dy = to_gerbv_svg_units(offset[0]), -to_gerbv_svg_units(offset[1]) + mean, max = gerber_difference(ref, tmp_gbr, diff_out=tmp_png, svg_transform=f'translate({dx} {dy})') + assert mean < 1e-4 + assert max < 0.9 + +@pytest.mark.filterwarnings('ignore:Deprecated.*statement found.*:DeprecationWarning') +@pytest.mark.filterwarnings('ignore::SyntaxWarning') +@pytest.mark.parametrize('reference', MIN_REFERENCE_FILES) +@pytest.mark.parametrize('angle', TEST_ANGLES) +@pytest.mark.parametrize('center', [(0, 0), (10, 0), (0, -10), (10, 20)]) +@pytest.mark.parametrize('offset', [(0, 0), (100, 0), (0, 100), (100, 100), (100, 10)]) +def test_combined(temp_files, reference, angle, center, offset): + tmp_gbr, tmp_png = temp_files + ref = reference_path(reference) + + f = GerberFile.open(ref) + f.rotate(deg_to_rad(angle), center=center) + f.offset(*offset) + f.save(tmp_gbr) + + size = (10, 10) # inches + cx, cy = to_gerbv_svg_units(center[0]), to_gerbv_svg_units(10, 'inch')-to_gerbv_svg_units(center[1], 'mm') + dx, dy = to_gerbv_svg_units(offset[0]), -to_gerbv_svg_units(offset[1]) + mean, max = gerber_difference(ref, tmp_gbr, diff_out=tmp_png, + svg_transform=f'rotate({anlge} {cx} {cy}) translate({dx} {dy})', + size=size) + assert mean < 1e-4 + assert max < 0.9 + -- cgit