Fix rotation bugs, all tests run through

This commit is contained in:
jaseg 2021-12-30 16:40:14 +01:00
parent e4941dd5e3
commit f4b2e74923
11 changed files with 321 additions and 135 deletions

View file

@ -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])])

View file

@ -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:

View file

@ -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 \

View file

@ -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'<File settings: units={self.units}/{self.angle_units} notation={self.notation} zeros={self.zeros} number_format={self.number_format}>'
return f'<File settings: unit={self.unit}/{self.angle_unit} notation={self.notation} zeros={self.zeros} number_format={self.number_format}>'
def parse_gerber_value(self, value):
if not value:

View file

@ -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 ('<MO Coordinate unit mode statement>' % 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):

View file

@ -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)

View file

@ -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

View file

@ -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'<GerberFile with {len(self.apertures)} apertures, {len(self.objects)} objects>'
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 rotate(self, angle:'radians', center=(0,0)):
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.
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')

View file

@ -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.

View file

@ -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)

View file

@ -4,22 +4,28 @@
# Author: Hamilton Kibbe <ham@hamiltonkib.be>
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