Basic round-trip works

This commit is contained in:
jaseg 2021-12-29 19:58:20 +01:00
parent 30dabef9ee
commit 3fb26e6940
21 changed files with 593 additions and 1414 deletions

View file

@ -23,4 +23,3 @@ files in python.
"""
from .layers import load_layer, load_layer_data
from .pcb import PCB

View file

@ -8,15 +8,14 @@ import re
import ast
MILLIMETERS_PER_INCH = 25.4
def expr(obj):
return obj if isinstance(obj, Expression) else ConstantExpression(obj)
class Expression(object):
@property
def value(self):
return self
class Expression:
def optimized(self, variable_binding={}):
return self
@ -79,17 +78,17 @@ class UnitExpression(Expression):
return f'<{self._expr.to_gerber()} {self.unit}>'
def converted(self, unit):
if unit is None or self.unit == unit:
if self.unit is None or unit is None or self.unit == unit:
return self._expr
elif unit == 'mm':
return self._expr * MILLIMETERS_PER_INCH
elif unit == 'inch':
return self._expr / MILLIMETERS_PER_INCH)
return self._expr / MILLIMETERS_PER_INCH
else:
raise ValueError('invalid unit, must be "inch" or "mm".')
raise ValueError(f'invalid unit {unit}, must be "inch" or "mm".')
class ConstantExpression(Expression):

View file

@ -9,10 +9,8 @@ import ast
import copy
import math
import primitive as ap
from expression import *
from .. import apertures
from . import primitive as ap
from .expression import *
def rad_to_deg(x):
return (x / math.pi) * 180
@ -54,10 +52,10 @@ class ApertureMacro:
self.primitives = primitives or []
@classmethod
def parse_macro(cls, name, macro, unit):
def parse_macro(cls, name, body, unit):
macro = cls(name)
blocks = re.sub(r'\s', '', macro).split('*')
blocks = re.sub(r'\s', '', body).split('*')
for block in blocks:
if not (block := block.strip()): # empty block
continue
@ -74,14 +72,14 @@ class ApertureMacro:
else: # primitive
primitive, *args = block.split(',')
args = [_parse_expression(arg) for arg in args]
primitive = ap.PRIMITIVE_CLASSES[int(primitive)](unit=unit, args=args
args = [ _parse_expression(arg) for arg in args ]
primitive = ap.PRIMITIVE_CLASSES[int(primitive)](unit=unit, args=args)
macro.primitives.append(primitive)
@property
def name(self):
if self.name is not None:
return self.name
if self._name is not None:
return self._name
else:
return f'gn_{hash(self)}'
@ -120,31 +118,34 @@ class ApertureMacro:
return copy
class GenericMacros:
deg_per_rad = 180 / math.pi
cons, var = VariableExpression
_generic_hole = lambda n: [
ap.Circle(exposure=0, diameter=var(n), x=0, y=0),
ap.Rectangle(exposure=0, w=var(n), h=var(n+1), x=0, y=0, rotation=var(n+2) * deg_per_rad)]
cons, var = ConstantExpression, VariableExpression
deg_per_rad = 180 / math.pi
circle = ApertureMacro([
ap.Circle(exposure=1, diameter=var(1), x=0, y=0, rotation=var(4) * deg_per_rad),
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])]
# Initialize all these with "None" units so they inherit file units, and do not convert their arguments.
circle = ApertureMacro('GNC', [
ap.Circle(None, [1, var(1), 0, 0, var(4) * deg_per_rad]),
*_generic_hole(2)])
rect = ApertureMacro([
ap.Rectangle(exposure=1, w=var(1), h=var(2), x=0, y=0, rotation=var(5) * deg_per_rad),
rect = ApertureMacro('GNR', [
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([
ap.Rectangle(exposure=1, w=var(1), h=var(2), x=0, y=0, rotation=var(5) * deg_per_rad),
ap.Circle(exposure=1, diameter=var(2), x=+var(1)/2, y=0, rotation=var(5) * deg_per_rad),
ap.Circle(exposure=1, diameter=var(2), x=-var(1)/2, y=0, rotation=var(5) * deg_per_rad),
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]),
*_generic_hole(3) ])
polygon = ApertureMacro([
ap.Polygon(exposure=1, n_vertices=var(2), x=0, y=0, diameter=var(1), rotation=var(3) * deg_per_rad),
pa.Circle(exposure=0, diameter=var(4), x=0, y=0)])
polygon = ApertureMacro('GNP', [
ap.Polygon(None, [1, var(2), 0, 0, var(1), var(3) * deg_per_rad]),
ap.Circle(None, [0, var(4), 0, 0])])
if __name__ == '__main__':

View file

@ -7,9 +7,9 @@
import contextlib
import math
from expression import Expression, UnitExpression, ConstantExpression, expr
from .expression import Expression, UnitExpression, ConstantExpression, expr
from .. import graphic_primitivese as gp
from .. import graphic_primitives as gp
def point_distance(a, b):
@ -41,7 +41,7 @@ class Primitive:
raise ValueError(f'Too few arguments ({len(args)}) for aperture macro primitive {self.code} ({type(self)})')
def to_gerber(self, unit=None):
return self.code + ',' + ','.join(
return f'{self.code},' + ','.join(
getattr(self, name).to_gerber(unit) for name in type(self).__annotations__) + '*'
def __str__(self):
@ -149,6 +149,7 @@ class Polygon(Primitive):
class Thermal(Primitive):
code = 7
exposure : Expression
# center x/y
x : UnitExpression
y : UnitExpression
@ -216,6 +217,8 @@ class Outline(Primitive):
class Comment:
code = 0
def __init__(self, comment):
self.comment = comment
@ -233,6 +236,6 @@ PRIMITIVE_CLASSES = {
Thermal,
]},
# alternative codes
2: VectorLinePrimitive,
2: VectorLine,
}

View file

@ -1,9 +1,11 @@
import math
from dataclasses import dataclass, replace
from aperture_macros.parse import GenericMacros
from dataclasses import dataclass, replace, astuple
from .aperture_macros.parse import GenericMacros
from . import graphic_primitives as gp
import graphic_primitives as gp
def _flash_hole(self, x, y):
if self.hole_rect_h is not None:
@ -11,6 +13,13 @@ def _flash_hole(self, x, y):
else:
return self.primitives(x, y), Circle((x, y), self.hole_dia, polarity_dark=False)
def strip_right(*args):
args = list(args)
while args and args[-1] is None:
args.pop()
return args
class Aperture:
@property
def hole_shape(self):
@ -25,12 +34,12 @@ class Aperture:
@property
def params(self):
return dataclasses.astuple(self)
return astuple(self)
def flash(self, x, y):
return self.primitives(x, y)
@parameter
@property
def equivalent_width(self):
raise ValueError('Non-circular aperture used in interpolation statement, line width is not properly defined.')
@ -39,8 +48,8 @@ class Aperture:
# we emulate this parameter. Our circle, rectangle and oblong classes below have a rotation parameter. Only at
# export time during to_gerber, this parameter is evaluated.
actual_inst = self._rotated()
params = 'X'.join(f'{par:.4}' for par in actual_inst.params)
return f'{actual_inst.aperture.gerber_shape_code},{params}'
params = 'X'.join(f'{float(par):.4}' for par in actual_inst.params if par is not None)
return f'{actual_inst.gerber_shape_code},{params}'
def __eq__(self, other):
return hasattr(other, to_gerber) and self.to_gerber() == other.to_gerber()
@ -57,7 +66,7 @@ class CircleAperture(Aperture):
gerber_shape_code = 'C'
human_readable_shape = 'circle'
diameter : float
hole_dia : float = 0
hole_dia : float = None
hole_rect_h : float = None
rotation : float = 0 # radians; for rectangular hole; see hack in Aperture.to_gerber
@ -69,12 +78,12 @@ class CircleAperture(Aperture):
flash = _flash_hole
@parameter
@property
def equivalent_width(self):
return self.diameter
def rotated(self):
if math.isclose(rotation % (2*math.pi), 0) or self.hole_rect_h is None:
def _rotated(self):
if math.isclose(self.rotation % (2*math.pi), 0) or self.hole_rect_h is None:
return self
else:
return self.to_macro(self.rotation)
@ -82,6 +91,10 @@ class CircleAperture(Aperture):
def to_macro(self):
return ApertureMacroInstance(GenericMacros.circle, *self.params)
@property
def params(self):
return strip_right(self.diameter, self.hole_dia, self.hole_rect_h)
@dataclass(frozen=True)
class RectangleAperture(Aperture):
@ -89,7 +102,7 @@ class RectangleAperture(Aperture):
human_readable_shape = 'rect'
w : float
h : float
hole_dia : float = 0
hole_dia : float = None
hole_rect_h : float = None
rotation : float = 0 # radians
@ -101,7 +114,7 @@ class RectangleAperture(Aperture):
flash = _flash_hole
@parameter
@property
def equivalent_width(self):
return math.sqrt(self.w**2 + self.h**2)
@ -116,6 +129,10 @@ class RectangleAperture(Aperture):
def to_macro(self):
return ApertureMacroInstance(GenericMacros.rect, *self.params)
@property
def params(self):
return strip_right(self.w, self.h, self.hole_dia, self.hole_rect_h)
@dataclass(frozen=True)
class ObroundAperture(Aperture):
@ -123,7 +140,7 @@ class ObroundAperture(Aperture):
human_readable_shape = 'obround'
w : float
h : float
hole_dia : float = 0
hole_dia : float = None
hole_rect_h : float = None
rotation : float = 0
@ -148,6 +165,10 @@ class ObroundAperture(Aperture):
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)
@property
def params(self):
return strip_right(self.w, self.h, self.hole_dia, self.hole_rect_h)
@dataclass(frozen=True)
class PolygonAperture(Aperture):
@ -155,7 +176,7 @@ class PolygonAperture(Aperture):
diameter : float
n_vertices : int
rotation : float = 0
hole_dia : float = 0
hole_dia : float = None
def primitives(self, x, y):
return [ gp.RegularPolygon(x, y, diameter, n_vertices, rotation=self.rotation) ]
@ -172,6 +193,15 @@ class PolygonAperture(Aperture):
def to_macro(self):
return ApertureMacroInstance(GenericMacros.polygon, *self.params)
@property
def params(self):
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
else:
return self.diameter, self.n_vertices
class ApertureMacroInstance(Aperture):
params : [float]
@ -204,4 +234,8 @@ class ApertureMacroInstance(Aperture):
hasattr(other, 'params') and self.params == other.params and \
hasattr(other, 'rotation') and self.rotation == other.rotation
@property
def params(self):
return astuple(self)[:-1]

View file

@ -16,7 +16,7 @@
# limitations under the License.
from dataclasses import dataclass
from copy import deepcopy
@dataclass
class FileSettings:
@ -28,7 +28,7 @@ class FileSettings:
suppressed, while the Excellon format specifies which zeros are
included. This function uses the Gerber-file convention, so an
Excellon file in LZ (leading zeros) mode would use
`zero_suppression='trailing'`
`zeros='trailing'`
'''
notation : str = 'absolute'
units : str = 'inch'
@ -38,24 +38,27 @@ class FileSettings:
# input validation
def __setattr__(self, name, value):
elif name == 'notation' and value not in ['inch', 'mm']:
raise ValueError('Units must be either "inch" or "mm"')
elif name == 'units' and value not in ['absolute', 'incremental']:
raise ValueError('Notation must be either "absolute" or "incremental"')
if name == 'units' and value not in ['inch', 'mm']:
raise ValueError(f'Units 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('Angle units may be "degrees" or "radians"')
raise ValueError(f'Angle units may be "degrees" or "radians", not {value}')
elif name == 'zeros' and value not in [None, 'leading', 'trailing']:
raise ValueError('zero_suppression must be either "leading" or "trailing" or None')
raise ValueError(f'zeros must be either "leading" or "trailing" or None, not {value}')
elif name == 'number_format':
if len(value) != 2:
raise ValueError('Number format must be a (integer, fractional) tuple of integers')
raise ValueError(f'Number format must be a (integer, fractional) tuple of integers, not {value}')
if value[0] > 6 or value[1] > 7:
raise ValueError('Requested precision is too high. Only up to 6.7 digits are supported by spec.')
raise ValueError(f'Requested precision of {value} is too high. Only up to 6.7 digits are supported by spec.')
super().__setattr__(name, value)
def copy(self):
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}>'
@ -74,9 +77,7 @@ class FileSettings:
sign = '-' if value[0] == '-' else ''
value = value.lstrip('+-')
missing_digits = MAX_DIGITS - len(value)
if self.zero_suppression == 'leading':
if self.zeros == 'leading':
return float(sign + value[:-decimal_digits] + '.' + value[-decimal_digits:])
else: # no or trailing zero suppression
@ -90,13 +91,13 @@ class FileSettings:
# negative sign affects padding, so deal with it at the end...
sign = '-' if value < 0 else ''
num = format(abs(value), f'0{integer_digits+decimal_digits+1}.{decimal_digits}f')
num = format(abs(value), f'0{integer_digits+decimal_digits+1}.{decimal_digits}f').replace('.', '')
# Suppression...
if self.zero_suppression == 'trailing':
if self.zeros == 'trailing':
num = num.rstrip('0')
elif self.zero_suppression == 'leading':
elif self.zeros == 'leading':
num = num.lstrip('0')
# Edge case. Per Gerber spec if the value is 0 we should return a single '0' in all cases, see page 77.
@ -106,49 +107,11 @@ class FileSettings:
return sign + (num or '0')
class CamFile(object):
""" Base class for Gerber/Excellon files.
Provides a common set of settings parameters.
Parameters
----------
settings : FileSettings
The current file configuration.
primitives : iterable
List of primitives in the file.
filename : string
Name of the file that this CamFile represents.
layer_name : string
Name of the PCB layer that the file represents
Attributes
----------
settings : FileSettings
File settings as a FileSettings object
notation : string
File notation setting. May be either 'absolute' or 'incremental'
units : string
File units setting. May be 'inch' or 'mm'
zero_suppression : string
File zero-suppression setting. May be either 'leading' or 'trailling'
format : tuple (<int>, <int>)
File decimal representation format as a tuple of (integer digits,
decimal digits)
"""
def __init__(self, settings=None, primitives=None,
filename=None, layer_name=None):
self.settings = settings if settings is not None else FileSettings()
class CamFile:
def __init__(self, filename=None, layer_name=None):
self.filename = filename
self.layer_name = layer_name
self.import_settings = None
@property
def bounds(self):

View file

@ -29,7 +29,7 @@ import operator
from .cam import CamFile, FileSettings
from .excellon_statements import *
from .excellon_tool import ExcellonToolDefinitionParser
from .primitives import Drill, Slot
from .graphic_objects import Drill, Slot
from .utils import inch, metric

View file

@ -24,7 +24,7 @@ Excellon Statements
import re
import uuid
import itertools
from .utils import (parse_gerber_value, write_gerber_value, decimal_string,
from .utils import (decimal_string,
inch, metric)
@ -155,23 +155,21 @@ class ExcellonTool(ExcellonStatement):
commands = pairwise(re.split('([BCFHSTZ])', line)[1:])
args = {}
args['id'] = id
nformat = settings.format
zero_suppression = settings.zero_suppression
for cmd, val in commands:
if cmd == 'B':
args['retract_rate'] = parse_gerber_value(val, nformat, zero_suppression)
args['retract_rate'] = settings.parse_gerber_value(val)
elif cmd == 'C':
args['diameter'] = parse_gerber_value(val, nformat, zero_suppression)
args['diameter'] = settings.parse_gerber_value(val)
elif cmd == 'F':
args['feed_rate'] = parse_gerber_value(val, nformat, zero_suppression)
args['feed_rate'] = settings.parse_gerber_value(val)
elif cmd == 'H':
args['max_hit_count'] = parse_gerber_value(val, nformat, zero_suppression)
args['max_hit_count'] = settings.parse_gerber_value(val)
elif cmd == 'S':
args['rpm'] = 1000 * parse_gerber_value(val, nformat, zero_suppression)
args['rpm'] = 1000 * settings.parse_gerber_value(val)
elif cmd == 'T':
args['number'] = int(val)
elif cmd == 'Z':
args['depth_offset'] = parse_gerber_value(val, nformat, zero_suppression)
args['depth_offset'] = settings.parse_gerber_value(val)
if plated != ExcellonTool.PLATED_UNKNOWN:
# Sometimees we can can parse the plating status
@ -215,24 +213,22 @@ class ExcellonTool(ExcellonStatement):
def to_excellon(self, settings=None):
if self.settings and not settings:
settings = self.settings
fmt = settings.format
zs = settings.zero_suppression
stmt = 'T%02d' % self.number
if self.retract_rate is not None:
stmt += 'B%s' % write_gerber_value(self.retract_rate, fmt, zs)
stmt += 'B%s' % settings.write_gerber_value(self.retract_rate)
if self.feed_rate is not None:
stmt += 'F%s' % write_gerber_value(self.feed_rate, fmt, zs)
stmt += 'F%s' % settings.write_gerber_value(self.feed_rate)
if self.max_hit_count is not None:
stmt += 'H%s' % write_gerber_value(self.max_hit_count, fmt, zs)
stmt += 'H%s' % settings.write_gerber_value(self.max_hit_count)
if self.rpm is not None:
if self.rpm < 100000.:
stmt += 'S%s' % write_gerber_value(self.rpm / 1000., fmt, zs)
stmt += 'S%s' % settings.write_gerber_value(self.rpm / 1000.)
else:
stmt += 'S%g' % (self.rpm / 1000.)
if self.diameter is not None:
stmt += 'C%s' % decimal_string(self.diameter, fmt[1], True)
if self.depth_offset is not None:
stmt += 'Z%s' % write_gerber_value(self.depth_offset, fmt, zs)
stmt += 'Z%s' % settings.write_gerber_value(self.depth_offset)
return stmt
def to_inch(self):
@ -381,14 +377,11 @@ class CoordinateStmt(ExcellonStatement):
y_coord = None
if line[0] == 'X':
splitline = line.strip('X').split('Y')
x_coord = parse_gerber_value(splitline[0], settings.format,
settings.zero_suppression)
x_coord = settings.parse_gerber_value(splitline[0])
if len(splitline) == 2:
y_coord = parse_gerber_value(splitline[1], settings.format,
settings.zero_suppression)
y_coord = settings.parse_gerber_value(splitline[1])
else:
y_coord = parse_gerber_value(line.strip(' Y'), settings.format,
settings.zero_suppression)
y_coord = settings.parse_gerber_value(line.strip(' Y'))
c = cls(x_coord, y_coord, **kwargs)
c.units = settings.units
return c
@ -406,11 +399,9 @@ class CoordinateStmt(ExcellonStatement):
if self.mode == "LINEAR":
stmt += "G01"
if self.x is not None:
stmt += 'X%s' % write_gerber_value(self.x, settings.format,
settings.zero_suppression)
stmt += 'X%s' % settings.write_gerber_value(self.x)
if self.y is not None:
stmt += 'Y%s' % write_gerber_value(self.y, settings.format,
settings.zero_suppression)
stmt += 'Y%s' % settings.write_gerber_value(self.y)
return stmt
def to_inch(self):
@ -453,11 +444,9 @@ class RepeatHoleStmt(ExcellonStatement):
'(?P<ydelta>[+\-]?\d*\.?\d*)?').match(line)
stmt = match.groupdict()
count = int(stmt['rcount'])
xdelta = (parse_gerber_value(stmt['xdelta'], settings.format,
settings.zero_suppression)
xdelta = (settings.parse_gerber_value(stmt['xdelta'])
if stmt['xdelta'] is not '' else None)
ydelta = (parse_gerber_value(stmt['ydelta'], settings.format,
settings.zero_suppression)
ydelta = (settings.parse_gerber_value(stmt['ydelta'])
if stmt['ydelta'] is not '' else None)
c = cls(count, xdelta, ydelta, **kwargs)
c.units = settings.units
@ -472,11 +461,9 @@ class RepeatHoleStmt(ExcellonStatement):
def to_excellon(self, settings):
stmt = 'R%d' % self.count
if self.xdelta is not None and self.xdelta != 0.0:
stmt += 'X%s' % write_gerber_value(self.xdelta, settings.format,
settings.zero_suppression)
stmt += 'X%s' % settings.write_gerber_value(self.xdelta)
if self.ydelta is not None and self.ydelta != 0.0:
stmt += 'Y%s' % write_gerber_value(self.ydelta, settings.format,
settings.zero_suppression)
stmt += 'Y%s' % settings.write_gerber_value(self.ydelta)
return stmt
def to_inch(self):
@ -604,11 +591,9 @@ class EndOfProgramStmt(ExcellonStatement):
match = re.compile(r'M30X?(?P<x>\d*\.?\d*)?Y?'
'(?P<y>\d*\.?\d*)?').match(line)
stmt = match.groupdict()
x = (parse_gerber_value(stmt['x'], settings.format,
settings.zero_suppression)
x = (settings.parse_gerber_value(stmt['x'])
if stmt['x'] is not '' else None)
y = (parse_gerber_value(stmt['y'], settings.format,
settings.zero_suppression)
y = (settings.parse_gerber_value(stmt['y'])
if stmt['y'] is not '' else None)
c = cls(x, y, **kwargs)
c.units = settings.units
@ -619,12 +604,12 @@ class EndOfProgramStmt(ExcellonStatement):
self.x = x
self.y = y
def to_excellon(self, settings=None):
def to_excellon(self, settings):
stmt = 'M30'
if self.x is not None:
stmt += 'X%s' % write_gerber_value(self.x)
stmt += 'X%s' % settings.write_gerber_value(self.x)
if self.y is not None:
stmt += 'Y%s' % write_gerber_value(self.y)
stmt += 'Y%s' % settings.write_gerber_value(self.y)
return stmt
def to_inch(self):
@ -878,14 +863,11 @@ class SlotStmt(ExcellonStatement):
if line[0] == 'X':
splitline = line.strip('X').split('Y')
x_coord = parse_gerber_value(splitline[0], settings.format,
settings.zero_suppression)
x_coord = settings.parse_gerber_value(splitline[0])
if len(splitline) == 2:
y_coord = parse_gerber_value(splitline[1], settings.format,
settings.zero_suppression)
y_coord = settings.parse_gerber_value(splitline[1])
else:
y_coord = parse_gerber_value(line.strip(' Y'), settings.format,
settings.zero_suppression)
y_coord = settings.parse_gerber_value(line.strip(' Y'))
return (x_coord, y_coord)
@ -902,20 +884,16 @@ class SlotStmt(ExcellonStatement):
stmt = ''
if self.x_start is not None:
stmt += 'X%s' % write_gerber_value(self.x_start, settings.format,
settings.zero_suppression)
stmt += 'X%s' % settings.write_gerber_value(self.x_start)
if self.y_start is not None:
stmt += 'Y%s' % write_gerber_value(self.y_start, settings.format,
settings.zero_suppression)
stmt += 'Y%s' % settings.write_gerber_value(self.y_start)
stmt += 'G85'
if self.x_end is not None:
stmt += 'X%s' % write_gerber_value(self.x_end, settings.format,
settings.zero_suppression)
stmt += 'X%s' % settings.write_gerber_value(self.x_end)
if self.y_end is not None:
stmt += 'Y%s' % write_gerber_value(self.y_end, settings.format,
settings.zero_suppression)
stmt += 'Y%s' % settings.write_gerber_value(self.y_end)
return stmt

View file

@ -20,7 +20,6 @@ Gerber (RS-274X) Statements
**Gerber RS-274X file statement classes**
"""
from utils import parse_gerber_value, write_gerber_value, decimal_string, inch, metric
class Statement:
pass
@ -38,7 +37,7 @@ class FormatSpecStmt(ParamStmt):
""" FS - Gerber Format Specification Statement """
def to_gerber(self, settings):
zeros = 'L' if settings.zero_suppression == 'leading' else 'T'
zeros = 'T' if settings.zeros == 'trailing' else 'L' # default to leading if "None" is specified
notation = 'A' if settings.notation == 'absolute' else 'I'
number_format = str(settings.number_format[0]) + str(settings.number_format[1])
@ -84,7 +83,7 @@ class ApertureDefStmt(ParamStmt):
self.aperture = aperture
def to_gerber(self, settings=None):
return '%ADD{self.number}{self.aperture.to_gerber()}*%'
return f'%ADD{self.number}{self.aperture.to_gerber()}*%'
def __str__(self):
return f'<AD aperture def for {str(self.aperture).strip("<>")}>'
@ -96,7 +95,8 @@ class ApertureMacroStmt(ParamStmt):
def __init__(self, macro):
self.macro = macro
def to_gerber(self, unit=None):
def to_gerber(self, settings=None):
unit = settings.units if settings else None
return f'%AM{self.macro.name}*\n{self.macro.to_gerber(unit=unit)}*\n%'
def __str__(self):
@ -107,8 +107,8 @@ class ImagePolarityStmt(ParamStmt):
""" IP - Image Polarity Statement. (Deprecated) """
def to_gerber(self, settings):
ip = 'POS' if settings.image_polarity == 'positive' else 'NEG'
return f'%IP{ip}*%'
#ip = 'POS' if settings.image_polarity == 'positive' else 'NEG'
return f'%IPPOS*%'
def __str__(self):
return '<IP Image Polarity>'
@ -125,16 +125,16 @@ class CoordStmt(Statement):
for var in 'xyij':
val = getattr(self, var)
if val is not None:
ret += var.upper() + write_gerber_value(val, settings.number_format, settings.zero_suppression)
ret += var.upper() + settings.write_gerber_value(val)
return ret + self.code + '*'
def __str__(self):
if self.i is None:
return f'<{self.__name__.strip()} x={self.x} y={self.y}>'
else
return f'<{self.__name__.strip()} x={self.x} y={self.y} i={self.i} j={self.j]>'
else:
return f'<{self.__name__.strip()} x={self.x} y={self.y} i={self.i} j={self.j}>'
class InterpolateStmt(Statement):
class InterpolateStmt(CoordStmt):
""" D01 Interpolation """
code = 'D01'
@ -148,7 +148,7 @@ class FlashStmt(CoordStmt):
class InterpolationModeStmt(Statement):
""" G01 / G02 / G03 interpolation mode statement """
def to_gerber(self, **_kwargs):
def to_gerber(self, settings=None):
return self.code + '*'
def __str__(self):
@ -205,9 +205,6 @@ class CommentStmt(Statement):
class EofStmt(Statement):
""" M02 EOF Statement """
def __init__(self):
Statement.__init__(self, "EOF")
def to_gerber(self, settings=None):
return 'M02*'
@ -218,7 +215,7 @@ class UnknownStmt(Statement):
def __init__(self, line):
self.line = line
def to_gerber(self, settings):
def to_gerber(self, settings=None):
return self.line
def __str__(self):

View file

@ -1,6 +1,10 @@
import graphic_primitives as gp
from dataclasses import dataclass, KW_ONLY
from . import graphic_primitives as gp
from .gerber_statements import *
@dataclass
class GerberObject:
_ : KW_ONLY
polarity_dark : bool = True
@ -73,6 +77,7 @@ class Region(GerberObject):
yield RegionEndStmt()
@dataclass
class Line(GerberObject):
# Line with *round* end caps.
x1 : float
@ -109,6 +114,52 @@ class Line(GerberObject):
yield InterpolateStmt(*self.p2)
@dataclass
class Drill(GerberObject):
x : float
y : float
diameter : float
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):
self.x, self.y = gp.rotate_point(self.x, self.y, angle, cx, cy)
def to_primitives(self):
yield gp.Circle(self.x, self.y, self.diameter/2)
@dataclass
class Slot(GerberObject):
x1 : float
y1 : float
x2 : float
y2 : float
width : float
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
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)
@property
def p1(self):
return self.x1, self.y1
@property
def p2(self):
return self.x2, self.y2
def to_primitives(self):
yield gp.Line(*self.p1, *self.p2, self.width, polarity_dark=self.polarity_dark)
class Arc(GerberObject):
x : float
y : float

View file

@ -4,7 +4,7 @@ import itertools
from dataclasses import dataclass, KW_ONLY, replace
from gerber_statements import *
from .gerber_statements import *
class GraphicPrimitive:
@ -69,10 +69,10 @@ class ArcPoly(GraphicPrimitive):
# list of (x : float, y : float) tuples. Describes closed outline, i.e. first and last point are considered
# connected.
outline : list(tuple(float))
outline : [(float,)]
# list of radii of segments, must be either None (all segments are straight lines) or same length as outline.
# Straight line segments have None entry.
arc_centers : list(tuple(float))
arc_centers : [(float,)]
@property
def segments(self):
@ -116,7 +116,7 @@ class Rectangle(GraphicPrimitive):
def bounds(self):
return ((self.x, self.y), (self.x+self.w, self.y+self.h))
@prorperty
@property
def center(self):
return self.x + self.w/2, self.y + self.h/2

View file

@ -16,10 +16,10 @@
# See the License for the specific language governing permissions and
# limitations under the License.
from dataclasses import dataclass
import math
import re
from .cam import CamFile, FileSettings
from .primitives import TestRecord
# Net Name Variables
_NNAME = re.compile(r'^NNAME\d+$')
@ -50,6 +50,11 @@ def read(filename):
# File object should use settings from source file by default.
return IPCNetlist.from_file(filename)
@dataclass
class TestRecord:
position : [float]
net_name : str
layer : str
def loads(data, filename=None):
""" Generate an IPCNetlist object from IPC-D-356 data in memory

View file

@ -19,7 +19,6 @@ import os
import re
from collections import namedtuple
from . import common
from .excellon import ExcellonFile
from .ipc356 import IPCNetlist
@ -294,3 +293,105 @@ class InternalLayer(PCBLayer):
if not hasattr(other, 'order'):
raise TypeError()
return (self.order <= other.order)
class PCB:
@classmethod
def from_directory(cls, directory, board_name=None, verbose=False):
layers = []
names = set()
# Validate
directory = os.path.abspath(directory)
if not os.path.isdir(directory):
raise TypeError('{} is not a directory.'.format(directory))
# Load gerber files
for filename in os.listdir(directory):
try:
camfile = gerber_read(os.path.join(directory, filename))
layer = PCBLayer.from_cam(camfile)
layers.append(layer)
name = os.path.splitext(filename)[0]
if len(os.path.splitext(filename)) > 1:
_name, ext = os.path.splitext(name)
if ext[1:] in layer_signatures(layer.layer_class):
name = _name
if layer.layer_class == 'drill' and 'drill' in ext:
name = _name
names.add(name)
if verbose:
print('[PCB]: Added {} layer <{}>'.format(layer.layer_class,
filename))
except ParseError:
if verbose:
print('[PCB]: Skipping file {}'.format(filename))
except IOError:
if verbose:
print('[PCB]: Skipping file {}'.format(filename))
# Try to guess board name
if board_name is None:
if len(names) == 1:
board_name = names.pop()
else:
board_name = os.path.basename(directory)
# Return PCB
return cls(layers, board_name)
def __init__(self, layers, name=None):
self.layers = sort_layers(layers)
self.name = name
def __len__(self):
return len(self.layers)
@property
def top_layers(self):
board_layers = [l for l in reversed(self.layers) if l.layer_class in
('topsilk', 'topmask', 'top')]
drill_layers = [l for l in self.drill_layers if 'top' in l.layers]
# Drill layer goes under soldermask for proper rendering of tented vias
return [board_layers[0]] + drill_layers + board_layers[1:]
@property
def bottom_layers(self):
board_layers = [l for l in self.layers if l.layer_class in
('bottomsilk', 'bottommask', 'bottom')]
drill_layers = [l for l in self.drill_layers if 'bottom' in l.layers]
# Drill layer goes under soldermask for proper rendering of tented vias
return [board_layers[0]] + drill_layers + board_layers[1:]
@property
def drill_layers(self):
return [l for l in self.layers if l.layer_class == 'drill']
@property
def copper_layers(self):
return list(reversed([layer for layer in self.layers if
layer.layer_class in
('top', 'bottom', 'internal')]))
@property
def outline_layer(self):
for layer in self.layers:
if layer.layer_class == 'outline':
return layer
@property
def layer_count(self):
""" Number of *COPPER* layers
"""
return len([l for l in self.layers if l.layer_class in
('top', 'bottom', 'internal')])
@property
def board_bounds(self):
for layer in self.layers:
if layer.layer_class == 'outline':
return layer.bounding_box
for layer in self.layers:
if layer.layer_class == 'top':
return layer.bounding_box

View file

@ -1,125 +0,0 @@
#! /usr/bin/env python
# -*- coding: utf-8 -*-
# copyright 2015 Hamilton Kibbe <ham@hamiltonkib.be>
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import os
from .exceptions import ParseError
from .layers import PCBLayer, sort_layers, layer_signatures
from .common import read as gerber_read
class PCB(object):
@classmethod
def from_directory(cls, directory, board_name=None, verbose=False):
layers = []
names = set()
# Validate
directory = os.path.abspath(directory)
if not os.path.isdir(directory):
raise TypeError('{} is not a directory.'.format(directory))
# Load gerber files
for filename in os.listdir(directory):
try:
camfile = gerber_read(os.path.join(directory, filename))
layer = PCBLayer.from_cam(camfile)
layers.append(layer)
name = os.path.splitext(filename)[0]
if len(os.path.splitext(filename)) > 1:
_name, ext = os.path.splitext(name)
if ext[1:] in layer_signatures(layer.layer_class):
name = _name
if layer.layer_class == 'drill' and 'drill' in ext:
name = _name
names.add(name)
if verbose:
print('[PCB]: Added {} layer <{}>'.format(layer.layer_class,
filename))
except ParseError:
if verbose:
print('[PCB]: Skipping file {}'.format(filename))
except IOError:
if verbose:
print('[PCB]: Skipping file {}'.format(filename))
# Try to guess board name
if board_name is None:
if len(names) == 1:
board_name = names.pop()
else:
board_name = os.path.basename(directory)
# Return PCB
return cls(layers, board_name)
def __init__(self, layers, name=None):
self.layers = sort_layers(layers)
self.name = name
def __len__(self):
return len(self.layers)
@property
def top_layers(self):
board_layers = [l for l in reversed(self.layers) if l.layer_class in
('topsilk', 'topmask', 'top')]
drill_layers = [l for l in self.drill_layers if 'top' in l.layers]
# Drill layer goes under soldermask for proper rendering of tented vias
return [board_layers[0]] + drill_layers + board_layers[1:]
@property
def bottom_layers(self):
board_layers = [l for l in self.layers if l.layer_class in
('bottomsilk', 'bottommask', 'bottom')]
drill_layers = [l for l in self.drill_layers if 'bottom' in l.layers]
# Drill layer goes under soldermask for proper rendering of tented vias
return [board_layers[0]] + drill_layers + board_layers[1:]
@property
def drill_layers(self):
return [l for l in self.layers if l.layer_class == 'drill']
@property
def copper_layers(self):
return list(reversed([layer for layer in self.layers if
layer.layer_class in
('top', 'bottom', 'internal')]))
@property
def outline_layer(self):
for layer in self.layers:
if layer.layer_class == 'outline':
return layer
@property
def layer_count(self):
""" Number of *COPPER* layers
"""
return len([l for l in self.layers if l.layer_class in
('top', 'bottom', 'internal')])
@property
def board_bounds(self):
for layer in self.layers:
if layer.layer_class == 'outline':
return layer.bounding_box
for layer in self.layers:
if layer.layer_class == 'top':
return layer.bounding_box

View file

@ -1,932 +0,0 @@
#! /usr/bin/env python
# -*- coding: utf-8 -*-
# copyright 2016 Hamilton Kibbe <ham@hamiltonkib.be>
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import math
from operator import add
from itertools import combinations
from .utils import validate_coordinates, inch, metric, convex_hull
from .utils import rotate_point, nearly_equal
class Primitive:
def __init__(self, polarity_dark=True, rotation=0, **meta):
self.polarity_dark = polarity_dark
self.meta = meta
self.rotation = rotation
def __eq__(self, other):
return self.__dict__ == other.__dict__
def aperture(self):
return None
class Line(Primitive):
def __init__(self, start, end, aperture=None, polarity_dark=True, rotation=0, **meta):
super().__init__(polarity_dark, rotation, **meta)
self.start = start
self.end = end
self.aperture = aperture
@property
def angle(self):
delta_x, delta_y = tuple(end - start for end, start in zip(self.end, self.start))
return math.atan2(delta_y, delta_x)
@property
def bounding_box(self):
if isinstance(self.aperture, Circle):
width_2 = self.aperture.radius
height_2 = width_2
else:
width_2 = self.aperture.width / 2.
height_2 = self.aperture.height / 2.
min_x = min(self.start[0], self.end[0]) - width_2
max_x = max(self.start[0], self.end[0]) + width_2
min_y = min(self.start[1], self.end[1]) - height_2
max_y = max(self.start[1], self.end[1]) + height_2
return (min_x, min_y), (max_x, max_y)
@property
def bounding_box_no_aperture(self):
'''Gets the bounding box without the aperture'''
min_x = min(self.start[0], self.end[0])
max_x = max(self.start[0], self.end[0])
min_y = min(self.start[1], self.end[1])
max_y = max(self.start[1], self.end[1])
return ((min_x, min_y), (max_x, max_y))
@property
def vertices(self):
if self._vertices is None:
start = self.start
end = self.end
if isinstance(self.aperture, Rectangle):
width = self.aperture.width
height = self.aperture.height
# Find all the corners of the start and end position
start_ll = (start[0] - (width / 2.), start[1] - (height / 2.))
start_lr = (start[0] + (width / 2.), start[1] - (height / 2.))
start_ul = (start[0] - (width / 2.), start[1] + (height / 2.))
start_ur = (start[0] + (width / 2.), start[1] + (height / 2.))
end_ll = (end[0] - (width / 2.), end[1] - (height / 2.))
end_lr = (end[0] + (width / 2.), end[1] - (height / 2.))
end_ul = (end[0] - (width / 2.), end[1] + (height / 2.))
end_ur = (end[0] + (width / 2.), end[1] + (height / 2.))
# The line is defined by the convex hull of the points
self._vertices = convex_hull((start_ll, start_lr, start_ul, start_ur, end_ll, end_lr, end_ul, end_ur))
elif isinstance(self.aperture, Polygon):
points = [map(add, point, vertex)
for vertex in self.aperture.vertices
for point in (start, end)]
self._vertices = convex_hull(points)
return self._vertices
def offset(self, x_offset=0, y_offset=0):
self._changed()
self.start = tuple([coord + offset for coord, offset
in zip(self.start, (x_offset, y_offset))])
self.end = tuple([coord + offset for coord, offset
in zip(self.end, (x_offset, y_offset))])
def equivalent(self, other, offset):
if not isinstance(other, Line):
return False
equiv_start = tuple(map(add, other.start, offset))
equiv_end = tuple(map(add, other.end, offset))
return nearly_equal(self.start, equiv_start) and nearly_equal(self.end, equiv_end)
def __str__(self):
return "<Line {} to {}>".format(self.start, self.end)
def __repr__(self):
return str(self)
class Arc(Primitive):
def __init__(self, start, end, center, direction, aperture, level_polarity=None, **kwargs):
super(Arc, self).__init__(**kwargs)
self.level_polarity = level_polarity
self._start = start
self._end = end
self._center = center
self.direction = direction
self.aperture = aperture
self._to_convert = ['start', 'end', 'center', 'aperture']
@property
def flashed(self):
return False
@property
def start(self):
return self._start
@start.setter
def start(self, value):
self._changed()
self._start = value
@property
def end(self):
return self._end
@end.setter
def end(self, value):
self._changed()
self._end = value
@property
def center(self):
return self._center
@center.setter
def center(self, value):
self._changed()
self._center = value
@property
def radius(self):
dy, dx = tuple([start - center for start, center
in zip(self.start, self.center)])
return math.sqrt(dy ** 2 + dx ** 2)
@property
def start_angle(self):
dx, dy = tuple([start - center for start, center
in zip(self.start, self.center)])
return math.atan2(dy, dx)
@property
def end_angle(self):
dx, dy = tuple([end - center for end, center
in zip(self.end, self.center)])
return math.atan2(dy, dx)
@property
def sweep_angle(self):
two_pi = 2 * math.pi
theta0 = (self.start_angle + two_pi) % two_pi
theta1 = (self.end_angle + two_pi) % two_pi
if self.direction == 'counterclockwise':
return abs(theta1 - theta0)
else:
theta0 += two_pi
return abs(theta0 - theta1) % two_pi
@property
def bounding_box(self):
if self._bounding_box is None:
two_pi = 2 * math.pi
theta0 = (self.start_angle + two_pi) % two_pi
theta1 = (self.end_angle + two_pi) % two_pi
points = [self.start, self.end]
x, y = zip(*points)
if hasattr(self.aperture, 'radius'):
min_x = min(x) - self.aperture.radius
max_x = max(x) + self.aperture.radius
min_y = min(y) - self.aperture.radius
max_y = max(y) + self.aperture.radius
else:
min_x = min(x) - self.aperture.width
max_x = max(x) + self.aperture.width
min_y = min(y) - self.aperture.height
max_y = max(y) + self.aperture.height
self._bounding_box = ((min_x, min_y), (max_x, max_y))
return self._bounding_box
@property
def bounding_box_no_aperture(self):
'''Gets the bounding box without considering the aperture'''
two_pi = 2 * math.pi
theta0 = (self.start_angle + two_pi) % two_pi
theta1 = (self.end_angle + two_pi) % two_pi
points = [self.start, self.end]
x, y = zip(*points)
min_x = min(x)
max_x = max(x)
min_y = min(y)
max_y = max(y)
return ((min_x, min_y), (max_x, max_y))
def offset(self, x_offset=0, y_offset=0):
self._changed()
self.start = tuple(map(add, self.start, (x_offset, y_offset)))
self.end = tuple(map(add, self.end, (x_offset, y_offset)))
self.center = tuple(map(add, self.center, (x_offset, y_offset)))
class Circle(Primitive):
def __init__(self, position, diameter, polarity_dark=True):
super(Circle, self).__init__(**kwargs)
validate_coordinates(position)
self._position = position
self._diameter = diameter
self.hole_diameter = hole_diameter
self.hole_width = hole_width
self.hole_height = hole_height
self._to_convert = ['position', 'diameter', 'hole_diameter', 'hole_width', 'hole_height']
@property
def flashed(self):
return True
@property
def position(self):
return self._position
@position.setter
def position(self, value):
self._changed()
self._position = value
@property
def diameter(self):
return self._diameter
@diameter.setter
def diameter(self, value):
self._changed()
self._diameter = value
@property
def radius(self):
return self.diameter / 2.
@property
def hole_radius(self):
if self.hole_diameter != None:
return self.hole_diameter / 2.
return None
@property
def bounding_box(self):
if self._bounding_box is None:
min_x = self.position[0] - self.radius
max_x = self.position[0] + self.radius
min_y = self.position[1] - self.radius
max_y = self.position[1] + self.radius
self._bounding_box = ((min_x, min_y), (max_x, max_y))
return self._bounding_box
def offset(self, x_offset=0, y_offset=0):
self.position = tuple(map(add, self.position, (x_offset, y_offset)))
def equivalent(self, other, offset):
'''Is this the same as the other circle, ignoring the offiset?'''
if not isinstance(other, Circle):
return False
if self.diameter != other.diameter or self.hole_diameter != other.hole_diameter:
return False
equiv_position = tuple(map(add, other.position, offset))
return nearly_equal(self.position, equiv_position)
class Rectangle(Primitive):
"""
When rotated, the rotation is about the center point.
Only aperture macro generated Rectangle objects can be rotated. If you aren't in a AMGroup,
then you don't need to worry about rotation
"""
def __init__(self, position, width, height, hole_diameter=0,
hole_width=0, hole_height=0, **kwargs):
super(Rectangle, self).__init__(**kwargs)
validate_coordinates(position)
self._position = position
self._width = width
self._height = height
self.hole_diameter = hole_diameter
self.hole_width = hole_width
self.hole_height = hole_height
self._to_convert = ['position', 'width', 'height', 'hole_diameter',
'hole_width', 'hole_height']
# TODO These are probably wrong when rotated
self._lower_left = None
self._upper_right = None
@property
def flashed(self):
return True
@property
def position(self):
return self._position
@position.setter
def position(self, value):
self._changed()
self._position = value
@property
def width(self):
return self._width
@width.setter
def width(self, value):
self._changed()
self._width = value
@property
def height(self):
return self._height
@height.setter
def height(self, value):
self._changed()
self._height = value
@property
def hole_radius(self):
"""The radius of the hole. If there is no hole, returns None"""
if self.hole_diameter != None:
return self.hole_diameter / 2.
return None
@property
def upper_right(self):
return (self.position[0] + (self.axis_aligned_width / 2.),
self.position[1] + (self.axis_aligned_height / 2.))
@property
def lower_left(self):
return (self.position[0] - (self.axis_aligned_width / 2.),
self.position[1] - (self.axis_aligned_height / 2.))
@property
def bounding_box(self):
if self._bounding_box is None:
ll = (self.position[0] - (self.axis_aligned_width / 2.),
self.position[1] - (self.axis_aligned_height / 2.))
ur = (self.position[0] + (self.axis_aligned_width / 2.),
self.position[1] + (self.axis_aligned_height / 2.))
self._bounding_box = ((ll[0], ll[1]), (ur[0], ur[1]))
return self._bounding_box
@property
def vertices(self):
if self._vertices is None:
delta_w = self.width / 2.
delta_h = self.height / 2.
ll = ((self.position[0] - delta_w), (self.position[1] - delta_h))
ul = ((self.position[0] - delta_w), (self.position[1] + delta_h))
ur = ((self.position[0] + delta_w), (self.position[1] + delta_h))
lr = ((self.position[0] + delta_w), (self.position[1] - delta_h))
self._vertices = [((x * self._cos_theta - y * self._sin_theta),
(x * self._sin_theta + y * self._cos_theta))
for x, y in [ll, ul, ur, lr]]
return self._vertices
@property
def axis_aligned_width(self):
return (self._cos_theta * self.width + self._sin_theta * self.height)
@property
def axis_aligned_height(self):
return (self._cos_theta * self.height + self._sin_theta * self.width)
def equivalent(self, other, offset):
"""Is this the same as the other rect, ignoring the offset?"""
if not isinstance(other, Rectangle):
return False
if self.width != other.width or self.height != other.height or self.rotation != other.rotation or self.hole_diameter != other.hole_diameter:
return False
equiv_position = tuple(map(add, other.position, offset))
return nearly_equal(self.position, equiv_position)
def __str__(self):
return "<Rectangle W {} H {} R {}>".format(self.width, self.height, self.rotation * 180/math.pi)
def __repr__(self):
return self.__str__()
class Obround(Primitive):
def __init__(self, position, width, height, hole_diameter=0,
hole_width=0,hole_height=0, **kwargs):
super(Obround, self).__init__(**kwargs)
validate_coordinates(position)
self._position = position
self._width = width
self._height = height
self.hole_diameter = hole_diameter
self.hole_width = hole_width
self.hole_height = hole_height
self._to_convert = ['position', 'width', 'height', 'hole_diameter',
'hole_width', 'hole_height' ]
@property
def flashed(self):
return True
@property
def position(self):
return self._position
@position.setter
def position(self, value):
self._changed()
self._position = value
@property
def width(self):
return self._width
@width.setter
def width(self, value):
self._changed()
self._width = value
@property
def height(self):
return self._height
@height.setter
def height(self, value):
self._changed()
self._height = value
@property
def hole_radius(self):
"""The radius of the hole. If there is no hole, returns None"""
if self.hole_diameter != None:
return self.hole_diameter / 2.
return None
@property
def orientation(self):
return 'vertical' if self.height > self.width else 'horizontal'
@property
def bounding_box(self):
if self._bounding_box is None:
ll = (self.position[0] - (self.axis_aligned_width / 2.),
self.position[1] - (self.axis_aligned_height / 2.))
ur = (self.position[0] + (self.axis_aligned_width / 2.),
self.position[1] + (self.axis_aligned_height / 2.))
self._bounding_box = ((ll[0], ll[1]), (ur[0], ur[1]))
return self._bounding_box
@property
def subshapes(self):
if self.orientation == 'vertical':
circle1 = Circle((self.position[0], self.position[1] +
(self.height - self.width) / 2.), self.width)
circle2 = Circle((self.position[0], self.position[1] -
(self.height - self.width) / 2.), self.width)
rect = Rectangle(self.position, self.width,
(self.height - self.width))
else:
circle1 = Circle((self.position[0]
- (self.height - self.width) / 2.,
self.position[1]), self.height)
circle2 = Circle((self.position[0]
+ (self.height - self.width) / 2.,
self.position[1]), self.height)
rect = Rectangle(self.position, (self.width - self.height),
self.height)
return {'circle1': circle1, 'circle2': circle2, 'rectangle': rect}
@property
def axis_aligned_width(self):
return (self._cos_theta * self.width +
self._sin_theta * self.height)
@property
def axis_aligned_height(self):
return (self._cos_theta * self.height +
self._sin_theta * self.width)
class Polygon(Primitive):
"""
Polygon flash defined by a set number of sides.
"""
def __init__(self, position, sides, radius, hole_diameter=0,
hole_width=0, hole_height=0, **kwargs):
super(Polygon, self).__init__(**kwargs)
validate_coordinates(position)
self._position = position
self.sides = sides
self._radius = radius
self.hole_diameter = hole_diameter
self.hole_width = hole_width
self.hole_height = hole_height
self._to_convert = ['position', 'radius', 'hole_diameter',
'hole_width', 'hole_height']
@property
def flashed(self):
return True
@property
def diameter(self):
return self.radius * 2
@property
def hole_radius(self):
if self.hole_diameter != None:
return self.hole_diameter / 2.
return None
@property
def position(self):
return self._position
@position.setter
def position(self, value):
self._changed()
self._position = value
@property
def radius(self):
return self._radius
@radius.setter
def radius(self, value):
self._changed()
self._radius = value
@property
def bounding_box(self):
if self._bounding_box is None:
min_x = self.position[0] - self.radius
max_x = self.position[0] + self.radius
min_y = self.position[1] - self.radius
max_y = self.position[1] + self.radius
self._bounding_box = ((min_x, min_y), (max_x, max_y))
return self._bounding_box
def offset(self, x_offset=0, y_offset=0):
self.position = tuple(map(add, self.position, (x_offset, y_offset)))
@property
def vertices(self):
offset = self.rotation
delta_angle = 360.0 / self.sides
points = []
for i in range(self.sides):
points.append(
rotate_point((self.position[0] + self.radius, self.position[1]), offset + delta_angle * i, self.position))
return points
def equivalent(self, other, offset):
"""
Is this the outline the same as the other, ignoring the position offset?
"""
# Quick check if it even makes sense to compare them
if type(self) != type(other) or self.sides != other.sides or self.radius != other.radius:
return False
equiv_pos = tuple(map(add, other.position, offset))
return nearly_equal(self.position, equiv_pos)
class AMGroup(Primitive):
"""
"""
def __init__(self, amprimitives, stmt = None, **kwargs):
"""
stmt : The original statment that generated this, since it is really hard to re-generate from primitives
"""
super(AMGroup, self).__init__(**kwargs)
self.primitives = []
for amprim in amprimitives:
prim = amprim.to_primitive(self.units)
if isinstance(prim, list):
for p in prim:
self.primitives.append(p)
elif prim:
self.primitives.append(prim)
self._position = None
self._to_convert = ['_position', 'primitives']
self.stmt = stmt
def to_inch(self):
if self.units == 'metric':
super(AMGroup, self).to_inch()
# If we also have a stmt, convert that too
if self.stmt:
self.stmt.to_inch()
def to_metric(self):
if self.units == 'inch':
super(AMGroup, self).to_metric()
# If we also have a stmt, convert that too
if self.stmt:
self.stmt.to_metric()
@property
def flashed(self):
return True
@property
def bounding_box(self):
# TODO Make this cached like other items
xlims, ylims = zip(*[p.bounding_box for p in self.primitives])
minx, maxx = zip(*xlims)
miny, maxy = zip(*ylims)
min_x = min(minx)
max_x = max(maxx)
min_y = min(miny)
max_y = max(maxy)
return ((min_x, max_x), (min_y, max_y))
@property
def position(self):
return self._position
def offset(self, x_offset=0, y_offset=0):
self._position = tuple(map(add, self._position, (x_offset, y_offset)))
for primitive in self.primitives:
primitive.offset(x_offset, y_offset)
@position.setter
def position(self, new_pos):
'''
Sets the position of the AMGroup.
This offset all of the objects by the specified distance.
'''
if self._position:
dx = new_pos[0] - self._position[0]
dy = new_pos[1] - self._position[1]
else:
dx = new_pos[0]
dy = new_pos[1]
for primitive in self.primitives:
primitive.offset(dx, dy)
self._position = new_pos
def equivalent(self, other, offset):
'''
Is this the macro group the same as the other, ignoring the position offset?
'''
if len(self.primitives) != len(other.primitives):
return False
# We know they have the same number of primitives, so now check them all
for i in range(0, len(self.primitives)):
if not self.primitives[i].equivalent(other.primitives[i], offset):
return False
# If we didn't find any differences, then they are the same
return True
class Outline(Primitive):
"""
Outlines only exist as the rendering for a apeture macro outline.
They don't exist outside of AMGroup objects
"""
def __init__(self, primitives, **kwargs):
super(Outline, self).__init__(**kwargs)
self.primitives = primitives
self._to_convert = ['primitives']
if self.primitives[0].start != self.primitives[-1].end:
raise ValueError('Outline must be closed')
@property
def flashed(self):
return True
@property
def bounding_box(self):
if self._bounding_box is None:
xlims, ylims = zip(*[p.bounding_box for p in self.primitives])
minx, maxx = zip(*xlims)
miny, maxy = zip(*ylims)
min_x = min(minx)
max_x = max(maxx)
min_y = min(miny)
max_y = max(maxy)
self._bounding_box = ((min_x, max_x), (min_y, max_y))
return self._bounding_box
def offset(self, x_offset=0, y_offset=0):
self._changed()
for p in self.primitives:
p.offset(x_offset, y_offset)
@property
def vertices(self):
if self._vertices is None:
theta = math.radians(360/self.sides)
vertices = [(self.position[0] + (math.cos(theta * side) * self.radius),
self.position[1] + (math.sin(theta * side) * self.radius))
for side in range(self.sides)]
self._vertices = [(((x * self._cos_theta) - (y * self._sin_theta)),
((x * self._sin_theta) + (y * self._cos_theta)))
for x, y in vertices]
return self._vertices
@property
def width(self):
bounding_box = self.bounding_box()
return bounding_box[1][0] - bounding_box[0][0]
def equivalent(self, other, offset):
'''
Is this the outline the same as the other, ignoring the position offset?
'''
# Quick check if it even makes sense to compare them
if type(self) != type(other) or len(self.primitives) != len(other.primitives):
return False
for i in range(0, len(self.primitives)):
if not self.primitives[i].equivalent(other.primitives[i], offset):
return False
return True
class Region(Primitive):
"""
"""
def __init__(self, primitives, **kwargs):
super(Region, self).__init__(**kwargs)
self.primitives = primitives
self._to_convert = ['primitives']
@property
def flashed(self):
return False
@property
def bounding_box(self):
if self._bounding_box is None:
xlims, ylims = zip(*[p.bounding_box for p in self.primitives])
minx, maxx = zip(*xlims)
miny, maxy = zip(*ylims)
min_x = min(minx)
max_x = max(maxx)
min_y = min(miny)
max_y = max(maxy)
self._bounding_box = ((min_x, min_y), (max_x, max_y))
return self._bounding_box
def offset(self, x_offset=0, y_offset=0):
self._changed()
for p in self.primitives:
p.offset(x_offset, y_offset)
class Drill(Primitive):
""" A drill hole
"""
def __init__(self, position, diameter, **kwargs):
super(Drill, self).__init__('dark', **kwargs)
validate_coordinates(position)
self._position = position
self._diameter = diameter
self._to_convert = ['position', 'diameter']
@property
def flashed(self):
return False
@property
def position(self):
return self._position
@position.setter
def position(self, value):
self._changed()
self._position = value
@property
def diameter(self):
return self._diameter
@diameter.setter
def diameter(self, value):
self._changed()
self._diameter = value
@property
def radius(self):
return self.diameter / 2.
@property
def bounding_box(self):
if self._bounding_box is None:
min_x = self.position[0] - self.radius
max_x = self.position[0] + self.radius
min_y = self.position[1] - self.radius
max_y = self.position[1] + self.radius
self._bounding_box = ((min_x, min_y), (max_x, max_y))
return self._bounding_box
def offset(self, x_offset=0, y_offset=0):
self._changed()
self.position = tuple(map(add, self.position, (x_offset, y_offset)))
def __str__(self):
return '<Drill %f %s (%f, %f)>' % (self.diameter, self.units, self.position[0], self.position[1])
class Slot(Primitive):
""" A drilled slot
"""
def __init__(self, start, end, diameter, **kwargs):
super(Slot, self).__init__('dark', **kwargs)
validate_coordinates(start)
validate_coordinates(end)
self.start = start
self.end = end
self.diameter = diameter
self._to_convert = ['start', 'end', 'diameter']
@property
def flashed(self):
return False
@property
def bounding_box(self):
if self._bounding_box is None:
radius = self.diameter / 2.
min_x = min(self.start[0], self.end[0]) - radius
max_x = max(self.start[0], self.end[0]) + radius
min_y = min(self.start[1], self.end[1]) - radius
max_y = max(self.start[1], self.end[1]) + radius
self._bounding_box = ((min_x, min_y), (max_x, max_y))
return self._bounding_box
def offset(self, x_offset=0, y_offset=0):
self.start = tuple(map(add, self.start, (x_offset, y_offset)))
self.end = tuple(map(add, self.end, (x_offset, y_offset)))
class TestRecord(Primitive):
""" Netlist Test record
"""
__test__ = False # This is not a PyTest unit test.
def __init__(self, position, net_name, layer, **kwargs):
super(TestRecord, self).__init__(**kwargs)
validate_coordinates(position)
self.position = position
self.net_name = net_name
self.layer = layer
self._to_convert = ['position']
class RegionGroup:
def __init__(self):
self.outline = []
def __bool__(self):
return bool(self.outline)
def append(self, primitive):
self.outline.append(primitive)

View file

@ -34,9 +34,10 @@ from io import StringIO
from .gerber_statements import *
from .cam import CamFile, FileSettings
from .utils import sq_distance, rotate_point
from aperture_macros.parse import ApertureMacro, GenericMacros
import graphic_primitives as gp
import graphic_objects as go
from .aperture_macros.parse import ApertureMacro, GenericMacros
from . import graphic_primitives as gp
from . import graphic_objects as go
from . import apertures
class GerberFile(CamFile):
@ -75,9 +76,9 @@ class GerberFile(CamFile):
# dedup aperture macros
macros = { m.to_gerber(): m
for m in [ GenericMacros.circle, GenericMacros.rect, GenericMacros.oblong, GenericMacros.polygon] }
for m in [ GenericMacros.circle, GenericMacros.rect, GenericMacros.obround, GenericMacros.polygon] }
for ap in new_apertures:
if isinstance(aperture, ApertureMacroInstance):
if isinstance(aperture, apertures.ApertureMacroInstance):
macro_grb = ap.macro.to_gerber() # use native units to compare macros
if macro_grb in macros:
ap.macro = macros[macro_grb]
@ -128,6 +129,7 @@ class GerberFile(CamFile):
yield FormatSpecStmt()
yield ImagePolarityStmt()
yield SingleQuadrantModeStmt()
yield LoadPolarityStmt(True)
if not drop_comments:
yield CommentStmt('File processed by Gerbonara. Original comments:')
@ -139,14 +141,14 @@ class GerberFile(CamFile):
# and they are only a few bytes anyway.
yield ApertureMacroStmt(GenericMacros.circle)
yield ApertureMacroStmt(GenericMacros.rect)
yield ApertureMacroStmt(GenericMacros.oblong)
yield ApertureMacroStmt(GenericMacros.obround)
yield ApertureMacroStmt(GenericMacros.polygon)
processed_macros = set()
aperture_map = {}
for number, aperture in enumerate(self.apertures, start=10):
if isinstance(aperture, ApertureMacroInstance):
if isinstance(aperture, apertures.ApertureMacroInstance):
macro_grb = aperture.macro.to_gerber() # use native units to compare macros
if macro_grb not in processed_macros:
processed_macros.add(macro_grb)
@ -170,8 +172,16 @@ class GerberFile(CamFile):
def save(self, filename):
with open(filename, 'w', encoding='utf-8') as f: # Encoding is specified as UTF-8 by spec.
for stmt in self.generate_statements():
print(stmt.to_gerber(self.settings), file=f)
f.write(self.to_gerber())
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
# file
if settings is None:
settings = self.import_settings.copy() or FileSettings()
settings.zeros = None
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):
# TODO round offset to file resolution
@ -207,8 +217,8 @@ class GraphicsState:
polarity_dark : bool = True
image_polarity : str = 'positive' # IP image polarity; deprecated
point : tuple = None
aperture : Aperture = None
interpolation_mode : InterpolationModeStmt = None
aperture : apertures.Aperture = None
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
@ -267,13 +277,13 @@ class GraphicsState:
a *= self.image_scale[0]
d *= self.image_scale[1]
if ir == 90:
if self.image_rotation == 90:
a, b, c, d = 0, -d, a, 0
off_x, off_y = off_y, -off_x
elif ir == 180:
elif self.image_rotation == 180:
a, b, c, d = -a, 0, 0, -d
off_x, off_y = -off_x, -off_y
elif ir == 270:
elif self.image_rotation == 270:
a, b, c, d = 0, d, -a, 0
off_x, off_y = -off_y, off_x
@ -283,11 +293,11 @@ class GraphicsState:
def map_coord(self, x, y, relative=False):
if self._mat is None:
self._update_xform()
a, b, c, d = self.mat
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])
else
else:
# Apply mirroring, scale and rotation, but do not apply offset
return (a*x + b*y), (c*x + d*y)
@ -305,14 +315,14 @@ class GraphicsState:
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, self.polarity_dark)
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)
def _create_arc(self, x, y, i, j, aperture=True):
old_point, self.point = self.point, self._map_coord(x, y)
old_point, self.point = self.point, self.map_coord(x, y)
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),
flipped=(direction == 'cw'), self.aperture if aperture else None, self.polarity_dark)
flipped=(direction == 'cw'), aperture=(self.aperture if aperture else None), polarity_dark=self.polarity_dark)
# Helpers for gerber generation
def set_polarity(self, polarity_dark):
@ -343,12 +353,12 @@ class GerberParser:
STATEMENT_REGEXES = {
'unit_mode': r"MO(?P<unit>(MM|IN))",
'interpolation_mode': r"(?P<code>G0?[123]|G74|G75)?",
'interpolation_mode': r"(?P<code>G0?[123]|G74|G75)",
'coord': fr"(X(?P<x>{NUMBER}))?(Y(?P<y>{NUMBER}))?" \
fr"(I(?P<i>{NUMBER}))?(J(?P<j>{NUMBER}))?" \
fr"(?P<operation>D0?[123])?\*",
'aperture': r"(G54|G55)?D(?P<number>\d+)\*",
'comment': r"G0?4(?P<comment>[^*]*)(\*)?",
fr"(?P<operation>D0?[123])$",
'aperture': r"(G54|G55)?D(?P<number>\d+)",
'comment': r"G0?4(?P<comment>[^*]*)",
'format_spec': r"FS(?P<zero>(L|T|D))?(?P<notation>(A|I))[NG0-9]*X(?P<x>[0-7][0-7])Y(?P<y>[0-7][0-7])[DM0-9]*",
'load_polarity': r"LP(?P<polarity>(D|C))",
# FIXME LM, LR, LS
@ -363,12 +373,12 @@ class GerberParser:
'scale_factor': fr"SF(A(?P<a>{DECIMAL}))?(B(?P<b>{DECIMAL}))?",
'aperture_definition': fr"ADD(?P<number>\d+)(?P<shape>C|R|O|P|{NAME})[,]?(?P<modifiers>[^,%]*)",
'aperture_macro': fr"AM(?P<name>{NAME})\*(?P<macro>[^%]*)",
'region_start': r'G36\*',
'region_end': r'G37\*',
'old_unit':r'(?P<mode>G7[01])\*',
'old_notation': r'(?P<mode>G9[01])\*',
'eof': r"M0?[02]\*",
'ignored': r"(?P<stmt>M01)\*",
'region_start': r'G36',
'region_end': r'G37',
'old_unit':r'(?P<mode>G7[01])',
'old_notation': r'(?P<mode>G9[01])',
'eof': r"M0?[02]",
'ignored': r"(?P<stmt>M01)",
}
STATEMENT_REGEXES = { key: re.compile(value) for key, value in STATEMENT_REGEXES.items() }
@ -382,6 +392,7 @@ class GerberParser:
self.file_settings = FileSettings()
self.graphics_state = GraphicsState()
self.aperture_map = {}
self.aperture_macros = {}
self.current_region = None
self.eof_found = False
self.multi_quadrant_mode = None # used only for syntax checking
@ -400,44 +411,46 @@ class GerberParser:
for pos, c in enumerate(data):
if c == '%':
if extended_command:
yield data[start:pos+1]
yield data[start:pos]
extended_command = False
start = pos + 1
else:
extended_command = True
start = pos + 1
continue
elif extended_command:
continue
if c == '\r' or c == '\n' or c == '*':
word_command = data[start:pos+1].strip()
word_command = data[start:pos].strip()
if word_command and word_command != '*':
yield word_command
start = cur + 1
start = pos + 1
def parse(self, data):
for line in self._split_commands(data):
if not line.strip():
continue
line = line.rstrip('*').strip()
# We cannot assume input gerber to use well-formed statement delimiters. Thus, we may need to parse
# multiple statements from one line.
while line:
if line.strip() and self.eof_found:
warnings.warn('Data found in gerber file after EOF.', SyntaxWarning)
for name, le_regex in self.STATEMENT_REGEXES.items():
if (match := le_regex.match(line)):
getattr(self, f'_parse_{name}')(self, match.groupdict())
line = line[match.end(0):]
break
if line.strip() and self.eof_found:
warnings.warn('Data found in gerber file after EOF.', SyntaxWarning)
else:
if line[-1] == '*':
warnings.warn(f'Unknown statement found: "{line}", ignoring.', SyntaxWarning)
self.target.comments.append(f'Unknown statement found: "{line}", ignoring.')
line = ''
for name, le_regex in self.STATEMENT_REGEXES.items():
if (match := le_regex.match(line)):
getattr(self, f'_parse_{name}')(match.groupdict())
line = line[match.end(0):]
break
else:
warnings.warn(f'Unknown statement found: "{line}", ignoring.', SyntaxWarning)
self.target.comments.append(f'Unknown statement found: "{line}", ignoring.')
self.target.apertures = list(self.aperture_map.values())
self.target.import_settings = self.file_settings
if not self.eof_found:
warnings.warn('File is missing mandatory M02 EOF marker. File may be truncated.', SyntaxWarning)
@ -519,17 +532,17 @@ class GerberParser:
modifiers = [ float(val) for val in match['modifiers'].split(',') ]
aperture_classes = {
'C': ApertureCircle,
'R': ApertureRectangle,
'O': ApertureObround,
'P': AperturePolygon,
'C': apertures.CircleAperture,
'R': apertures.RectangleAperture,
'O': apertures.ObroundAperture,
'P': apertures.PolygonAperture,
}
if (kls := aperture_classes.get(match['shape'])):
new_aperture = kls(*modifiers)
elif (macro := self.target.aperture_macros.get(match['shape'])):
new_aperture = ApertureMacroInstance(match['shape'], macro, modifiers)
elif (macro := self.aperture_macros.get(match['shape'])):
new_aperture = apertures.ApertureMacroInstance(match['shape'], macro, modifiers)
else:
raise ValueError(f'Aperture shape "{match["shape"]}" is unknown')
@ -537,11 +550,12 @@ class GerberParser:
self.aperture_map[int(match['number'])] = new_aperture
def _parse_aperture_macro(self, match):
self.target.aperture_macros[match['name']] = ApertureMacro.parse(match['macro'])
self.aperture_macros[match['name']] = ApertureMacro.parse_macro(
match['name'], match['macro'], self.file_settings.units)
def _parse_format_spec(self, match):
# This is a common problem in Eagle files, so just suppress it
self.file_settings.zero_suppression = {'L': 'leading', 'T': 'trailing'}.get(match['zero'], 'leading')
self.file_settings.zeros = {'L': 'leading', 'T': 'trailing'}.get(match['zero'], 'leading')
self.file_settings.notation = 'absolute' if match['notation'] == 'A' else 'incremental'
if match['x'] != match['y']:
@ -604,7 +618,7 @@ class GerberParser:
def _parse_image_polarity(self, match):
warnings.warn('Deprecated IP (image polarity) statement found. This deprecated since rev. I4 (Oct 2013).',
DeprecationWarning)
self.graphics_state.image_polarity = match['polarity']
self.graphics_state.image_polarity = dict(POS='positive', NEG='negative')[match['polarity']]
def _parse_image_rotation(self, match):
warnings.warn('Deprecated IR (image rotation) statement found. This deprecated since rev. I1 (Dec 2012).',
@ -673,3 +687,11 @@ def _match_one_from_many(exprs, data):
return ({}, None)
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser()
parser.add_argument('testfile')
args = parser.parse_args()
print(GerberFile.open(args.testfile).to_gerber())

View file

@ -0,0 +1,22 @@
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' Calculated difference: {diff}', ]
# store report in node object so tmp_gbr can determine if the test failed.
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
rep = outcome.get_result()
setattr(item, f'rep_{rep.when}', rep)

View file

@ -0,0 +1,63 @@
import subprocess
from pathlib import Path
import tempfile
import numpy as np
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:
return subprocess.run([os.environ[cmd.upper()], *args], **kwargs)
try:
return subprocess.run([cmd, *args], **kwargs)
except FileNotFoundError:
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)
def gbr_to_svg(in_gbr, out_svg):
cmd = ['gerbv', '-x', 'svg',
'--border=0',
#f'--origin={origin_x:.6f}x{origin_y:.6f}', f'--window_inch={width:.6f}x{height:.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,\
tempfile.NamedTemporaryFile(suffix='.svg') as ref_svg:
gbr_to_svg(reference, ref_svg.name)
gbr_to_svg(actual, act_svg.name)
diff = svg_difference(ref_svg.name, act_svg.name)
diff.ref_path, diff.act_path = reference, actual
return diff
def svg_difference(reference, actual):
with tempfile.NamedTemporaryFile(suffix='.png') as ref_png,\
tempfile.NamedTemporaryFile(suffix='.png') as act_png:
svg_to_png(reference, ref_png.name)
svg_to_png(actual, act_png.name)
diff = image_difference(ref_png.name, act_png.name)
diff.ref_path, diff.act_path = reference, actual
return diff
def image_difference(reference, actual):
ref = np.array(Image.open(reference)).astype(float)
out = np.array(Image.open(actual)).astype(float)
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)

View file

@ -1,70 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Copyright 2019 Hiroshi Murayama <opiopan@gmail.com>
import os
import tempfile
from pathlib import Path
from contextlib import contextmanager
import unittest
from ...rs274x import read
class TestRs274x(unittest.TestCase):
@classmethod
def setUpClass(cls):
here = Path(__file__).parent
cls.EXPECTSDIR = here / 'expects'
cls.METRIC_FILE = here / 'data' / 'ref_gerber_metric.gtl'
cls.INCH_FILE = here / 'data' / 'ref_gerber_inch.gtl'
cls.SQ_FILE = here / 'data' / 'ref_gerber_single_quadrant.gtl'
@contextmanager
def _check_result(self, reference_fn):
with tempfile.NamedTemporaryFile('rb') as tmp_out:
yield tmp_out.name
actual = tmp_out.read()
expected = (self.EXPECTSDIR / reference_fn).read_bytes()
print('==== ACTUAL ===')
print(actual.decode())
print()
print()
print('==== EXPECTED ===')
print(expected.decode())
print()
print()
self.assertEqual(actual, expected)
def test_save(self):
with self._check_result('RS2724x_save.gtl') as outfile:
gerber = read(self.METRIC_FILE)
gerber.write(outfile)
def test_to_inch(self):
with self._check_result('RS2724x_to_inch.gtl') as outfile:
gerber = read(self.METRIC_FILE)
gerber.to_inch()
gerber.format = (2,5)
gerber.write(outfile)
def test_to_metric(self):
with self._check_result('RS2724x_to_metric.gtl') as outfile:
gerber = read(self.INCH_FILE)
gerber.to_metric()
gerber.format = (3, 4)
gerber.write(outfile)
def test_offset(self):
with self._check_result('RS2724x_offset.gtl') as outfile:
gerber = read(self.METRIC_FILE)
gerber.offset(11, 5)
gerber.write(outfile)
def test_rotate(self):
with self._check_result('RS2724x_rotate.gtl') as outfile:
gerber = read(self.METRIC_FILE)
gerber.rotate(20, (10,10))
gerber.write(outfile)

View file

@ -0,0 +1,33 @@
G04 Layer_Color=16711935*
%FSLAX25Y25*%
%MOIN*%
G70*
G01*
G75*
%ADD26C,0.01000*%
D26*
X354331Y177165D02*
G03*
X334646Y196850I-19685J0D01*
G01*
Y0D02*
G03*
X354331Y19685I0J19685D01*
G01*
X0D02*
G03*
X19685Y0I19685J0D01*
G01*
Y196850D02*
G03*
X0Y177165I0J-19685D01*
G01*
X354331Y19685D02*
Y177165D01*
X19685Y196850D02*
X334646D01*
X19685Y0D02*
X334646D01*
X0Y19685D02*
Y177165D01*
M02*

View file

@ -4,52 +4,87 @@
# Author: Hamilton Kibbe <ham@hamiltonkib.be>
import os
import pytest
import functools
import tempfile
import shutil
from argparse import Namespace
from pathlib import Path
from ..rs274x import read, GerberFile
from ..rs274x import GerberFile
from .image_support import gerber_difference
TOP_COPPER_FILE = os.path.join(os.path.dirname(__file__), "resources/top_copper.GTL")
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)
MULTILINE_READ_FILE = os.path.join(
os.path.dirname(__file__), "resources/multiline_read.ger"
)
@pytest.fixture
def tmp_gbr(request):
with tempfile.NamedTemporaryFile(suffix='.gbr') as tmp_out_gbr:
yield Path(tmp_out_gbr.name)
def test_read():
top_copper = read(TOP_COPPER_FILE)
assert isinstance(top_copper, GerberFile)
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(']', '_')
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}')
@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 ])
def test_round_trip(tmp_gbr, reference):
ref = Path(__file__).parent / 'resources' / reference
GerberFile.open(ref).save(tmp_gbr)
assert gerber_difference(ref, tmp_gbr) < 0.02
def test_multiline_read():
multiline = read(MULTILINE_READ_FILE)
assert isinstance(multiline, GerberFile)
assert 11 == len(multiline.statements)
def test_comments_parameter():
top_copper = read(TOP_COPPER_FILE)
assert top_copper.comments[0] == "This is a comment,:"
def test_size_parameter():
top_copper = read(TOP_COPPER_FILE)
size = top_copper.size
pytest.approx(size[0], 2.256900, 6)
pytest.approx(size[1], 1.500000, 6)
def test_conversion():
top_copper = read(TOP_COPPER_FILE)
assert top_copper.units == "inch"
top_copper_inch = read(TOP_COPPER_FILE)
top_copper.to_metric()
for statement in top_copper_inch.statements:
statement.to_metric()
for primitive in top_copper_inch.primitives:
primitive.to_metric()
assert top_copper.units == "metric"
for i, m in zip(top_copper.statements, top_copper_inch.statements):
assert i == m
for i, m in zip(top_copper.primitives, top_copper_inch.primitives):
assert i == m