Fix rotation bugs, all tests run through
This commit is contained in:
parent
e4941dd5e3
commit
f4b2e74923
11 changed files with 321 additions and 135 deletions
|
|
@ -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])])
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue