Basic round-trip works
This commit is contained in:
parent
30dabef9ee
commit
3fb26e6940
21 changed files with 593 additions and 1414 deletions
|
|
@ -23,4 +23,3 @@ files in python.
|
|||
"""
|
||||
|
||||
from .layers import load_layer, load_layer_data
|
||||
from .pcb import PCB
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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__':
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
22
gerbonara/gerber/tests/conftest.py
Normal file
22
gerbonara/gerber/tests/conftest.py
Normal 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)
|
||||
|
||||
63
gerbonara/gerber/tests/image_support.py
Normal file
63
gerbonara/gerber/tests/image_support.py
Normal 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)
|
||||
|
||||
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
@ -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*
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue