This commit is contained in:
jaseg 2021-12-28 21:40:22 +01:00
parent 25dd65fac0
commit 63e1eae8d8
11 changed files with 834 additions and 1249 deletions

View file

@ -22,6 +22,5 @@ gerbonara provides utilities for working with Gerber (RS-274X) and Excellon
files in python.
"""
from .common import read, loads
from .layers import load_layer, load_layer_data
from .pcb import PCB

View file

@ -8,6 +8,10 @@ import re
import ast
def expr(obj):
return obj if isinstance(obj, Expression) else ConstantExpression(obj)
class Expression(object):
@property
def value(self):
@ -28,6 +32,35 @@ class Expression(object):
raise IndexError(f'Cannot fully resolve expression due to unresolved variables: {expr} with variables {variable_binding}')
return expr.value
def __add__(self, other):
return OperatorExpression(operator.add, self, expr(other)).optimized()
def __radd__(self, other):
return expr(other) + self
def __sub__(self, other):
return OperatorExpression(operator.sub, self, expr(other)).optimized()
def __rsub__(self, other):
return expr(other) - self
def __mul__(self, other):
return OperatorExpression(operator.mul, self, expr(other)).optimized()
def __rmul__(self, other):
return expr(other) * self
def __truediv__(self, other):
return OperatorExpression(operator.truediv, self, expr(other)).optimized()
def __rtruediv__(self, other):
return expr(other) / self
def __neg__(self):
return 0 - self
def __pos__(self):
return self
class UnitExpression(Expression):
def __init__(self, expr, unit):
@ -50,10 +83,10 @@ class UnitExpression(Expression):
return self._expr
elif unit == 'mm':
return OperatorExpression.mul(self._expr, MILLIMETERS_PER_INCH)
return self._expr * MILLIMETERS_PER_INCH
elif unit == 'inch':
return OperatorExpression.div(self._expr, MILLIMETERS_PER_INCH)
return self._expr / MILLIMETERS_PER_INCH)
else:
raise ValueError('invalid unit, must be "inch" or "mm".')

View file

@ -2,24 +2,37 @@
# -*- coding: utf-8 -*-
# Copyright 2019 Hiroshi Murayama <opiopan@gmail.com>
# Copyright 2022 Jan Götte <gerbonara@jaseg.de>
import contextlib
import math
from expression import Expression, UnitExpression, ConstantExpression, expr
from .. import graphic_primitivese as gp
def point_distance(a, b):
x1, y1 = a
x2, y2 = b
return math.sqrt((x2 - x1)**2 + (y2 - y1)**2)
def deg_to_rad(a):
return (a / 180) * math.pi
from dataclasses import dataclass, fields
from expression import Expression, UnitExpression, ConstantExpression
class Primitive:
def __init__(self, unit, args, is_abstract):
def __init__(self, unit, args):
self.unit = unit
self.is_abstract = is_abstract
if len(args) > len(type(self).__annotations__):
raise ValueError(f'Too many arguments ({len(args)}) for aperture macro primitive {self.code} ({type(self)})')
for arg, (name, fieldtype) in zip(args, type(self).__annotations__.items()):
if is_abstract:
if fieldtype == UnitExpression:
setattr(self, name, UnitExpression(arg, unit))
else:
setattr(self, name, arg)
arg = expr(arg) # convert int/float to Expression object
if fieldtype == UnitExpression:
setattr(self, name, UnitExpression(arg, unit))
else:
setattr(self, name, arg)
@ -28,8 +41,6 @@ class Primitive:
raise ValueError(f'Too few arguments ({len(args)}) for aperture macro primitive {self.code} ({type(self)})')
def to_gerber(self, unit=None):
if not self.is_abstract:
raise TypeError(f"Something went wrong, tried to gerber'ize bound aperture macro primitive {self}")
return self.code + ',' + ','.join(
getattr(self, name).to_gerber(unit) for name in type(self).__annotations__) + '*'
@ -37,27 +48,42 @@ class Primitive:
attrs = ','.join(str(getattr(self, name)).strip('<>') for name in type(self).__annotations__)
return f'<{type(self).__name__} {attrs}>'
def bind(self, variable_binding={}):
if not self.is_abstract:
raise TypeError('{type(self).__name__} object is already instantiated, cannot bind again.')
# Return instance of the same class, but replace all attributes by their actual numeric values
return type(self)(unit=self.unit, is_abstract=False, args=[
getattr(self, name).calculate(variable_binding) for name in type(self).__annotations__
])
@contextlib.contextmanager
class Calculator:
def __init__(self, instance, variable_binding={}, unit=None):
self.instance = instance
self.variable_binding = variable_binding
self.unit = unit
class CommentPrimitive(Primitive):
code = 0
comment : str
def __enter__(self):
return self
class CirclePrimitive(Primitive):
def __exit__(self, _type, _value, _traceback):
pass
def __getattr__(self, name):
return getattr(self.instance, name).calculate(self.variable_binding, self.unit)
def __call__(self, expr):
return expr.calculate(self.variable_binding, self.unit)
class Circle(Primitive):
code = 1
exposure : Expression
diameter : UnitExpression
center_x : UnitExpression
center_y : UnitExpression
# center x/y
x : UnitExpression
y : UnitExpression
rotation : Expression = ConstantExpression(0.0)
class VectorLinePrimitive(Primitive):
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None):
with self.Calculator(variable_binding, unit) as calc:
x, y = gp.rotate_point(calc.x, calc.y, deg_to_rad(calc.rotation) + rotation, 0, 0)
x, y = x+offset[0], y+offset[1]
return [ gp.Circle(x, y, calc.r, polarity_dark=bool(calc.exposure)) ]
class VectorLine(Primitive):
code = 20
exposure : Expression
width : UnitExpression
@ -67,40 +93,90 @@ class VectorLinePrimitive(Primitive):
end_y : UnitExpression
rotation : Expression
class CenterLinePrimitive(Primitive):
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None):
with self.Calculator(variable_binding, unit) as calc:
center_x = (calc.end_x + calc.start_x) / 2
center_y = (calc.end_y + calc.start_y) / 2
delta_x = calc.end_x - calc.start_x
delta_y = calc.end_y - calc.start_y
length = point_distance((calc.start_x, calc.start_y), (calc.end_x, calc.end_y))
center_x, center_y = center_x+offset[0], center_y+offset[1]
rotation += deg_to_rad(calc.rotation) + math.atan2(delta_y, delta_x)
return [ gp.Rectangle(center_x, center_y, length, calc.width, rotation=rotation,
polarity_dark=bool(calc.exposure)) ]
class CenterLine(Primitive):
code = 21
exposure : Expression
width : UnitExpression
height : UnitExpression
# center x/y
x : UnitExpression
y : UnitExpression
rotation : Expression
def to_graphic_primitives(self, variable_binding={}, unit=None):
with self.Calculator(variable_binding, unit) as calc:
rotation += deg_to_rad(calc.rotation)
x, y = gp.rotate_point(calc.x, calc.y, rotation, 0, 0)
x, y = x+offset[0], y+offset[1]
w, h = calc.width, calc.height
class PolygonPrimitive(Primitive):
return [ gp.Rectangle(x, y, w, h, rotation, polarity_dark=bool(calc.exposure)) ]
class Polygon(Primitive):
code = 5
exposure : Expression
n_vertices : Expression
center_x : UnitExpression
center_y : UnitExpression
# center x/y
x : UnitExpression
y : UnitExpression
diameter : UnitExpression
rotation : Expression
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None):
with self.Calculator(variable_binding, unit) as calc:
rotation += deg_to_rad(calc.rotation)
x, y = gp.rotate_point(calc.x, calc.y, rotation, 0, 0)
x, y = x+offset[0], y+offset[1]
return [ gp.RegularPolygon(calc.x, calc.y, calc.diameter/2, calc.n_vertices, rotation,
polarity_dark=bool(calc.exposure)) ]
class ThermalPrimitive(Primitive):
class Thermal(Primitive):
code = 7
center_x : UnitExpression
center_y : UnitExpression
# center x/y
x : UnitExpression
y : UnitExpression
d_outer : UnitExpression
d_inner : UnitExpression
gap_w : UnitExpression
rotation : Expression
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None):
with self.Calculator(variable_binding, unit) as calc:
rotation += deg_to_rad(calc.rotation)
x, y = gp.rotate_point(calc.x, calc.y, rotation, 0, 0)
x, y = x+offset[0], y+offset[1]
class OutlinePrimitive(Primitive):
dark = bool(calc.exposure)
return [
gp.Circle(x, y, calc.d_outer/2, polarity_dark=dark),
gp.Circle(x, y, calc.d_inner/2, polarity_dark=not dark),
gp.Rectangle(x, y, d_outer, gap_w, rotation=rotation, polarity_dark=not dark),
gp.Rectangle(x, y, gap_w, d_outer, rotation=rotation, polarity_dark=not dark),
]
class Outline(Primitive):
code = 4
def __init__(self, unit, args, is_abstract):
def __init__(self, unit, args):
if len(args) < 11:
raise ValueError(f'Invalid aperture macro outline primitive, not enough parameters ({len(args)}).')
if len(args) > 5004:
@ -108,42 +184,36 @@ class OutlinePrimitive(Primitive):
self.exposure = args[0]
if is_abstract:
# length arg must not contain variabels (that would not make sense)
length_arg = args[1].calculate()
# length arg must not contain variables (that would not make sense)
length_arg = args[1].calculate()
if length_arg != len(args)//2 - 2:
raise ValueError(f'Invalid aperture macro outline primitive, given size does not match length of coordinate list({len(args)}).')
if len(args) % 1 != 1:
self.rotation = args.pop()
else:
self.rotation = ConstantExpression(0.0)
if args[2] != args[-2] or args[3] != args[-1]:
raise ValueError(f'Invalid aperture macro outline primitive, polygon is not closed {args[2:4], args[-3:-1]}')
self.coords = [UnitExpression(arg, unit) for arg in args[1:]]
if length_arg != len(args)//2 - 2:
raise ValueError(f'Invalid aperture macro outline primitive, given size does not match length of coordinate list({len(args)}).')
if len(args) % 1 != 1:
self.rotation = args.pop()
else:
if len(args) % 1 != 1:
self.rotation = args.pop()
else:
self.rotation = 0
self.rotation = ConstantExpression(0.0)
if args[2] != args[-2] or args[3] != args[-1]:
raise ValueError(f'Invalid aperture macro outline primitive, polygon is not closed {args[2:4], args[-3:-1]}')
self.coords = [(UnitExpression(x, unit), UnitExpression(y, unit)) for x, y in zip(args[1::2], args[2::2])]
self.coords = args[1:]
def to_gerber(self, unit=None):
if not self.is_abstract:
raise TypeError(f"Something went wrong, tried to gerber'ize bound aperture macro primitive {self}")
coords = ','.join(coord.to_gerber(unit) for coord in self.coords)
return f'{self.code},{self.exposure.to_gerber()},{len(self.coords)//2-1},{coords},{self.rotation.to_gerber()}'
def bind(self, variable_binding={}):
if not self.is_abstract:
raise TypeError('{type(self).__name__} object is already instantiated, cannot bind again.')
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None):
with self.Calculator(variable_binding, unit) as calc:
bound_coords = [ (calc(x)+offset[0], calc(y)+offset[1]) for x, y in self.coords ]
bound_radii = [None] * len(bound_coords)
rotation += deg_to_rad(calc.rotation)
bound_coords = [ rotate_point(*p, rotation, 0, 0) for p in bound_coords ]
return gp.ArcPoly(bound_coords, bound_radii, polarity_dark=calc.exposure)
return OutlinePrimitive(self.unit, is_abstract=False, args=[None, *self.coords, self.rotation])
class Comment:
def __init__(self, comment):
@ -154,13 +224,13 @@ class Comment:
PRIMITIVE_CLASSES = {
**{cls.code: cls for cls in [
CommentPrimitive,
CirclePrimitive,
VectorLinePrimitive,
CenterLinePrimitive,
OutlinePrimitive,
PolygonPrimitive,
ThermalPrimitive,
Comment,
Circle,
VectorLine,
CenterLine,
Outline,
Polygon,
Thermal,
]},
# alternative codes
2: VectorLinePrimitive,

View file

@ -1,11 +1,13 @@
from dataclasses import dataclass
import math
from dataclasses import dataclass, replace
from aperture_macros.parse import GenericMacros
from primitives import Primitive
import graphic_primitives as gp
def _flash_hole(self, x, y):
if self.hole_rect_h is not None:
return self.primitives(x, y), Rectangle((x, y), (self.hole_dia, self.hole_rect_h), polarity_dark=False)
return self.primitives(x, y), Rectangle((x, y), (self.hole_dia, self.hole_rect_h), rotation=self.rotation, polarity_dark=False)
else:
return self.primitives(x, y), Circle((x, y), self.hole_dia, polarity_dark=False)
@ -21,65 +23,185 @@ class Aperture:
def hole_size(self):
return (self.hole_dia, self.hole_rect_h)
@property
def params(self):
return dataclasses.astuple(self)
def flash(self, x, y):
return self.primitives(x, y)
@parameter
def equivalent_width(self):
raise ValueError('Non-circular aperture used in interpolation statement, line width is not properly defined.')
@dataclass
class ApertureCircle(Aperture):
def to_gerber(self):
# Hack: The standard aperture shapes C, R, O do not have a rotation parameter. To make this API easier to use,
# 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}'
def __eq__(self, other):
return hasattr(other, to_gerber) and self.to_gerber() == other.to_gerber()
def _rotate_hole_90(self):
if self.hole_rect_h is None:
return {'hole_dia': self.hole_dia, 'hole_rect_h': None}
else:
return {'hole_dia': self.hole_rect_h, 'hole_rect_h': self.hole_dia}
@dataclass(frozen=True)
class CircleAperture(Aperture):
gerber_shape_code = 'C'
human_readable_shape = 'circle'
diameter : float
hole_dia : float = 0
hole_rect_h : float = None
rotation : float = 0 # radians; for rectangular hole; see hack in Aperture.to_gerber
def primitives(self, x, y):
return Circle((x, y), self.diameter, polarity_dark=True),
def primitives(self, x, y, rotation):
return [ gp.Circle(x, y, self.diameter/2) ]
def __str__(self):
return f'<circle aperture d={self.diameter:.3}>'
flash = _flash_hole
@parameter
def equivalent_width(self):
return self.diameter
@dataclass
class ApertureRectangle(Aperture):
def rotated(self):
if math.isclose(rotation % (2*math.pi), 0) or self.hole_rect_h is None:
return self
else:
return self.to_macro(self.rotation)
def to_macro(self):
return ApertureMacroInstance(GenericMacros.circle, *self.params)
@dataclass(frozen=True)
class RectangleAperture(Aperture):
gerber_shape_code = 'R'
human_readable_shape = 'rect'
w : float
h : float
hole_dia : float = 0
hole_rect_h : float = None
rotation : float = 0 # radians
def primitives(self, x, y):
return Rectangle((x, y), (self.w, self.h), polarity_dark=True),
return [ gp.Rectangle(x, y, self.w, self.h, rotation=self.rotation) ]
def __str__(self):
return f'<rect aperture {self.w:.3}x{self.h:.3}>'
flash = _flash_hole
@parameter
def equivalent_width(self):
return math.sqrt(self.w**2 + self.h**2)
@dataclass
class ApertureObround(Aperture):
def _rotated(self):
if math.isclose(self.rotation % math.pi, 0):
return self
elif math.isclose(self.rotation % math.pi, math.pi/2):
return replace(self, w=self.h, h=self.w, **self._rotate_hole_90())
else: # odd angle
return self.to_macro()
def to_macro(self):
return ApertureMacroInstance(GenericMacros.rect, *self.params)
@dataclass(frozen=True)
class ObroundAperture(Aperture):
gerber_shape_code = 'O'
human_readable_shape = 'obround'
w : float
h : float
hole_dia : float = 0
hole_rect_h : float = None
rotation : float = 0
def primitives(self, x, y):
return Obround((x, y), self.w, self.h, polarity_dark=True)
return [ gp.Obround(x, y, self.w, self.h, rotation=self.rotation) ]
def __str__(self):
return f'<obround aperture {self.w:.3}x{self.h:.3}>'
flash = _flash_hole
def _rotated(self):
if math.isclose(self.rotation % math.pi, 0):
return self
elif math.isclose(self.rotation % math.pi, math.pi/2):
return replace(self, w=self.h, h=self.w, **self._rotate_hole_90())
else:
return self.to_macro()
@dataclass
class AperturePolygon(Aperture):
def to_macro(self, rotation:'radians'=0):
# generic macro only supports w > h so flip x/y if h > w
inst = self if self.w > self.h else replace(self, w=self.h, h=self.w, **_rotate_hole_90(self))
return ApertureMacroInstance(GenericMacros.obround, *inst.params)
@dataclass(frozen=True)
class PolygonAperture(Aperture):
gerber_shape_code = 'P'
diameter : float
n_vertices : int
rotation : float = 0
hole_dia : float = 0
hole_rect_h : float = None
def primitives(self, x, y):
return Polygon((x, y), diameter, n_vertices, rotation, polarity_dark=True),
return [ gp.RegularPolygon(x, y, diameter, n_vertices, rotation=self.rotation) ]
def __str__(self):
return f'<{self.n_vertices}-gon aperture d={self.diameter:.3}'
flash = _flash_hole
class MacroAperture(Aperture):
parameters : [float]
self.macro : ApertureMacro
def _rotated(self):
self.rotation %= (2*math.pi / self.n_vertices)
return self
def to_macro(self):
return ApertureMacroInstance(GenericMacros.polygon, *self.params)
class ApertureMacroInstance(Aperture):
params : [float]
rotation : float = 0
def __init__(self, macro, *parameters):
self.params = parameters
self._primitives = macro.to_graphic_primitives(parameters)
self.macro = macro
@property
def gerber_shape_code(self):
return self.macro.name
def primitives(self, x, y):
return self.macro.execute(x, y, self.parameters)
# FIXME return graphical primitives not macro primitives here
return [ primitive.with_offset(x, y).rotated(self.rotation, cx=0, cy=0) for primitive in self._primitives ]
def _rotated(self):
if math.isclose(self.rotation % (2*math.pi), 0):
return self
else:
return self.to_macro()
def to_macro(self):
return type(self)(self.macro.rotated(self.rotation), self.params)
def __eq__(self, other):
return hasattr(other, 'macro') and self.macro == other.macro and \
hasattr(other, 'params') and self.params == other.params and \
hasattr(other, 'rotation') and self.rotation == other.rotation

View file

@ -20,12 +20,16 @@ from dataclasses import dataclass
@dataclass
class FileSettings:
output_axes : str = 'AXBY' # For deprecated AS statement
image_polarity : str = 'positive'
image_rotation: int = 0
mirror_image : tuple = (False, False)
offset : tuple = (0, 0)
scale_factor : tuple = (1.0, 1.0) # For deprecated SF statement
'''
.. note::
Format and zero suppression are configurable. Note that the Excellon
and Gerber formats use opposite terminology with respect to leading
and trailing zeros. The Gerber format specifies which zeros are
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'`
'''
notation : str = 'absolute'
units : str = 'inch'
angle_units : str = 'degrees'
@ -34,18 +38,6 @@ class FileSettings:
# input validation
def __setattr__(self, name, value):
if name == 'output_axes' and value not in [None, 'AXBY', 'AYBX']:
raise ValueError('output_axes must be either "AXBY", "AYBX" or None')
if name == 'image_rotation' and value not in [0, 90, 180, 270]:
raise ValueError('image_rotation must be 0, 90, 180 or 270')
elif name == 'image_polarity' and value not in ['positive', 'negative']:
raise ValueError('image_polarity must be either "positive" or "negative"')
elif name == 'mirror_image' and len(value) != 2:
raise ValueError('mirror_image must be 2-tuple of bools: (mirror_a, mirror_b)')
elif name == 'offset' and len(value) != 2:
raise ValueError('offset must be 2-tuple of floats: (offset_a, offset_b)')
elif name == 'scale_factor' and len(value) != 2:
raise ValueError('scale_factor must be 2-tuple of floats: (scale_a, scale_b)')
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']:
@ -54,14 +46,65 @@ class FileSettings:
raise ValueError('Angle units may be "degrees" or "radians"')
elif name == 'zeros' and value not in [None, 'leading', 'trailing']:
raise ValueError('zero_suppression must be either "leading" or "trailing" or None')
elif name == 'number_format' and len(value) != 2:
raise ValueError('Number format must be a (integer, fractional) tuple of integers')
elif name == 'number_format':
if len(value) != 2:
raise ValueError('Number format must be a (integer, fractional) tuple of integers')
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.')
super().__setattr__(name, value)
def __str__(self):
return f'<File settings: units={self.units}/{self.angle_units} notation={self.notation} zeros={self.zeros} number_format={self.number_format}>'
def parse_gerber_value(self, value):
if not value:
return None
# Handle excellon edge case with explicit decimal. "That was easy!"
if '.' in value:
return float(value)
# Format precision
integer_digits, decimal_digits = self.number_format
# Remove extraneous information
sign = '-' if value[0] == '-' else ''
value = value.lstrip('+-')
missing_digits = MAX_DIGITS - len(value)
if self.zero_suppression == 'leading':
return float(sign + value[:-decimal_digits] + '.' + value[-decimal_digits:])
else: # no or trailing zero suppression
return float(sign + value[:integer_digits] + '.' + value[integer_digits:])
def write_gerber_value(self, value):
""" Convert a floating point number to a Gerber/Excellon-formatted string. """
integer_digits, decimal_digits = self.number_format
# 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')
# Suppression...
if self.zero_suppression == 'trailing':
num = num.rstrip('0')
elif self.zero_suppression == '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.
elif not num.strip('0'):
num = '0'
return sign + (num or '0')
class CamFile(object):
""" Base class for Gerber/Excellon files.
@ -101,39 +144,12 @@ class CamFile(object):
decimal digits)
"""
def __init__(self, statements=None, settings=None, primitives=None,
def __init__(self, settings=None, primitives=None,
filename=None, layer_name=None):
if settings is not None:
self.notation = settings['notation']
self.units = settings['units']
self.zero_suppression = settings['zero_suppression']
self.zeros = settings['zeros']
self.format = settings['format']
else:
self.notation = 'absolute'
self.units = 'inch'
self.zero_suppression = 'trailing'
self.zeros = 'leading'
self.format = (2, 5)
self.statements = statements if statements is not None else []
if primitives is not None:
self.primitives = primitives
self.settings = settings if settings is not None else FileSettings()
self.filename = filename
self.layer_name = layer_name
@property
def settings(self):
""" File settings
Returns
-------
settings : FileSettings (dict-like)
A FileSettings object with the specified configuration.
"""
return FileSettings(self.notation, self.units, self.zero_suppression,
self.format)
@property
def bounds(self):
""" File boundaries
@ -144,12 +160,6 @@ class CamFile(object):
def bounding_box(self):
pass
def to_inch(self):
pass
def to_metric(self):
pass
def render(self, ctx=None, invert=False, filename=None):
""" Generate image of layer.

View file

@ -1,71 +0,0 @@
#! /usr/bin/env python
# -*- coding: utf-8 -*-
# Copyright 2014 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.
from . import rs274x
from . import excellon
from . import ipc356
from .exceptions import ParseError
from .utils import detect_file_format
def read(filename):
""" Read a gerber or excellon file and return a representative object.
Parameters
----------
filename : string
Filename of the file to read.
Returns
-------
file : CncFile subclass
CncFile object representing the file, either GerberFile, ExcellonFile,
or IPCNetlist. Returns None if file is not of the proper type.
"""
with open(filename, 'r') as f:
data = f.read()
return loads(data, filename)
def loads(data, filename=None):
""" Read gerber or excellon file contents from a string and return a
representative object.
Parameters
----------
data : string
Source file contents as a string.
filename : string, optional
String containing the filename of the data source.
Returns
-------
file : CncFile subclass
CncFile object representing the data, either GerberFile, ExcellonFile,
or IPCNetlist. Returns None if data is not of the proper type.
"""
fmt = detect_file_format(data)
if fmt == 'rs274x':
return rs274x.loads(data, filename=filename)
elif fmt == 'excellon':
return excellon.loads(data, filename=filename)
elif fmt == 'ipc_d_356':
return ipc356.loads(data, filename=filename)
else:
raise ParseError('Unable to detect file format')

View file

@ -20,14 +20,7 @@ Gerber (RS-274X) Statements
**Gerber RS-274X file statement classes**
"""
from .utils import (parse_gerber_value, write_gerber_value, decimal_string,
inch, metric)
from .am_statements import *
from .am_read import read_macro
from .am_primitive import eval_macro
from .primitives import AMGroup
from utils import parse_gerber_value, write_gerber_value, decimal_string, inch, metric
class Statement:
pass
@ -86,202 +79,28 @@ class LoadPolarityStmt(ParamStmt):
class ApertureDefStmt(ParamStmt):
""" AD - Aperture Definition Statement """
@classmethod
def rect(cls, dcode, width, height, hole_diameter=None, hole_width=None, hole_height=None):
'''Create a rectangular aperture definition statement'''
if hole_diameter is not None and hole_diameter > 0:
return cls('AD', dcode, 'R', ([width, height, hole_diameter],))
elif (hole_width is not None and hole_width > 0
and hole_height is not None and hole_height > 0):
return cls('AD', dcode, 'R', ([width, height, hole_width, hole_height],))
return cls('AD', dcode, 'R', ([width, height],))
@classmethod
def circle(cls, dcode, diameter, hole_diameter=None, hole_width=None, hole_height=None):
'''Create a circular aperture definition statement'''
if hole_diameter is not None and hole_diameter > 0:
return cls('AD', dcode, 'C', ([diameter, hole_diameter],))
elif (hole_width is not None and hole_width > 0
and hole_height is not None and hole_height > 0):
return cls('AD', dcode, 'C', ([diameter, hole_width, hole_height],))
return cls('AD', dcode, 'C', ([diameter],))
@classmethod
def obround(cls, dcode, width, height, hole_diameter=None, hole_width=None, hole_height=None):
'''Create an obround aperture definition statement'''
if hole_diameter is not None and hole_diameter > 0:
return cls('AD', dcode, 'O', ([width, height, hole_diameter],))
elif (hole_width is not None and hole_width > 0
and hole_height is not None and hole_height > 0):
return cls('AD', dcode, 'O', ([width, height, hole_width, hole_height],))
return cls('AD', dcode, 'O', ([width, height],))
@classmethod
def polygon(cls, dcode, diameter, num_vertices, rotation, hole_diameter=None, hole_width=None, hole_height=None):
'''Create a polygon aperture definition statement'''
if hole_diameter is not None and hole_diameter > 0:
return cls('AD', dcode, 'P', ([diameter, num_vertices, rotation, hole_diameter],))
elif (hole_width is not None and hole_width > 0
and hole_height is not None and hole_height > 0):
return cls('AD', dcode, 'P', ([diameter, num_vertices, rotation, hole_width, hole_height],))
return cls('AD', dcode, 'P', ([diameter, num_vertices, rotation],))
@classmethod
def macro(cls, dcode, name):
return cls('AD', dcode, name, '')
@classmethod
def from_dict(cls, stmt_dict):
param = stmt_dict.get('param')
d = int(stmt_dict.get('d'))
shape = stmt_dict.get('shape')
modifiers = stmt_dict.get('modifiers')
return cls(param, d, shape, modifiers)
def __init__(self, param, d, shape, modifiers):
""" Initialize ADParamStmt class
Parameters
----------
param : string
Parameter code
d : int
Aperture D-code
shape : string
aperture name
modifiers : list of lists of floats
Shape modifiers
Returns
-------
ParamStmt : ADParamStmt
Initialized ADParamStmt class.
"""
ParamStmt.__init__(self, param)
self.d = d
self.shape = shape
if isinstance(modifiers, tuple):
self.modifiers = modifiers
elif modifiers:
self.modifiers = [tuple([float(x) for x in m.split("X") if len(x)])
for m in modifiers.split(",") if len(m)]
else:
self.modifiers = [tuple()]
def to_inch(self):
if self.units == 'metric':
self.units = 'inch'
self.modifiers = [tuple([inch(x) for x in modifier])
for modifier in self.modifiers]
def to_metric(self):
if self.units == 'inch':
self.units = 'metric'
self.modifiers = [tuple([metric(x) for x in modifier])
for modifier in self.modifiers]
def __init__(self, number, aperture):
self.number = number
self.aperture = aperture
def to_gerber(self, settings=None):
if any(self.modifiers):
return '%ADD{0}{1},{2}*%'.format(self.d, self.shape, ','.join(['X'.join(["%.4g" % x for x in modifier]) for modifier in self.modifiers]))
else:
return '%ADD{0}{1}*%'.format(self.d, self.shape)
return '%ADD{self.number}{self.aperture.to_gerber()}*%'
def __str__(self):
if self.shape == 'C':
shape = 'circle'
elif self.shape == 'R':
shape = 'rectangle'
elif self.shape == 'O':
shape = 'obround'
else:
shape = self.shape
return '<Aperture Definition: %d: %s>' % (self.d, shape)
return f'<AD aperture def for {str(self.aperture).strip("<>")}>'
class AMParamStmt(ParamStmt):
""" AM - Aperture Macro Statement
"""
class ApertureMacroStmt(ParamStmt):
""" AM - Aperture Macro Statement """
@classmethod
def from_dict(cls, stmt_dict, units):
return cls(**stmt_dict, units=units)
def __init__(self, param, name, macro, units):
""" Initialize AMParamStmt class
Parameters
----------
param : string
Parameter code
name : string
Aperture macro name
macro : string
Aperture macro string
Returns
-------
ParamStmt : AMParamStmt
Initialized AMParamStmt class.
"""
ParamStmt.__init__(self, param)
self.name = name
def __init__(self, macro):
self.macro = macro
self.units = units
self.primitives = list(eval_macro(read_macro(macro), units))
@classmethod
def circle(cls, name, units):
return cls('AM', name, '1,1,$1,0,0,0*1,0,$2,0,0,0', units)
@classmethod
def rectangle(cls, name, units):
return cls('AM', name, '21,1,$1,$2,0,0,0*1,0,$3,0,0,0', units)
@classmethod
def landscape_obround(cls, name, units):
return cls(
'AM', name,
'$4=$1-$2*'
'$5=$1-$4*'
'21,1,$5,$2,0,0,0*'
'1,1,$4,$4/2,0,0*'
'1,1,$4,-$4/2,0,0*'
'1,0,$3,0,0,0', units)
@classmethod
def portrate_obround(cls, name, units):
return cls(
'AM', name,
'$4=$2-$1*'
'$5=$2-$4*'
'21,1,$1,$5,0,0,0*'
'1,1,$4,0,$4/2,0*'
'1,1,$4,0,-$4/2,0*'
'1,0,$3,0,0,0', units)
@classmethod
def polygon(cls, name, units):
return cls('AM', name, '5,1,$2,0,0,$1,$3*1,0,$4,0,0,0', units)
def to_gerber(self, unit=None):
primitive_defs = '\n'.join(primitive.to_gerber(unit=unit) for primitive in self.primitives)
return f'%AM{self.name}*\n{primitive_defs}%'
def rotate(self, angle, center=None):
for primitive_def in self.primitives:
primitive_def.rotate(angle, center)
return f'%AM{self.macro.name}*\n{self.macro.to_gerber(unit=unit)}*\n%'
def __str__(self):
return '<AM Aperture Macro %s: %s>' % (self.name, self.macro)
return f'<AM Aperture Macro {self.macro.name}: {self.macro}>'
class ImagePolarityStmt(ParamStmt):
@ -298,7 +117,7 @@ class ImagePolarityStmt(ParamStmt):
class CoordStmt(Statement):
""" D01 - D03 operation statements """
def __init__(self, x, y, i, j):
def __init__(self, x, y, i=None, j=None):
self.x, self.y, self.i, self.j = x, y, i, j
def to_gerber(self, settings=None):
@ -309,22 +128,12 @@ class CoordStmt(Statement):
ret += var.upper() + write_gerber_value(val, settings.number_format, settings.zero_suppression)
return ret + self.code + '*'
def offset(self, x=0, y=0):
if self.x is not None:
self.x += x
if self.y is not None:
self.y += y
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]>'
def render_primitives(self, state):
if state.interpolation_mode == InterpolateStmt:
yield Line(state.current_point, (self.x, self.y))
class InterpolateStmt(Statement):
""" D01 Interpolation """
code = 'D01'
@ -369,20 +178,12 @@ class RegionEndStatement(InterpolationModeStmt):
""" G37 Region Mode End Statement. """
code = 'G37'
class RegionGroup:
def __init__(self):
self.outline = []
class ApertureStmt(Statement):
def __init__(self, d):
self.d = int(d)
self.deprecated = True if deprecated is not None and deprecated is not False else False
def to_gerber(self, settings=None):
if self.deprecated:
return 'G54D{0}*'.format(self.d)
else:
return 'D{0}*'.format(self.d)
return 'D{0}*'.format(self.d)
def __str__(self):
return '<Aperture: %d>' % self.d

View file

@ -0,0 +1,140 @@
import math
import itertools
from dataclasses import dataclass, KW_ONLY, replace
from gerber_statements import *
class GraphicPrimitive:
_ : KW_ONLY
polarity_dark : bool = True
def rotate_point(x, y, angle, cx=None, cy=None):
if cx is None:
return (x, y)
else:
return (cx + (x - cx) * math.cos(angle) - (y - cy) * math.sin(angle),
cy + (x - cx) * math.sin(angle) + (y - cy) * math.cos(angle))
@dataclass
class Circle(GraphicPrimitive):
x : float
y : float
r : float # Here, we use radius as common in modern computer graphics, not diameter as gerber uses.
def bounds(self):
return ((self.x-self.r, self.y-self.r), (self.x+self.r, self.y+self.r))
@dataclass
class Obround(GraphicPrimitive):
x : float
y : float
w : float
h : float
rotation : float # radians!
def decompose(self):
''' decompose obround to two circles and one rectangle '''
cx = self.x + self.w/2
cy = self.y + self.h/2
if self.w > self.h:
x = self.x + self.h/2
yield Circle(x, cy, self.h/2)
yield Circle(x + self.w, cy, self.h/2)
yield Rectangle(x, self.y, self.w - self.h, self.h)
elif self.h > self.w:
y = self.y + self.w/2
yield Circle(cx, y, self.w/2)
yield Circle(cx, y + self.h, self.w/2)
yield Rectangle(self.x, y, self.w, self.h - self.w)
else:
yield Circle(cx, cy, self.w/2)
def bounds(self):
return ((self.x-self.w/2, self.y-self.h/2), (self.x+self.w/2, self.y+self.h/2))
@dataclass
class ArcPoly(GraphicPrimitive):
""" Polygon whose sides may be either straight lines or circular arcs """
# list of (x : float, y : float) tuples. Describes closed outline, i.e. first and last point are considered
# connected.
outline : list(tuple(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))
@property
def segments(self):
return itertools.zip_longest(self.outline[:-1], self.outline[1:], self.radii or [])
def bounds(self):
for (x1, y1), (x2, y2), radius in self.segments:
return
@dataclass
class Line(GraphicPrimitive):
x1 : float
y1 : float
x2 : float
y2 : float
width : float
# FIXME bounds
@dataclass
class Arc(GraphicPrimitive):
x : float
y : float
r : float
angle1 : float # radians!
angle2 : float # radians!
width : float
# FIXME bounds
@dataclass
class Rectangle(GraphicPrimitive):
# coordinates are center coordinates
x : float
y : float
w : float
h : float
rotation : float # radians, around center!
def bounds(self):
return ((self.x, self.y), (self.x+self.w, self.y+self.h))
@prorperty
def center(self):
return self.x + self.w/2, self.y + self.h/2
class RegularPolygon(GraphicPrimitive):
x : float
y : float
r : float
n : int
rotation : float # radians!
def decompose(self):
''' convert n-sided gerber polygon to normal Region defined by outline '''
delta = 2*math.pi / self.n
yield Region([
(self.x + math.cos(self.rotation + i*delta) * self.r,
self.y + math.sin(self.rotation + i*delta) * self.r)
for i in range(self.n) ])

View file

@ -38,7 +38,7 @@ class Primitive:
class Line(Primitive):
def __init__(self, start, end, aperture, polarity_dark=True, rotation=0, **meta):
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
@ -240,9 +240,6 @@ class Arc(Primitive):
class Circle(Primitive):
"""
"""
def __init__(self, position, diameter, polarity_dark=True):
super(Circle, self).__init__(**kwargs)
validate_coordinates(position)
@ -922,3 +919,14 @@ class TestRecord(Primitive):
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)

File diff suppressed because it is too large Load diff

View file

@ -29,148 +29,6 @@ from math import radians, sin, cos, sqrt, atan2, pi
MILLIMETERS_PER_INCH = 25.4
def parse_gerber_value(value, settings):
""" Convert gerber/excellon formatted string to floating-point number
.. note::
Format and zero suppression are configurable. Note that the Excellon
and Gerber formats use opposite terminology with respect to leading
and trailing zeros. The Gerber format specifies which zeros are
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'`
Parameters
----------
value : string
A Gerber/Excellon-formatted string representing a numerical value.
format : tuple (int,int)
Gerber/Excellon precision format expressed as a tuple containing:
(number of integer-part digits, number of decimal-part digits)
zero_suppression : string
Zero-suppression mode. May be 'leading', 'trailing' or 'none'
Returns
-------
value : float
The specified value as a floating-point number.
"""
if not value:
return None
# Handle excellon edge case with explicit decimal. "That was easy!"
if '.' in value:
return float(value)
# Format precision
integer_digits, decimal_digits = settings.format
MAX_DIGITS = integer_digits + decimal_digits
# Absolute maximum number of digits supported. This will handle up to
# 6:7 format, which is somewhat supported, even though the gerber spec
# only allows up to 6:6
if MAX_DIGITS > 13 or integer_digits > 6 or decimal_digits > 7:
raise ValueError('Parser only supports precision up to 6:7 format')
# Remove extraneous information
value = value.lstrip('+')
negative = '-' in value
if negative:
value = value.lstrip('-')
missing_digits = MAX_DIGITS - len(value)
if settings.zero_suppression == 'trailing':
digits = list(value + ('0' * missing_digits))
elif settings.zero_suppression == 'leading':
digits = list(('0' * missing_digits) + value)
else:
digits = list(value)
result = float(
''.join(digits[:integer_digits] + ['.'] + digits[integer_digits:]))
return -result if negative else result
def write_gerber_value(value, settings):
""" Convert a floating point number to a Gerber/Excellon-formatted string.
.. note::
Format and zero suppression are configurable. Note that the Excellon
and Gerber formats use opposite terminology with respect to leading
and trailing zeros. The Gerber format specifies which zeros are
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'`
Parameters
----------
value : float
A floating point value.
format : tuple (n=2)
Gerber/Excellon precision format expressed as a tuple containing:
(number of integer-part digits, number of decimal-part digits)
zero_suppression : string
Zero-suppression mode. May be 'leading', 'trailing' or 'none'
Returns
-------
value : string
The specified value as a Gerber/Excellon-formatted string.
"""
if format[0] == float:
return "%f" %value
# Format precision
integer_digits, decimal_digits = settings.format
MAX_DIGITS = integer_digits + decimal_digits
if MAX_DIGITS > 13 or integer_digits > 6 or decimal_digits > 7:
raise ValueError('Parser only supports precision up to 6:7 format')
# Edge case... (per Gerber spec we should return 0 in all cases, see page
# 77)
if value == 0:
return '0'
# negative sign affects padding, so deal with it at the end...
negative = value < 0.0
if negative:
value = -1.0 * value
# Format string for padding out in both directions
fmtstring = '%%0%d.0%df' % (MAX_DIGITS + 1, decimal_digits)
digits = [val for val in fmtstring % value if val != '.']
# If all the digits are 0, return '0'.
digit_sum = sum([int(digit) for digit in digits])
if digit_sum == 0:
return '0'
# Suppression...
if settings.zero_suppression == 'trailing':
while digits and digits[-1] == '0':
digits.pop()
elif settings.zero_suppression == 'leading':
while digits and digits[0] == '0':
digits.pop(0)
if not digits:
return '0'
return ''.join(digits) if not negative else ''.join(['-'] + digits)
def decimal_string(value, precision=6, padding=False):
""" Convert float to string with limited precision
@ -208,32 +66,6 @@ def decimal_string(value, precision=6, padding=False):
else:
return int(floatstr)
def detect_file_format(data):
""" Determine format of a file
Parameters
----------
data : string
string containing file data.
Returns
-------
format : string
File format. 'excellon' or 'rs274x' or 'unknown'
"""
lines = data.split('\n')
for line in lines:
if 'M48' in line:
return 'excellon'
elif '%FS' in line:
return 'rs274x'
elif ((len(line.split()) >= 2) and
(line.split()[0] == 'P') and (line.split()[1] == 'JOB')):
return 'ipc_d_356'
return 'unknown'
def validate_coordinates(position):
if position is not None:
if len(position) != 2: