diff options
-rw-r--r-- | gerbonara/gerber/aperture_macros/parse.py | 2 | ||||
-rw-r--r-- | gerbonara/gerber/apertures.py | 31 | ||||
-rw-r--r-- | gerbonara/gerber/gerber_statements.py | 2 | ||||
-rw-r--r-- | gerbonara/gerber/graphic_objects.py | 49 | ||||
-rw-r--r-- | gerbonara/gerber/graphic_primitives.py | 12 | ||||
-rw-r--r-- | gerbonara/gerber/rs274x.py | 78 | ||||
-rw-r--r-- | gerbonara/gerber/tests/conftest.py | 3 | ||||
-rw-r--r-- | gerbonara/gerber/tests/image_support.py | 33 | ||||
-rw-r--r-- | gerbonara/gerber/tests/test_rs274x.py | 103 |
9 files changed, 179 insertions, 134 deletions
diff --git a/gerbonara/gerber/aperture_macros/parse.py b/gerbonara/gerber/aperture_macros/parse.py index 35cb6c2..f1e2150 100644 --- a/gerbonara/gerber/aperture_macros/parse.py +++ b/gerbonara/gerber/aperture_macros/parse.py @@ -76,6 +76,8 @@ class ApertureMacro: primitive = ap.PRIMITIVE_CLASSES[int(primitive)](unit=unit, args=args) macro.primitives.append(primitive) + return macro + @property def name(self): if self._name is not None: diff --git a/gerbonara/gerber/apertures.py b/gerbonara/gerber/apertures.py index b478ad9..0b43822 100644 --- a/gerbonara/gerber/apertures.py +++ b/gerbonara/gerber/apertures.py @@ -1,6 +1,6 @@ import math -from dataclasses import dataclass, replace, astuple +from dataclasses import dataclass, replace, astuple, InitVar from .aperture_macros.parse import GenericMacros @@ -61,7 +61,7 @@ class Aperture: return {'hole_dia': self.hole_rect_h, 'hole_rect_h': self.hole_dia} -@dataclass(frozen=True) +@dataclass class CircleAperture(Aperture): gerber_shape_code = 'C' human_readable_shape = 'circle' @@ -96,7 +96,7 @@ class CircleAperture(Aperture): return strip_right(self.diameter, self.hole_dia, self.hole_rect_h) -@dataclass(frozen=True) +@dataclass class RectangleAperture(Aperture): gerber_shape_code = 'R' human_readable_shape = 'rect' @@ -134,7 +134,7 @@ class RectangleAperture(Aperture): return strip_right(self.w, self.h, self.hole_dia, self.hole_rect_h) -@dataclass(frozen=True) +@dataclass class ObroundAperture(Aperture): gerber_shape_code = 'O' human_readable_shape = 'obround' @@ -170,7 +170,7 @@ class ObroundAperture(Aperture): return strip_right(self.w, self.h, self.hole_dia, self.hole_rect_h) -@dataclass(frozen=True) +@dataclass class PolygonAperture(Aperture): gerber_shape_code = 'P' diameter : float @@ -187,7 +187,6 @@ class PolygonAperture(Aperture): flash = _flash_hole def _rotated(self): - self.rotation %= (2*math.pi / self.n_vertices) return self def to_macro(self): @@ -195,22 +194,22 @@ class PolygonAperture(Aperture): @property def params(self): + rotation = self.rotation % (2*math.pi / self.n_vertices) if self.rotation is not None else None if self.hole_dia is not None: - return self.diameter, self.n_vertices, self.rotation, self.hole_dia - elif self.rotation: - return self.diameter, self.n_vertices, self.rotation + return self.diameter, self.n_vertices, rotation, self.hole_dia + elif rotation is not None and not math.isclose(rotation, 0): + return self.diameter, self.n_vertices, rotation else: return self.diameter, self.n_vertices - +@dataclass class ApertureMacroInstance(Aperture): - params : [float] + macro : object + parameters : [float] rotation : float = 0 - def __init__(self, macro, *parameters): - self.params = parameters + def __post__init__(self, macro): self._primitives = macro.to_graphic_primitives(parameters) - self.macro = macro @property def gerber_shape_code(self): @@ -227,7 +226,7 @@ class ApertureMacroInstance(Aperture): return self.to_macro() def to_macro(self): - return type(self)(self.macro.rotated(self.rotation), self.params) + return replace(self, macro=macro.rotated(self.rotation)) def __eq__(self, other): return hasattr(other, 'macro') and self.macro == other.macro and \ @@ -236,6 +235,6 @@ class ApertureMacroInstance(Aperture): @property def params(self): - return astuple(self)[:-1] + return tuple(self.parameters) diff --git a/gerbonara/gerber/gerber_statements.py b/gerbonara/gerber/gerber_statements.py index 5f3363e..2bf909f 100644 --- a/gerbonara/gerber/gerber_statements.py +++ b/gerbonara/gerber/gerber_statements.py @@ -38,7 +38,7 @@ class FormatSpecStmt(ParamStmt): def to_gerber(self, settings): zeros = 'T' if settings.zeros == 'trailing' else 'L' # default to leading if "None" is specified - notation = 'A' if settings.notation == 'absolute' else 'I' + notation = 'I' if settings.notation == 'incremental' else 'A' # default to absolute number_format = str(settings.number_format[0]) + str(settings.number_format[1]) return f'%FS{zeros}{notation}X{number_format}Y{number_format}*%' diff --git a/gerbonara/gerber/graphic_objects.py b/gerbonara/gerber/graphic_objects.py index 47ed718..9902dee 100644 --- a/gerbonara/gerber/graphic_objects.py +++ b/gerbonara/gerber/graphic_objects.py @@ -1,5 +1,6 @@ -from dataclasses import dataclass, KW_ONLY +import math +from dataclasses import dataclass, KW_ONLY, astuple from . import graphic_primitives as gp from .gerber_statements import * @@ -160,57 +161,45 @@ class Slot(GerberObject): yield gp.Line(*self.p1, *self.p2, self.width, polarity_dark=self.polarity_dark) +@dataclass class Arc(GerberObject): - x : float - y : float - r : float - angle1 : float # radians! - angle2 : float # radians! + x1 : float + y1 : float + x2 : float + y2 : float + cx : float + cy : float + flipped : bool aperture : object - @classmethod - def from_coords(kls, start, end, center_delta, aperture, flipped=False, polarity_dark=True): - x0, y0 = start - x1, y1 = end - dx, dy = center_delta - cx, cy = x0+dx, y0+dy - angle1 = math.atan2(y0-cy, x0-cx) - angle2 = math.atan2(y1-cy, x1-cx) - aperture = self.aperture - if flipped: - angle1, angle2 = angle2, angle1 - r = math.sqrt(dx**2 + dy**2) - # r should be approximately (depending on coordinate resolution) equal for center->start and center->end - return kls(cx, cy, r, angle1, angle2, polarity_dark=polarity_dark) - def with_offset(self, dx, dy): return replace(self, x=self.x+dx, y=self.y+dy) @property def p1(self): - return self.x + self.r*sin(self.angle1), self.y + self.r*cos(self.angle1) + return self.x1, self.y1 @property def p2(self): - return self.x + self.r*sin(self.angle2), self.y + self.r*cos(self.angle2) + return self.x2, self.y2 @property def center(self): - return (self.x, self.y) + return self.x1 + self.cx, self.y1 + self.cy def rotate(self, rotation, cx=None, cy=None): - self.x, self.y = gp.rotate_point(self.x, self.y, rotation, cx, cy) - self.angle1 = (self.angle1+rotation) % (2*math.pi) - self.angle2 = (self.angle2+rotation) % (2*math.pi) + cx, 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 def to_primitives(self): - yield gp.Arc(self.x, self.y, self.r, self.angle1, self.angle2, self.aperture.equivalent_width, polarity_dark=self.polarity_dark) + 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_aperture(self.aperture) yield from gs.set_interpolation_mode(CircularCCWModeStmt) yield from gs.set_current_point(self.p1) - x2, y2 = self.p2 - yield InterpolateStmt(x2, y2, self.x-x2, self.y-y2) + yield InterpolateStmt(self.x2, self.y2, self.cx, self.cy) diff --git a/gerbonara/gerber/graphic_primitives.py b/gerbonara/gerber/graphic_primitives.py index 9518501..4810066 100644 --- a/gerbonara/gerber/graphic_primitives.py +++ b/gerbonara/gerber/graphic_primitives.py @@ -95,11 +95,13 @@ class Line(GraphicPrimitive): @dataclass class Arc(GraphicPrimitive): - x : float - y : float - r : float - angle1 : float # radians! - angle2 : float # radians! + x1 : float + y1 : float + x2 : float + y2 : float + cx : float + cy : float + flipped : bool width : float # FIXME bounds diff --git a/gerbonara/gerber/rs274x.py b/gerbonara/gerber/rs274x.py index 98e8d53..364cb43 100644 --- a/gerbonara/gerber/rs274x.py +++ b/gerbonara/gerber/rs274x.py @@ -156,7 +156,7 @@ class GerberFile(CamFile): yield ApertureDefStmt(number, aperture) - aperture_map[aperture] = number + aperture_map[id(aperture)] = number gs = GraphicsState(aperture_map=aperture_map) for primitive in self.objects: @@ -296,34 +296,64 @@ class GraphicsState: a, b, c, d = self._mat if not relative: - return (a*x + b*y + self.image_offset[0]), (c*x + d*y + self.image_offset[1]) + rx, ry = (a*x + b*y + self.image_offset[0]), (c*x + d*y + self.image_offset[1]) + print(f'map {x},{y} to {rx},{ry}') + return rx, ry else: # Apply mirroring, scale and rotation, but do not apply offset - return (a*x + b*y), (c*x + d*y) + rx, ry = (a*x + b*y), (c*x + d*y) + print(f'map {x},{y} to {rx},{ry}') + return rx, ry def flash(self, x, y): - return gp.Flash(self.aperture, *self.map_coord(x, y), polarity_dark=self.polarity_dark) + self.update_point(x, y) + return go.Flash(*self.map_coord(*self.point), self.aperture, polarity_dark=self.polarity_dark) def interpolate(self, x, y, i=None, j=None, aperture=True): + if self.point is None: + warnings.warn('D01 interpolation without preceding D02 move.', SyntaxWarning) + self.point = (0, 0) + old_point = self.map_coord(*self.update_point(x, y)) + if self.interpolation_mode == LinearModeStmt: 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)") - return self._create_line(x, y, aperture) + return self._create_line(old_point, self.map_coord(*self.point), aperture) else: - return self._create_arc(x, y, i, j, aperture) - - def _create_line(self, x, y, aperture=True): - old_point, self.point = self.point, self.map_coord(x, y) - return go.Line(*old_point, *self.point, self.aperture if aperture else None, polarity_dark=self.polarity_dark) + 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) + return self._create_line(old_point, self.map_coord(*self.point), aperture) - def _create_arc(self, x, y, i, j, aperture=True): - old_point, self.point = self.point, self.map_coord(x, y) + else: + if i is None: + warnings.warn('Arc is missing I value', SyntaxWarning) + i = 0 + if j is None: + warnings.warn('Arc is missing J value', SyntaxWarning) + j = 0 + return self._create_arc(old_point, self.map_coord(*self.point), (i, j), aperture) + + def _create_line(self, old_point, new_point, aperture=True): + return go.Line(*old_point, *new_point, self.aperture if aperture else None, polarity_dark=self.polarity_dark) + + def _create_arc(self, old_point, new_point, control_point, aperture=True): direction = 'ccw' if self.interpolation_mode == CircularCCWModeStmt else 'cw' - return go.Arc.from_coords(old_point, self.point, *self.map_coord(i, j, relative=True), + return go.Arc(*old_point, *new_point,* self.map_coord(*control_point, relative=True), flipped=(direction == 'cw'), aperture=(self.aperture if aperture else None), polarity_dark=self.polarity_dark) + def update_point(self, x, y): + print(f'update_point {x=} {y=}') + old_point = self.point + if x is None: + x = self.point[0] + if y is None: + y = self.point[1] + if self.point != (x, y): + self.point = (x, y) + return old_point + # Helpers for gerber generation def set_polarity(self, polarity_dark): if self.polarity_dark != polarity_dark: @@ -333,7 +363,7 @@ class GraphicsState: def set_aperture(self, aperture): if self.aperture != aperture: self.aperture = aperture - yield ApertureStmt(self.aperture_map[aperture]) + yield ApertureStmt(self.aperture_map[id(aperture)]) def set_current_point(self, point): if self.point != point: @@ -342,7 +372,7 @@ class GraphicsState: def set_interpolation_mode(self, mode): if self.interpolation_mode != mode: - gs.interpolation_mode = mode + self.interpolation_mode = mode yield mode() @@ -438,6 +468,7 @@ 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)): @@ -504,7 +535,7 @@ class GerberParser: raise SyntaxError("i/j coordinates given for D02/D03 operation (which doesn't take i/j)") if op in ('D2', 'D02'): - self.graphics_state.point = (x, y) + self.graphics_state.update_point(x, y) if self.current_region: # Start a new region for every outline. As gerber has no concept of fill rules or winding numbers, # it does not make a graphical difference, and it makes the implementation slightly easier. @@ -529,7 +560,7 @@ class GerberParser: def _parse_aperture_definition(self, match): # number, shape, modifiers - modifiers = [ float(val) for val in match['modifiers'].split(',') ] + modifiers = [ float(val) for val in match['modifiers'].split('X') ] if match['modifiers'].strip() else [] aperture_classes = { 'C': apertures.CircleAperture, @@ -542,7 +573,7 @@ class GerberParser: new_aperture = kls(*modifiers) elif (macro := self.aperture_macros.get(match['shape'])): - new_aperture = apertures.ApertureMacroInstance(match['shape'], macro, modifiers) + new_aperture = apertures.ApertureMacroInstance(macro, modifiers) else: raise ValueError(f'Aperture shape "{match["shape"]}" is unknown') @@ -556,7 +587,7 @@ class GerberParser: def _parse_format_spec(self, match): # This is a common problem in Eagle files, so just suppress it self.file_settings.zeros = {'L': 'leading', 'T': 'trailing'}.get(match['zero'], 'leading') - self.file_settings.notation = 'absolute' if match['notation'] == 'A' else 'incremental' + self.file_settings.notation = 'incremental' if match['notation'] == 'I' else 'absolute' if match['x'] != match['y']: raise SyntaxError(f'FS specifies different coordinate formats for X and Y ({match["x"]} != {match["y"]})') @@ -616,8 +647,9 @@ class GerberParser: self.graphics_state.output_axes = match['axes'] def _parse_image_polarity(self, match): - warnings.warn('Deprecated IP (image polarity) statement found. This deprecated since rev. I4 (Oct 2013).', - DeprecationWarning) + # Do not warn, this is still common. + # warnings.warn('Deprecated IP (image polarity) statement found. This deprecated since rev. I4 (Oct 2013).', + # DeprecationWarning) self.graphics_state.image_polarity = dict(POS='positive', NEG='negative')[match['polarity']] def _parse_image_rotation(self, match): @@ -657,9 +689,9 @@ class GerberParser: DeprecationWarning) self.target.comments.append('Replaced deprecated {match["mode"]} unit mode statement with MO statement') - def _parse_old_unit(self, match): + def _parse_old_notation(self, match): # FIXME make sure we always have FS at end of processing. - self.settings.notation = 'absolute' if match['mode'] == 'G90' else 'incremental' + self.file_settings.notation = 'absolute' if match['mode'] == 'G90' else 'incremental' 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') diff --git a/gerbonara/gerber/tests/conftest.py b/gerbonara/gerber/tests/conftest.py index 0ad2555..c8fd475 100644 --- a/gerbonara/gerber/tests/conftest.py +++ b/gerbonara/gerber/tests/conftest.py @@ -3,14 +3,13 @@ import pytest from .image_support import ImageDifference -@pytest.hookimpl(tryfirst=True, hookwrapper=True) def pytest_assertrepr_compare(op, left, right): if isinstance(left, ImageDifference) or isinstance(right, ImageDifference): diff = left if isinstance(left, ImageDifference) else right return [ f'Image difference assertion failed.', f' Reference: {diff.ref_path}', - f' Actual: {diff.out_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 ee8e6b9..e1b1c00 100644 --- a/gerbonara/gerber/tests/image_support.py +++ b/gerbonara/gerber/tests/image_support.py @@ -1,13 +1,28 @@ import subprocess from pathlib import Path import tempfile +import os +from functools import total_ordering import numpy as np +from PIL import Image + +class ImageDifference: + def __init__(self, value, ref_path, act_path): + self.value, self.ref_path, self.act_path = value, ref_path, act_path + + def __float__(self): + return float(self.value) + + def __eq__(self, other): + return float(self) == float(other) + + def __lt__(self, other): + return float(self) < float(other) + + def __str__(self): + return str(float(self)) -class ImageDifference(float): - def __init__(self, value, ref_path, out_path): - super().__init__(value) - self.ref_path, self.out_path = ref_path, out_path def run_cargo_cmd(cmd, args, **kwargs): if cmd.upper() in os.environ: @@ -22,16 +37,18 @@ def run_cargo_cmd(cmd, args, **kwargs): def svg_to_png(in_svg, out_png): run_cargo_cmd('resvg', [in_svg, out_png], check=True, stdout=subprocess.DEVNULL) -def gbr_to_svg(in_gbr, out_svg): +def gbr_to_svg(in_gbr, out_svg, origin=(0, 0), size=(10, 10)): + x, y = origin + w, h = size cmd = ['gerbv', '-x', 'svg', '--border=0', - #f'--origin={origin_x:.6f}x{origin_y:.6f}', f'--window_inch={width:.6f}x{height:.6f}', + f'--origin={x:.6f}x{y:.6f}', f'--window_inch={w:.6f}x{h:.6f}', '--foreground=#ffffff', '-o', str(out_svg), str(in_gbr)] subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) def gerber_difference(reference, actual): - with tempfile.NamedTemporaryFile(suffix='.svg') as out_svg,\ + with tempfile.NamedTemporaryFile(suffix='.svg') as act_svg,\ tempfile.NamedTemporaryFile(suffix='.svg') as ref_svg: gbr_to_svg(reference, ref_svg.name) @@ -58,6 +75,6 @@ def image_difference(reference, actual): ref, out = ref.mean(axis=2), out.mean(axis=2) # convert to grayscale delta = np.abs(out - ref).astype(float) / 255 - return ImageDifference(delta.mean(), ref, out) + return ImageDifference(delta.mean(), reference, actual) diff --git a/gerbonara/gerber/tests/test_rs274x.py b/gerbonara/gerber/tests/test_rs274x.py index beaea11..f359ca9 100644 --- a/gerbonara/gerber/tests/test_rs274x.py +++ b/gerbonara/gerber/tests/test_rs274x.py @@ -3,6 +3,7 @@ # Author: Hamilton Kibbe <ham@hamiltonkib.be> import os +import re import pytest import functools import tempfile @@ -18,8 +19,10 @@ from .image_support import gerber_difference fail_dir = Path('gerbonara_test_failures') @pytest.fixture(scope='session', autouse=True) def clear_failure_dir(request): - if fail_dir.is_dir(): - shutil.rmtree(fail_dir) + for f in fail_dir.glob('*.gbr'): + f.unlink() + +reference_path = lambda reference: Path(__file__).parent / 'resources' / reference @pytest.fixture def tmp_gbr(request): @@ -30,61 +33,63 @@ def tmp_gbr(request): if request.node.rep_call.failed: module, _, test_name = request.node.nodeid.rpartition('::') _test, _, test_name = test_name.partition('_') - test_name = test_name.replace('[', '_').replace(']', '_') + test_name, _, _ext = test_name.partition('.') + test_name = re.sub(r'[^\w\d]', '_', test_name) fail_dir.mkdir(exist_ok=True) perm_path = fail_dir / f'failure_{test_name}.gbr' shutil.copy(tmp_out_gbr.name, perm_path) - print('Failing output saved to {perm_path}') + print(f'Failing output saved to {perm_path}') + print(f'Reference file is {reference_path(request.node.funcargs["reference"])}') +@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 -''' +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 ]) def test_round_trip(tmp_gbr, reference): - ref = Path(__file__).parent / 'resources' / reference + ref = reference_path(reference) GerberFile.open(ref).save(tmp_gbr) - assert gerber_difference(ref, tmp_gbr) < 0.02 + assert gerber_difference(ref, tmp_gbr) < 1e-5 |