summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorjaseg <git@jaseg.de>2021-12-28 21:40:22 +0100
committerjaseg <git@jaseg.de>2021-12-28 21:40:22 +0100
commit63e1eae8d81cb7940d3547511488f8ec4acd4d1c (patch)
treec0d2ddf93d8637d0df600a320cbf9d1387860163
parent25dd65fac05a43ef75fe75049d5b79a73a207fc0 (diff)
downloadgerbonara-63e1eae8d81cb7940d3547511488f8ec4acd4d1c.tar.gz
gerbonara-63e1eae8d81cb7940d3547511488f8ec4acd4d1c.tar.bz2
gerbonara-63e1eae8d81cb7940d3547511488f8ec4acd4d1c.zip
WIP
-rw-r--r--gerbonara/gerber/__init__.py1
-rw-r--r--gerbonara/gerber/aperture_macros/expression.py37
-rw-r--r--gerbonara/gerber/aperture_macros/primitive.py204
-rw-r--r--gerbonara/gerber/apertures.py164
-rw-r--r--gerbonara/gerber/cam.py120
-rw-r--r--gerbonara/gerber/common.py71
-rw-r--r--gerbonara/gerber/gerber_statements.py225
-rw-r--r--gerbonara/gerber/graphic_primitives.py140
-rw-r--r--gerbonara/gerber/primitives.py16
-rw-r--r--gerbonara/gerber/rs274x.py935
-rw-r--r--gerbonara/gerber/utils.py168
11 files changed, 833 insertions, 1248 deletions
diff --git a/gerbonara/gerber/__init__.py b/gerbonara/gerber/__init__.py
index a3d4753..5cf9dc1 100644
--- a/gerbonara/gerber/__init__.py
+++ b/gerbonara/gerber/__init__.py
@@ -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
diff --git a/gerbonara/gerber/aperture_macros/expression.py b/gerbonara/gerber/aperture_macros/expression.py
index 74fbd90..ddd8d53 100644
--- a/gerbonara/gerber/aperture_macros/expression.py
+++ b/gerbonara/gerber/aperture_macros/expression.py
@@ -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".')
diff --git a/gerbonara/gerber/aperture_macros/primitive.py b/gerbonara/gerber/aperture_macros/primitive.py
index f4400f5..aeb38c4 100644
--- a/gerbonara/gerber/aperture_macros/primitive.py
+++ b/gerbonara/gerber/aperture_macros/primitive.py
@@ -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
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, _type, _value, _traceback):
+ pass
-class CommentPrimitive(Primitive):
- code = 0
- comment : str
+ def __getattr__(self, name):
+ return getattr(self.instance, name).calculate(self.variable_binding, self.unit)
-class CirclePrimitive(Primitive):
+ 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
+
+ return [ gp.Rectangle(x, y, w, h, rotation, polarity_dark=bool(calc.exposure)) ]
+
-class PolygonPrimitive(Primitive):
+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]
+
+ 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 OutlinePrimitive(Primitive):
+
+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()
-
- 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)}).')
+ # length arg must not contain variables (that would not make sense)
+ length_arg = args[1].calculate()
- if len(args) % 1 != 1:
- self.rotation = args.pop()
- else:
- self.rotation = ConstantExpression(0.0)
+ 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 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]}')
+ if len(args) % 1 != 1:
+ self.rotation = args.pop()
+ else:
+ self.rotation = ConstantExpression(0.0)
- self.coords = [UnitExpression(arg, unit) for arg in args[1:]]
+ 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]}')
- else:
- if len(args) % 1 != 1:
- self.rotation = args.pop()
- else:
- self.rotation = 0
+ 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,
diff --git a/gerbonara/gerber/apertures.py b/gerbonara/gerber/apertures.py
index aa2764e..2c03a37 100644
--- a/gerbonara/gerber/apertures.py
+++ b/gerbonara/gerber/apertures.py
@@ -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.')
+
+ 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}'
-@dataclass
-class ApertureCircle(Aperture):
+ 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)
+
+ 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
-class ApertureObround(Aperture):
+@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()
+
+ 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
-class AperturePolygon(Aperture):
+@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
diff --git a/gerbonara/gerber/cam.py b/gerbonara/gerber/cam.py
index 7d68ae2..fa46ba2 100644
--- a/gerbonara/gerber/cam.py
+++ b/gerbonara/gerber/cam.py
@@ -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,40 +144,13 @@ 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.
diff --git a/gerbonara/gerber/common.py b/gerbonara/gerber/common.py
deleted file mode 100644
index 12b87c4..0000000
--- a/gerbonara/gerber/common.py
+++ /dev/null
@@ -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')
diff --git a/gerbonara/gerber/gerber_statements.py b/gerbonara/gerber/gerber_statements.py
index 6faf15e..7555a18 100644
--- a/gerbonara/gerber/gerber_statements.py
+++ b/gerbonara/gerber/gerber_statements.py
@@ -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)
-
-
-class AMParamStmt(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
+ return f'<AD aperture def for {str(self.aperture).strip("<>")}>'
- Parameters
- ----------
- param : string
- Parameter code
- name : string
- Aperture macro name
+class ApertureMacroStmt(ParamStmt):
+ """ AM - Aperture Macro Statement """
- 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
diff --git a/gerbonara/gerber/graphic_primitives.py b/gerbonara/gerber/graphic_primitives.py
new file mode 100644
index 0000000..391a452
--- /dev/null
+++ b/gerbonara/gerber/graphic_primitives.py
@@ -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) ])
+
diff --git a/gerbonara/gerber/primitives.py b/gerbonara/gerber/primitives.py
index 25f8e06..d505ddb 100644
--- a/gerbonara/gerber/primitives.py
+++ b/gerbonara/gerber/primitives.py
@@ -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)
+
diff --git a/gerbonara/gerber/rs274x.py b/gerbonara/gerber/rs274x.py
index 0c7b1f4..f2473b9 100644
--- a/gerbonara/gerber/rs274x.py
+++ b/gerbonara/gerber/rs274x.py
@@ -26,92 +26,30 @@ import os
import re
import sys
import warnings
+import functools
from pathlib import Path
from itertools import count, chain
from io import StringIO
from .gerber_statements import *
-from .primitives 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
class GerberFile(CamFile):
""" A class representing a single gerber file
The GerberFile class represents a single gerber file.
-
- Parameters
- ----------
- statements : list
- list of gerber file statements
-
- settings : dict
- Dictionary of gerber file settings
-
- filename : string
- Filename of the source gerber file
-
- Attributes
- ----------
- comments: list of strings
- List of comments contained in the gerber file.
-
- size : tuple, (<float>, <float>)
- Size in [self.units] of the layer described by the gerber file.
-
- bounds: tuple, ((<float>, <float>), (<float>, <float>))
- boundaries of the layer described by the gerber file.
- `bounds` is stored as ((min x, max x), (min y, max y))
-
"""
- def __init__(self, statements, settings, primitives, apertures, filename=None):
- super(GerberFile, self).__init__(statements, settings, primitives, filename)
-
- self.apertures = apertures
-
- # always explicitly set polarity
- self.statements.insert(0, LPParamStmt('LP', 'dark'))
-
- self.aperture_macros = {}
- self.aperture_defs = []
- self.main_statements = []
-
- self.context = GerberContext.from_settings(self.settings)
-
- for stmt in self.statements:
- self.context.update_from_statement(stmt)
-
- if isinstance(stmt, CoordStmt):
- self.context.normalize_coordinates(stmt)
-
- if isinstance(stmt, AMParamStmt):
- self.aperture_macros[stmt.name] = stmt
-
- elif isinstance(stmt, ADParamStmt):
- self.aperture_defs.append(stmt)
-
- else:
- # ignore FS, MO, AS, IN, IP, IR, MI, OF, SF, LN statements
- if isinstance(stmt, ParamStmt) and not isinstance(stmt, LPParamStmt):
- continue
-
- if isinstance(stmt, (CommentStmt, EofStmt)):
- continue
-
- self.main_statements.append(stmt)
-
- if self.context.angle != 0:
- self.rotate(self.context.angle) # TODO is this correct/useful?
-
- if self.context.is_negative:
- self.negate_polarity() # TODO is this correct/useful?
-
- self.context.notation = 'absolute'
- self.context.zeros = 'trailing'
-
+ def __init__(self, filename=None):
+ super(GerberFile, self).__init__(filename)
+ self.apertures = []
+ self.comments = []
+ self.objects = []
@classmethod
def open(kls, filename, enable_includes=False, enable_include_dir=None):
@@ -120,14 +58,11 @@ class GerberFile(CamFile):
enable_include_dir = Path(filename).parent
return kls.from_string(f.read(), enable_include_dir)
-
@classmethod
def from_string(kls, data, enable_include_dir=None):
- return GerberParser().parse(data, enable_include_dir)
-
- @property
- def comments(self):
- return [stmt.comment for stmt in self.statements if isinstance(stmt, CommentStmt)]
+ obj = kls()
+ GerberParser(obj, include_dir=enable_include_dir).parse(data)
+ return obj
@property
def size(self):
@@ -145,20 +80,40 @@ class GerberFile(CamFile):
return ((min_x, max_x), (min_y, max_y))
- def generate_statements(self):
- self.settings.notation = 'absolute'
- self.settings.zeros = 'trailing'
- self.settings.format = self.format
- self.units = self.units
-
+ def generate_statements(self, drop_comments=True):
yield UnitStmt()
yield FormatSpecStmt()
yield ImagePolarityStmt()
yield SingleQuadrantModeStmt()
- yield from self.aperture_macros.values()
- yield from self.aperture_defs
- yield from self.main_statements
+ if not drop_comments:
+ yield CommentStmt('File processed by Gerbonara. Original comments:')
+ for cmt in self.comments:
+ yield CommentStmt(cmt)
+
+ # Emit gerbonara's generic, rotation-capable aperture macro replacements for the standard C/R/O/P shapes.
+ yield ApertureMacroStmt(GenericMacros.circle)
+ yield ApertureMacroStmt(GenericMacros.rect)
+ yield ApertureMacroStmt(GenericMacros.oblong)
+ yield ApertureMacroStmt(GenericMacros.polygon)
+
+ processed_macros = set()
+ aperture_map = {}
+ for number, aperture in enumerate(self.apertures, start=10):
+
+ if isinstance(aperture, 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)
+ yield ApertureMacroStmt(aperture.macro)
+
+ yield ApertureDefStmt(number, aperture)
+
+ aperture_map[aperture] = number
+
+ gs = GraphicsState(aperture_map=aperture_map)
+ for primitive in self.objects:
+ yield from primitive.to_statements(gs)
yield EofStmt()
@@ -170,130 +125,167 @@ class GerberFile(CamFile):
for stmt in self.generate_statements():
print(stmt.to_gerber(self.settings), file=f)
- def render_primitives(self):
- for stmt in self.main_statements:
- yield from stmt.render_primitives()
-
- def to_inch(self):
- if self.units == 'metric':
- for thing in chain(self.aperture_macros.values(), self.aperture_defs, self.statements, self.primitives):
- thing.to_inch()
- self.units = 'inch'
- self.context.units = 'inch'
-
- def to_metric(self):
- if self.units == 'inch':
- for thing in chain(self.aperture_macros.values(), self.aperture_defs, self.statements, self.primitives):
- thing.to_metric()
- self.units='metric'
- self.context.units='metric'
-
- def offset(self, x_offset=0, y_offset=0):
- for thing in chain(self.main_statements, self.primitives):
- thing.offset(x_offset, y_offset)
-
- def rotate(self, angle, center=(0,0)):
- if angle % 360 == 0:
- return
-
- self._generalize_apertures()
+ def offset(self, dx=0, dy=0):
+ # TODO round offset to file resolution
+ self.objects = [ obj.with_offset(dx, dy) for obj in self.objects ]
- last_x = 0
- last_y = 0
- last_rx = 0
- last_ry = 0
+ def rotate(self, angle:'radians', center=(0,0)):
+ """ Rotate file contents around given point.
- for macro in self.aperture_macros.values():
- macro.rotate(angle, center)
+ Arguments:
+ angle -- Rotation angle in radians counter-clockwise.
+ center -- Center of rotation (default: document origin (0, 0))
- for statement in self.main_statements:
- if isinstance(statement, CoordStmt) and statement.x != None and statement.y != None:
+ Note that when rotating by odd angles other than 0, 90, 180 or 270 degrees this method may replace standard
+ rect and oblong apertures by macro apertures. Existing macro apertures are re-written.
+ """
+ if angle % (2*math.pi) == 0:
+ return
- if statement.i is not None and statement.j is not None:
- cx, cy = last_x + statement.i, last_y + statement.j
- cx, cy = rotate_point((cx, cy), angle, center)
- statement.i, statement.j = cx - last_rx, cy - last_ry
+ # First, rotate apertures. We do this separately from rotating the individual objects below to rotate each
+ # aperture exactly once.
+ for ap in self.apertures:
+ ap.rotation += angle
- last_x, last_y = statement.x, statement.y
- last_rx, last_ry = rotate_point((statement.x, statement.y), angle, center)
- statement.x, statement.y = last_rx, last_ry
+ for obj in self.objects:
+ obj.rotate(rotation, *center)
- def negate_polarity(self):
- for statement in self.main_statements:
- if isinstance(statement, LPParamStmt):
- statement.lp = 'dark' if statement.lp == 'clear' else 'clear'
+ def invert_polarity(self):
+ for obj in self.objects:
+ obj.polarity_dark = not p.polarity_dark
- def _generalize_apertures(self):
- # For rotation, replace standard apertures with macro apertures.
- if not any(isinstance(stm, ADParamStmt) and stm.shape in 'ROP' for stm in self.aperture_defs):
- return
-
- # find an unused macro name with the given prefix
- def free_name(prefix):
- return next(f'{prefix}_{i}' for i in count() if f'{prefix}_{i}' not in self.aperture_macros)
-
- rect = free_name('MACR')
- self.aperture_macros[rect] = AMParamStmt.rectangle(rect, self.units)
-
- obround_landscape = free_name('MACLO')
- self.aperture_macros[obround_landscape] = AMParamStmt.landscape_obround(obround_landscape, self.units)
-
- obround_portrait = free_name('MACPO')
- self.aperture_macros[obround_portrait] = AMParamStmt.portrait_obround(obround_portrait, self.units)
-
- polygon = free_name('MACP')
- self.aperture_macros[polygon] = AMParamStmt.polygon(polygon, self.units)
- for statement in self.aperture_defs:
- if isinstance(statement, ADParamStmt):
- if statement.shape == 'R':
- statement.shape = rect
-
- elif statement.shape == 'O':
- x, y, *_ = *statement.modifiers[0], 0, 0
- statement.shape = obround_landscape if x > y else obround_portrait
-
- elif statement.shape == 'P':
- statement.shape = polygon
-
-
-@dataclass
class GraphicsState:
polarity_dark : bool = True
+ image_polarity : str = 'positive' # IP image polarity; deprecated
point : tuple = None
- aperture : ApertureDefStmt = None
+ aperture : Aperture = None
interpolation_mode : InterpolationModeStmt = None
multi_quadrant_mode : bool = None # used only for syntax checking
+ aperture_mirroring = (False, False) # LM mirroring (x, y)
+ aperture_rotation = 0 # LR rotation in degrees, ccw
+ aperture_scale = 1 # LS scale factor, NOTE: same for both axes
+ # The following are deprecated file-wide settings. We normalize these during parsing.
+ image_offset : (float, float) = (0, 0)
+ image_rotation: int = 0 # IR image rotation in degrees ccw, one of 0, 90, 180 or 270; deprecated
+ image_mirror : tuple = (False, False) # IM image mirroring, (x, y); deprecated
+ image_scale : tuple = (1.0, 1.0) # SF image scaling (x, y); deprecated
+ image_axes : str = 'AXBY' # AS axis mapping; deprecated
+ # for statement generation
+ aperture_map = {}
+
+
+ def __init__(self, aperture_map=None):
+ self._mat = None
+ if aperture_map is not None:
+ self.aperture_map = aperture_map
+
+ def __setattr__(self, name, value):
+ # input validation
+ if name == 'image_axes' and value not in [None, 'AXBY', 'AYBX']:
+ raise ValueError('image_axes must be either "AXBY", "AYBX" or None')
+ elif 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 == 'image_mirror' and len(value) != 2:
+ raise ValueError('mirror_image must be 2-tuple of bools: (mirror_a, mirror_b)')
+ elif name == 'image_offset' and len(value) != 2:
+ raise ValueError('image_offset must be 2-tuple of floats: (offset_a, offset_b)')
+ elif name == 'image_scale' and len(value) != 2:
+ raise ValueError('image_scale must be 2-tuple of floats: (scale_a, scale_b)')
+
+ # polarity handling
+ if name == 'image_polarity': # global IP statement image polarity, can only be set at beginning of file
+ if self.image_polarity == 'negative':
+ self.polarity_dark = False # evaluated before image_polarity is set below through super().__setattr__
+
+ elif name == 'polarity_dark': # local LP statement polarity for subsequent objects
+ if self.image_polarity == 'negative':
+ value = not value
+
+ super().__setattr__(name, value)
+
+ def _update_xform(self):
+ a, b = 1, 0
+ c, d = 0, 1
+ off_x, off_y = self.image_offset
+
+ if self.image_mirror[0]:
+ a = -1
+ if self.image_mirror[1]:
+ d = -1
+
+ a *= self.image_scale[0]
+ d *= self.image_scale[1]
+
+ if ir == 90:
+ a, b, c, d = 0, -d, a, 0
+ off_x, off_y = off_y, -off_x
+ elif ir == 180:
+ a, b, c, d = -a, 0, 0, -d
+ off_x, off_y = -off_x, -off_y
+ elif ir == 270:
+ a, b, c, d = 0, d, -a, 0
+ off_x, off_y = -off_y, off_x
+
+ self.image_offset = off_x, off_y
+ self._mat = a, b, c, d
+
+ def map_coord(self, x, y, relative=False):
+ if self._mat is None:
+ self._update_xform()
+ 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
+ # Apply mirroring, scale and rotation, but do not apply offset
+ return (a*x + b*y), (c*x + d*y)
def flash(self, x, y):
- self.point = (x, y)
- return Aperture(self.aperture, x, y)
+ return gp.Flash(self.aperture, *self.map_coord(x, y), polarity_dark=self.polarity_dark)
- def interpolate(self, x, y, i=None, j=None):
+ def interpolate(self, x, y, i=None, j=None, aperture=True):
if self.interpolation_mode == LinearModeStmt:
if i is not None or j is not None:
raise SyntaxError("i/j coordinates given for linear D01 operation (which doesn't take i/j)")
- return self._create_line(x, y)
+ return self._create_line(x, y, aperture)
else:
- return self._create_arc(x, y, i, j)
+ 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)
- def _create_line(self, x, y):
- old_point, self.point = self.point, (x, y)
- return Line(old_point, self.point, self.aperture, self.polarity_dark)
+ def _create_arc(self, x, y, i, j, aperture=True):
+ 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)
- def _create_arc(self, x, y, i, j):
- if self.multi_quadrant_mode is None:
- warnings.warn('Circular arc interpolation without explicit G75 Single-Quadrant mode statement. '\
- 'This can cause problems with older gerber interpreters.', SyntaxWarning)
+ # Helpers for gerber generation
+ def set_polarity(self, polarity_dark):
+ if self.polarity_dark != polarity_dark:
+ self.polarity_dark = polarity_dark
+ yield LoadPolarityStmt(polarity_dark)
- elif self.multi_quadrant_mode:
- raise SyntaxError('Circular arc interpolation in multi-quadrant mode (G74) is not implemented.')
+ def set_aperture(self, aperture):
+ if self.aperture != aperture:
+ self.aperture = aperture
+ yield ApertureStmt(self.aperture_map[aperture])
- old_point, self.point = self.point, (x, y)
- direction = 'ccw' if self.interpolation_mode == CircularCCWModeStmt else 'cw'
- return Arc(old_point, self.point, (i, j), direction, self.aperture, self.polarity_dark):
+ def set_current_point(self, point):
+ if self.point != point:
+ self.point = point
+ yield MoveStmt(*point)
+
+ def set_interpolation_mode(self, mode):
+ if self.interpolation_mode != mode:
+ gs.interpolation_mode = mode
+ yield mode()
class GerberParser:
@@ -304,7 +296,7 @@ class GerberParser:
STATEMENT_REGEXES = {
'unit_mode': r"MO(?P<unit>(MM|IN))",
'interpolation_mode': r"(?P<code>G0?[123]|G74|G75)?",
- 'coord': = fr"(X(?P<x>{NUMBER}))?(Y(?P<y>{NUMBER}))?" \
+ '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+)\*",
@@ -334,43 +326,19 @@ class GerberParser:
STATEMENT_REGEXES = { key: re.compile(value) for key, value in STATEMENT_REGEXES.items() }
- def __init__(self, include_dir=None):
+ def __init__(self, target, include_dir=None):
""" Pass an include dir to enable IF include statements (potentially DANGEROUS!). """
+ self.target = target
self.include_dir = include_dir
self.include_stack = []
- self.settings = FileSettings()
- self.current_region = None
+ self.file_settings = FileSettings()
self.graphics_state = GraphicsState()
-
- self.statements = []
- self.primitives = []
- self.apertures = {}
+ self.aperture_map = {}
+ self.current_region = None
+ self.eof_found = False
+ self.multi_quadrant_mode = None # used only for syntax checking
self.macros = {}
- self.x = 0
- self.y = 0
self.last_operation = None
- self.op = "D02"
- self.aperture = 0
- self.interpolation = 'linear'
- self.direction = 'clockwise'
- self.image_polarity = 'positive'
- self.level_polarity = 'dark'
- self.region_mode = 'off'
- self.step_and_repeat = (1, 1, 0, 0)
-
- def parse(self, data):
- for stmt in self._parse(data):
- if self.current_region is None:
- self.statements.append(stmt)
- else:
- self.current_region.append(stmt)
- self.evaluate(stmt)
-
- # Initialize statement units
- for stmt in self.statements:
- stmt.units = self.settings.units
-
- return GerberFile(self.statements, self.settings, self.primitives, self.apertures.values())
@classmethod
def _split_commands(kls, data):
@@ -402,49 +370,48 @@ class GerberParser:
yield word_command
start = cur + 1
- def dump_json(self):
- return json.dumps({"statements": [stmt.__dict__ for stmt in self.statements]})
-
- def dump_str(self):
- return '\n'.join(str(stmt) for stmt in self.statements) + '\n'
-
- def _parse(self, data):
+ def parse(self, data):
for line in self._split_commands(data):
# 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))
- yield from getattr(self, f'_parse_{name}')(self, match.groupdict())
+ if (match := le_regex.match(line)):
+ getattr(self, f'_parse_{name}')(self, match.groupdict())
line = line[match.end(0):]
break
else:
if line[-1] == '*':
- yield UnknownStmt(line)
+ warnings.warn(f'Unknown statement found: "{line}", ignoring.', SyntaxWarning)
+ self.target.comments.append(f'Unknown statement found: "{line}", ignoring.')
line = ''
+
+ self.target.apertures = list(self.aperture_map.values())
+
+ if not self.eof_found:
+ warnings.warn('File is missing mandatory M02 EOF marker. File may be truncated.', SyntaxWarning)
def _parse_interpolation_mode(self, match):
if match['code'] == 'G01':
self.graphics_state.interpolation_mode = LinearModeStmt
- yield LinearModeStmt()
elif match['code'] == 'G02':
self.graphics_state.interpolation_mode = CircularCWModeStmt
- yield CircularCWModeStmt()
elif match['code'] == 'G03':
self.graphics_state.interpolation_mode = CircularCCWModeStmt
- yield CircularCCWModeStmt()
elif match['code'] == 'G74':
- self.graphics_state.multi_quadrant_mode = True # used only for syntax checking
+ self.multi_quadrant_mode = True # used only for syntax checking
elif match['code'] == 'G75':
- self.graphics_state.multi_quadrant_mode = False
+ self.multi_quadrant_mode = False
# we always emit a G75 at the beginning of the file.
def _parse_coord(self, match):
- x = parse_gerber_value(match['x'], self.settings)
- y = parse_gerber_value(match['y'], self.settings)
- i = parse_gerber_value(match['i'], self.settings)
- j = parse_gerber_value(match['j'], self.settings)
+ x = self.file_settings.parse_gerber_value(match['x'])
+ y = self.file_settings.parse_gerber_value(match['y'])
+ i = self.file_settings.parse_gerber_value(match['i'])
+ j = self.file_settings.parse_gerber_value(match['j'])
if not (op := match['operation']):
if self.last_operation == 'D01':
@@ -455,8 +422,21 @@ class GerberParser:
raise SyntaxError('Ambiguous coordinate statement. Coordinate statement does not have an operation '\
'mode and the last operation statement was not D01.')
+ self.last_operation = op
+
if op in ('D1', 'D01'):
- yield self.graphics_state.interpolate(x, y, i, j)
+ if self.graphics_state.interpolation_mode != LinearModeStmt:
+ if self.multi_quadrant_mode is None:
+ warnings.warn('Circular arc interpolation without explicit G75 Single-Quadrant mode statement. '\
+ 'This can cause problems with older gerber interpreters.', SyntaxWarning)
+
+ elif self.multi_quadrant_mode:
+ raise SyntaxError('Circular arc interpolation in multi-quadrant mode (G74) is not implemented.')
+
+ if self.current_region is None:
+ self.target.objects.append(self.graphics_state.interpolate(x, y, i, j))
+ else:
+ self.current_region.append(self.graphics_state.interpolate(x, y, i, j))
else:
if i is not None or j is not None:
@@ -464,380 +444,170 @@ class GerberParser:
if op in ('D2', 'D02'):
self.graphics_state.point = (x, y)
+ if self.current_region:
+ # Start a new region for every outline. As gerber has no concept of fill rules or winding numbers,
+ # it does not make a graphical difference, and it makes the implementation slightly easier.
+ self.target.objects.append(self.current_region)
+ self.current_region = gp.Region(polarity_dark=gp.polarity_dark)
else: # D03
- yield self.graphics_state.flash(x, y)
-
+ if self.current_region is None:
+ self.target.objects.append(self.graphics_state.flash(x, y))
+ else:
+ raise SyntaxError('DO3 flash statement inside region')
def _parse_aperture(self, match):
number = int(match['number'])
if number < 10:
raise SyntaxError(f'Invalid aperture number {number}: Aperture number must be >= 10.')
- if number not in self.apertures:
+ if number not in self.aperture_map:
raise SyntaxError(f'Tried to access undefined aperture {number}')
- self.graphics_state.aperture = self.apertures[number]
+ self.graphics_state.aperture = self.aperture_map[number]
+
+ def _parse_aperture_definition(self, match):
+ # number, shape, modifiers
+ modifiers = [ float(val) for val in match['modifiers'].split(',') ]
+
+ aperture_classes = {
+ 'C': ApertureCircle,
+ 'R': ApertureRectangle,
+ 'O': ApertureObround,
+ 'P': AperturePolygon,
+ }
+
+ 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)
+
+ else:
+ raise ValueError(f'Aperture shape "{match["shape"]}" is unknown')
+
+ self.aperture_map[int(match['number'])] = new_aperture
+
+ def _parse_aperture_macro(self, match):
+ self.target.aperture_macros[match['name']] = ApertureMacro.parse(match['macro'])
def _parse_format_spec(self, match):
# This is a common problem in Eagle files, so just suppress it
- self.settings.zero_suppression = {'L': 'leading', 'T': 'trailing'}.get(match['zero'], 'leading')
- self.settings.notation = 'absolute' if match.['notation'] == 'A' else 'incremental'
+ self.file_settings.zero_suppression = {'L': 'leading', 'T': 'trailing'}.get(match['zero'], 'leading')
+ self.file_settings.notation = 'absolute' if match['notation'] == 'A' else 'incremental'
if match['x'] != match['y']:
raise SyntaxError(f'FS specifies different coordinate formats for X and Y ({match["x"]} != {match["y"]})')
- self.settings.number_format = int(match['x'][0]), int(match['x'][1])
-
- yield from () # We always force a format spec statement at the beginning of the file
+ self.file_settings.number_format = int(match['x'][0]), int(match['x'][1])
def _parse_unit_mode(self, match):
if match['unit'] == 'MM':
- self.settings.units = 'mm'
+ self.file_settings.units = 'mm'
else:
- self.settings.units = 'inch'
-
- yield from () # We always force a unit mode statement at the beginning of the file
+ self.file_settings.units = 'inch'
def _parse_load_polarity(self, match):
- yield LoadPolarityStmt(dark=match['polarity'] == 'D')
+ self.graphics_state.polarity_dark = match['polarity'] == 'D'
def _parse_offset(self, match):
a, b = match['a'], match['b']
a = float(a) if a else 0
b = float(b) if b else 0
- self.settings.offset = a, b
- yield from () # Handled by coordinate normalization
+ self.graphics_state.offset = a, b
def _parse_include_file(self, match):
if self.include_dir is None:
- warnings.warn('IF Include File statement found, but includes are deactivated.', ResourceWarning)
+ warnings.warn('IF include statement found, but includes are deactivated.', ResourceWarning)
else:
- warnings.warn('IF Include File statement found. Includes are activated, but is this really a good idea?', ResourceWarning)
+ warnings.warn('IF include statement found. Includes are activated, but is this really a good idea?', ResourceWarning)
include_file = self.include_dir / param["filename"]
- if include_file in self.include_stack
- raise ValueError("Recusive file inclusion via IF include statement.")
+ # Do not check if path exists to avoid leaking existence via error message
+ include_file = include_file.resolve(strict=False)
+
+ if not include_file.is_relative_to(self.include_dir):
+ raise FileNotFoundError('Attempted traversal to parent of include dir in path from IF include statement')
+
+ if not include_file.is_file():
+ raise FileNotFoundError('File pointed to by IF include statement does not exist')
+
+ if include_file in self.include_stack:
+ raise ValueError("Recusive inclusion via IF include statement.")
self.include_stack.append(include_file)
# Spec 2020-09 section 3.1: Gerber files must use UTF-8
- yield from self._parse(f.read_text(encoding='UTF-8'))
+ self._parse(f.read_text(encoding='UTF-8'))
self.include_stack.pop()
-
def _parse_image_name(self, match):
warnings.warn('Deprecated IN (image name) statement found. This deprecated since rev. I4 (Oct 2013).',
DeprecationWarning)
- yield CommentStmt(f'Image name: {match["name"]}')
+ self.target.comments.append(f'Image name: {match["name"]}')
def _parse_load_name(self, match):
warnings.warn('Deprecated LN (load name) statement found. This deprecated since rev. I4 (Oct 2013).',
DeprecationWarning)
- yield CommentStmt(f'Name of subsequent part: {match["name"]}')
def _parse_axis_selection(self, match):
warnings.warn('Deprecated AS (axis selection) statement found. This deprecated since rev. I1 (Dec 2012).',
DeprecationWarning)
- self.settings.output_axes = match['axes']
- yield from () # Handled by coordinate normalization
+ self.graphics_state.output_axes = match['axes']
def _parse_image_polarity(self, match):
warnings.warn('Deprecated IP (image polarity) statement found. This deprecated since rev. I4 (Oct 2013).',
DeprecationWarning)
- self.settings.image_polarity = match['polarity']
- yield from () # We always emit this in the header
+ self.graphics_state.image_polarity = match['polarity']
def _parse_image_rotation(self, match):
warnings.warn('Deprecated IR (image rotation) statement found. This deprecated since rev. I1 (Dec 2012).',
DeprecationWarning)
- self.settings.image_rotation = int(match['rotation'])
- yield from () # Handled by coordinate normalization
+ self.graphics_state.image_rotation = int(match['rotation'])
def _parse_mirror_image(self, match):
warnings.warn('Deprecated MI (mirror image) statement found. This deprecated since rev. I1 (Dec 2012).',
DeprecationWarning)
- self.settings.mirror = bool(int(match['a'] or '0')), bool(int(match['b'] or '1'))
- yield from () # Handled by coordinate normalization
+ self.graphics_state.mirror = bool(int(match['a'] or '0')), bool(int(match['b'] or '1'))
def _parse_scale_factor(self, match):
warnings.warn('Deprecated SF (scale factor) statement found. This deprecated since rev. I1 (Dec 2012).',
DeprecationWarning)
a = float(match['a']) if match['a'] else 1.0
b = float(match['b']) if match['b'] else 1.0
- self.settings.scale_factor = a, b
- yield from () # Handled by coordinate normalization
+ self.graphics_state.scale_factor = a, b
def _parse_comment(self, match):
- yield CommentStmt(match["comment"])
+ self.target.comments.append(match["comment"])
def _parse_region_start(self, _match):
- current_region = RegionGroup()
+ self.current_region = gp.Region(polarity_dark=gp.polarity_dark)
def _parse_region_end(self, _match):
if self.current_region is None:
raise SyntaxError('Region end command (G37) outside of region')
- yield self.current_region
+ if self.current_region: # ignore empty regions
+ self.target.objects.append(self.current_region)
self.current_region = None
def _parse_old_unit(self, match):
- self.settings.units = 'inch' if match['mode'] == 'G70' else 'mm'
+ self.file_settings.units = 'inch' if match['mode'] == 'G70' else 'mm'
warnings.warn(f'Deprecated {match["mode"]} unit mode statement found. This deprecated since 2012.',
DeprecationWarning)
- yield CommentStmt(f'Replaced deprecated {match["mode"]} unit mode statement with MO statement')
- yield UnitStmt()
+ self.target.comments.append('Replaced deprecated {match["mode"]} unit mode statement with MO statement')
def _parse_old_unit(self, match):
# FIXME make sure we always have FS at end of processing.
self.settings.notation = 'absolute' if match['mode'] == 'G90' else 'incremental'
warnings.warn(f'Deprecated {match["mode"]} notation mode statement found. This deprecated since 2012.',
DeprecationWarning)
- yield CommentStmt(f'Replaced deprecated {match["mode"]} notation mode statement with FS statement')
+ self.target.comments.append('Replaced deprecated {match["mode"]} notation mode statement with FS statement')
def _parse_eof(self, _match):
- yield EofStmt()
+ self.eof_found = True
def _parse_ignored(self, match):
- yield CommentStmt(f'Ignoring {match{"stmt"]} statement.')
-
- def _parse_aperture_definition(self, match):
- modifiers = [ float(mod) for mod in match['modifiers'].split(',') ]
- if match['shape'] == 'C':
- aperture = ApertureCircle(*modifiers)
-
- elif match['shape'] == 'R'
- aperture = ApertureRectangle(*modifiers)
-
- elif shape == 'O':
- aperture = ApertureObround(*modifiers)
-
- elif shape == 'P':
- aperture = AperturePolygon(*modifiers)
-
- else:
- aperture = self.macros[shape].build(modifiers)
-
- self.apertures[d] = aperture
-
-
-
- def evaluate(self, stmt):
- """ Evaluate Gerber statement and update image accordingly.
-
- This method is called once for each statement in the file as it
- is parsed.
+ pass
- Parameters
- ----------
- statement : Statement
- Gerber/Excellon statement to evaluate.
-
- """
- if isinstance(stmt, CoordStmt):
- self._evaluate_coord(stmt)
-
- elif isinstance(stmt, ParamStmt):
- self._evaluate_param(stmt)
-
- elif isinstance(stmt, ApertureStmt):
- self._evaluate_aperture(stmt)
-
- elif isinstance(stmt, (RegionModeStmt, QuadrantModeStmt)):
- self._evaluate_mode(stmt)
-
- elif isinstance(stmt, (CommentStmt, UnknownStmt, DeprecatedStmt, EofStmt)):
- return
-
- else:
- raise Exception("Invalid statement to evaluate")
-
- def _evaluate_mode(self, stmt):
- if stmt.type == 'RegionMode':
- if self.region_mode == 'on' and stmt.mode == 'off':
- # Sometimes we have regions that have no points. Skip those
- if self.current_region:
- self.primitives.append(Region(self.current_region,
- level_polarity=self.level_polarity, units=self.settings.units))
-
- self.current_region = None
- self.region_mode = stmt.mode
- elif stmt.type == 'QuadrantMode':
- self.quadrant_mode = stmt.mode
-
- def _evaluate_param(self, stmt):
- elif stmt.param == "LP":
- self.level_polarity = stmt.lp
- elif stmt.param == "AM":
- self.macros[stmt.name] = stmt
- elif stmt.param == "AD":
- self._define_aperture(stmt.d, stmt.shape, stmt.modifiers)
-
- def _evaluate_coord(self, stmt):
- x = self.x if stmt.x is None else stmt.x
- y = self.y if stmt.y is None else stmt.y
-
- if stmt.function in ("G01", "G1"):
- self.interpolation = 'linear'
- elif stmt.function in ('G02', 'G2', 'G03', 'G3'):
- self.interpolation = 'arc'
- self.direction = ('clockwise' if stmt.function in
- ('G02', 'G2') else 'counterclockwise')
-
- if stmt.only_function:
- # Sometimes we get a coordinate statement
- # that only sets the function. If so, don't
- # try futher otherwise that might draw/flash something
- return
-
- if stmt.op:
- self.op = stmt.op
- else:
- # no implicit op allowed, force here if coord block doesn't have it
- stmt.op = self.op
-
- if self.op == "D01" or self.op == "D1":
- start = (self.x, self.y)
- end = (x, y)
-
- if self.interpolation == 'linear':
- if self.region_mode == 'off':
- self.primitives.append(Line(start, end,
- self.apertures[self.aperture],
- level_polarity=self.level_polarity,
- units=self.settings.units))
- else:
- # from gerber spec revision J3, Section 4.5, page 55:
- # The segments are not graphics objects in themselves; segments are part of region which is the graphics object. The segments have no thickness.
- # The current aperture is associated with the region.
- # This has no graphical effect, but allows all its attributes to
- # be applied to the region.
-
- if self.current_region is None:
- self.current_region = [Line(start, end,
- self.apertures.get(self.aperture,
- Circle((0, 0), 0)),
- level_polarity=self.level_polarity,
- units=self.settings.units), ]
- else:
- self.current_region.append(Line(start, end,
- self.apertures.get(self.aperture,
- Circle((0, 0), 0)),
- level_polarity=self.level_polarity,
- units=self.settings.units))
- else:
- i = 0 if stmt.i is None else stmt.i
- j = 0 if stmt.j is None else stmt.j
- center = self._find_center(start, end, (i, j))
- if self.region_mode == 'off':
- self.primitives.append(Arc(start, end, center, self.direction,
- self.apertures[self.aperture],
- quadrant_mode=self.quadrant_mode,
- level_polarity=self.level_polarity,
- units=self.settings.units))
- else:
- if self.current_region is None:
- self.current_region = [Arc(start, end, center, self.direction,
- self.apertures.get(self.aperture, Circle((0,0), 0)),
- quadrant_mode=self.quadrant_mode,
- level_polarity=self.level_polarity,
- units=self.settings.units),]
- else:
- self.current_region.append(Arc(start, end, center, self.direction,
- self.apertures.get(self.aperture, Circle((0,0), 0)),
- quadrant_mode=self.quadrant_mode,
- level_polarity=self.level_polarity,
- units=self.settings.units))
- # Gerbv seems to reset interpolation mode in regions..
- # TODO: Make sure this is right.
- self.interpolation = 'linear'
-
- elif self.op == "D02" or self.op == "D2":
-
- if self.region_mode == "on":
- # D02 in the middle of a region finishes that region and starts a new one
- if self.current_region and len(self.current_region) > 1:
- self.primitives.append(Region(self.current_region,
- level_polarity=self.level_polarity,
- units=self.settings.units))
- self.current_region = None
-
- elif self.op == "D03" or self.op == "D3":
- primitive = copy.deepcopy(self.apertures[self.aperture])
-
- if primitive is not None:
-
- if not isinstance(primitive, AMParamStmt):
- primitive.position = (x, y)
- primitive.level_polarity = self.level_polarity
- primitive.units = self.settings.units
- self.primitives.append(primitive)
- else:
- # Aperture Macro
- for am_prim in primitive.primitives:
- renderable = am_prim.to_primitive((x, y),
- self.level_polarity,
- self.settings.units)
- if renderable is not None:
- self.primitives.append(renderable)
- self.x, self.y = x, y
-
- def _find_center(self, start, end, offsets):
- """
- In single quadrant mode, the offsets are always positive, which means
- there are 4 possible centers. The correct center is the only one that
- results in an arc with sweep angle of less than or equal to 90 degrees
- in the specified direction
- """
- two_pi = 2 * math.pi
- if self.quadrant_mode == 'single-quadrant':
- # The Gerber spec says single quadrant only has one possible center,
- # and you can detect it based on the angle. But for real files, this
- # seems to work better - there is usually only one option that makes
- # sense for the center (since the distance should be the same
- # from start and end). We select the center with the least error in
- # radius from all the options with a valid sweep angle.
-
- sqdist_diff_min = sys.maxsize
- center = None
- for factors in [(1, 1), (1, -1), (-1, 1), (-1, -1)]:
-
- test_center = (start[0] + offsets[0] * factors[0],
- start[1] + offsets[1] * factors[1])
-
- # Find angle from center to start and end points
- start_angle = math.atan2(*reversed([_start - _center for _start, _center in zip(start, test_center)]))
- end_angle = math.atan2(*reversed([_end - _center for _end, _center in zip(end, test_center)]))
-
- # Clamp angles to 0, 2pi
- theta0 = (start_angle + two_pi) % two_pi
- theta1 = (end_angle + two_pi) % two_pi
-
- # Determine sweep angle in the current arc direction
- if self.direction == 'counterclockwise':
- sweep_angle = abs(theta1 - theta0)
- else:
- theta0 += two_pi
- sweep_angle = abs(theta0 - theta1) % two_pi
-
- # Calculate the radius error
- sqdist_start = sq_distance(start, test_center)
- sqdist_end = sq_distance(end, test_center)
- sqdist_diff = abs(sqdist_start - sqdist_end)
-
- # Take the option with the lowest radius error from the set of
- # options with a valid sweep angle
- # In some rare cases, the sweep angle is numerically (10**-14) above pi/2
- # So it is safer to compare the angles with some tolerance
- is_lowest_radius_error = sqdist_diff < sqdist_diff_min
- is_valid_sweep_angle = sweep_angle >= 0 and sweep_angle <= math.pi / 2.0 + 1e-6
- if is_lowest_radius_error and is_valid_sweep_angle:
- center = test_center
- sqdist_diff_min = sqdist_diff
- return center
- else:
- return (start[0] + offsets[0], start[1] + offsets[1])
-
- def _evaluate_aperture(self, stmt):
- self.aperture = stmt.d
def _match_one(expr, data):
match = expr.match(data)
@@ -855,132 +625,3 @@ def _match_one_from_many(exprs, data):
return ({}, None)
-class GerberContext(FileSettings):
- TYPE_NONE = 'none'
- TYPE_AM = 'am'
- TYPE_AD = 'ad'
- TYPE_MAIN = 'main'
- IP_LINEAR = 'linear'
- IP_ARC = 'arc'
- DIR_CLOCKWISE = 'cw'
- DIR_COUNTERCLOCKWISE = 'ccw'
-
- @classmethod
- def from_settings(cls, settings):
- return cls(settings.notation, settings.units, settings.zero_suppression,
- settings.format, settings.zeros, settings.angle_units)
-
- def __init__(self, notation='absolute', units='inch',
- zero_suppression=None, format=(2, 5), zeros=None,
- angle_units='degrees',
- mirror=(False, False), offset=(0., 0.), scale=(1., 1.),
- angle=0., axis='xy'):
- super(GerberContext, self).__init__(notation, units, zero_suppression,
- format, zeros, angle_units)
- self.mirror = mirror
- self.offset = offset
- self.scale = scale
- self.angle = angle
- self.axis = axis
-
- self.is_negative = False
- self.no_polarity = True
- self.in_single_quadrant_mode = False
- self.op = None
- self.interpolation = self.IP_LINEAR
- self.direction = self.DIR_CLOCKWISE
- self.x, self.y = 0, 0
-
- def update_from_statement(self, stmt):
- if isinstance(stmt, MIParamStmt):
- self.mirror = (stmt.a, stmt.b)
-
- elif isinstance(stmt, OFParamStmt):
- self.offset = (stmt.a, stmt.b)
-
- elif isinstance(stmt, SFParamStmt):
- self.scale = (stmt.a, stmt.b)
-
- elif isinstance(stmt, ASParamStmt):
- self.axis = 'yx' if stmt.mode == 'AYBX' else 'xy'
-
- elif isinstance(stmt, IRParamStmt):
- self.angle = stmt.angle
-
- elif isinstance(stmt, QuadrantModeStmt):
- self.in_single_quadrant_mode = stmt.mode == 'single-quadrant'
- stmt.mode = 'multi-quadrant'
-
- elif isinstance(stmt, IPParamStmt):
- self.is_negative = stmt.ip == 'negative'
-
- elif isinstance(stmt, LPParamStmt):
- self.no_polarity = False
-
- @property
- def matrix(self):
- if self.axis == 'xy':
- mx = -1 if self.mirror[0] else 1
- my = -1 if self.mirror[1] else 1
- return (
- self.scale[0] * mx, self.offset[0],
- self.scale[1] * my, self.offset[1],
- self.scale[0] * mx, self.scale[1] * my)
- else:
- mx = -1 if self.mirror[1] else 1
- my = -1 if self.mirror[0] else 1
- return (
- self.scale[1] * mx, self.offset[1],
- self.scale[0] * my, self.offset[0],
- self.scale[1] * mx, self.scale[0] * my)
-
- def normalize_coordinates(self, stmt):
- if stmt.function == 'G01' or stmt.function == 'G1':
- self.interpolation = self.IP_LINEAR
-
- elif stmt.function == 'G02' or stmt.function == 'G2':
- self.interpolation = self.IP_ARC
- self.direction = self.DIR_CLOCKWISE
- if self.mirror[0] != self.mirror[1]:
- stmt.function = 'G03'
-
- elif stmt.function == 'G03' or stmt.function == 'G3':
- self.interpolation = self.IP_ARC
- self.direction = self.DIR_COUNTERCLOCKWISE
- if self.mirror[0] != self.mirror[1]:
- stmt.function = 'G02'
-
- if stmt.only_function:
- return
-
- last_x, last_y = self.x, self.y
- if self.notation == 'absolute':
- x = stmt.x if stmt.x is not None else self.x
- y = stmt.y if stmt.y is not None else self.y
-
- else:
- x = self.x + stmt.x if stmt.x is not None else 0
- y = self.y + stmt.y if stmt.y is not None else 0
-
- self.x, self.y = x, y
- self.op = stmt.op if stmt.op is not None else self.op
-
- stmt.op = self.op
- stmt.x = self.matrix[0] * x + self.matrix[1]
- stmt.y = self.matrix[2] * y + self.matrix[3]
-
- if stmt.op == 'D01' and self.interpolation == self.IP_ARC:
- qx, qy = 1, 1
- if self.in_single_quadrant_mode:
- if self.direction == self.DIR_CLOCKWISE:
- qx = 1 if y > last_y else -1
- qy = 1 if x < last_x else -1
- else:
- qx = 1 if y < last_y else -1
- qy = 1 if x > last_x else -1
- if last_x == x and last_y == y:
- qx, qy = 0, 0
-
- stmt.i = qx * self.matrix[4] * stmt.i if stmt.i is not None else 0
- stmt.j = qy * self.matrix[5] * stmt.j if stmt.j is not None else 0
-
diff --git a/gerbonara/gerber/utils.py b/gerbonara/gerber/utils.py
index 492321a..122dd5a 100644
--- a/gerbonara/gerber/utils.py
+++ b/gerbonara/gerber/utils.py
@@ -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: