summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorjaseg <git@jaseg.de>2021-12-29 19:58:20 +0100
committerjaseg <git@jaseg.de>2021-12-29 19:58:20 +0100
commit3fb26e6940b5ae752308d8a33f2608d266795153 (patch)
treea563b0cf512e5661b2a450ebf73eafe655ac18b2
parent30dabef9ee83021067957854187b9bbf245c14cf (diff)
downloadgerbonara-3fb26e6940b5ae752308d8a33f2608d266795153.tar.gz
gerbonara-3fb26e6940b5ae752308d8a33f2608d266795153.tar.bz2
gerbonara-3fb26e6940b5ae752308d8a33f2608d266795153.zip
Basic round-trip works
-rw-r--r--gerbonara/gerber/__init__.py1
-rw-r--r--gerbonara/gerber/aperture_macros/expression.py15
-rw-r--r--gerbonara/gerber/aperture_macros/parse.py51
-rw-r--r--gerbonara/gerber/aperture_macros/primitive.py11
-rw-r--r--gerbonara/gerber/apertures.py64
-rw-r--r--gerbonara/gerber/cam.py77
-rwxr-xr-xgerbonara/gerber/excellon.py2
-rw-r--r--gerbonara/gerber/excellon_statements.py88
-rw-r--r--gerbonara/gerber/gerber_statements.py27
-rw-r--r--gerbonara/gerber/graphic_objects.py53
-rw-r--r--gerbonara/gerber/graphic_primitives.py8
-rw-r--r--gerbonara/gerber/ipc356.py7
-rw-r--r--gerbonara/gerber/layers.py103
-rw-r--r--gerbonara/gerber/pcb.py125
-rw-r--r--gerbonara/gerber/primitives.py932
-rw-r--r--gerbonara/gerber/rs274x.py134
-rw-r--r--gerbonara/gerber/tests/conftest.py22
-rw-r--r--gerbonara/gerber/tests/image_support.py63
-rw-r--r--gerbonara/gerber/tests/panelize/test_rs274x.py70
-rw-r--r--gerbonara/gerber/tests/resources/example_outline_with_arcs.gbr33
-rw-r--r--gerbonara/gerber/tests/test_rs274x.py131
21 files changed, 598 insertions, 1419 deletions
diff --git a/gerbonara/gerber/__init__.py b/gerbonara/gerber/__init__.py
index 5cf9dc1..9c8453c 100644
--- a/gerbonara/gerber/__init__.py
+++ b/gerbonara/gerber/__init__.py
@@ -23,4 +23,3 @@ files in python.
"""
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 ddd8d53..73a2a36 100644
--- a/gerbonara/gerber/aperture_macros/expression.py
+++ b/gerbonara/gerber/aperture_macros/expression.py
@@ -8,15 +8,14 @@ import re
import ast
+MILLIMETERS_PER_INCH = 25.4
+
+
def expr(obj):
return obj if isinstance(obj, Expression) else ConstantExpression(obj)
-class Expression(object):
- @property
- def value(self):
- return self
-
+class Expression:
def optimized(self, variable_binding={}):
return self
@@ -79,17 +78,17 @@ class UnitExpression(Expression):
return f'<{self._expr.to_gerber()} {self.unit}>'
def converted(self, unit):
- if unit is None or self.unit == unit:
+ if self.unit is None or unit is None or self.unit == unit:
return self._expr
elif unit == 'mm':
return self._expr * MILLIMETERS_PER_INCH
elif unit == 'inch':
- return self._expr / MILLIMETERS_PER_INCH)
+ return self._expr / MILLIMETERS_PER_INCH
else:
- raise ValueError('invalid unit, must be "inch" or "mm".')
+ raise ValueError(f'invalid unit {unit}, must be "inch" or "mm".')
class ConstantExpression(Expression):
diff --git a/gerbonara/gerber/aperture_macros/parse.py b/gerbonara/gerber/aperture_macros/parse.py
index 2f578ee..35cb6c2 100644
--- a/gerbonara/gerber/aperture_macros/parse.py
+++ b/gerbonara/gerber/aperture_macros/parse.py
@@ -9,10 +9,8 @@ import ast
import copy
import math
-import primitive as ap
-from expression import *
-
-from .. import apertures
+from . import primitive as ap
+from .expression import *
def rad_to_deg(x):
return (x / math.pi) * 180
@@ -54,10 +52,10 @@ class ApertureMacro:
self.primitives = primitives or []
@classmethod
- def parse_macro(cls, name, macro, unit):
+ def parse_macro(cls, name, body, unit):
macro = cls(name)
- blocks = re.sub(r'\s', '', macro).split('*')
+ blocks = re.sub(r'\s', '', body).split('*')
for block in blocks:
if not (block := block.strip()): # empty block
continue
@@ -74,14 +72,14 @@ class ApertureMacro:
else: # primitive
primitive, *args = block.split(',')
- args = [_parse_expression(arg) for arg in args]
- primitive = ap.PRIMITIVE_CLASSES[int(primitive)](unit=unit, args=args
+ args = [ _parse_expression(arg) for arg in args ]
+ primitive = ap.PRIMITIVE_CLASSES[int(primitive)](unit=unit, args=args)
macro.primitives.append(primitive)
@property
def name(self):
- if self.name is not None:
- return self.name
+ if self._name is not None:
+ return self._name
else:
return f'gn_{hash(self)}'
@@ -120,31 +118,34 @@ class ApertureMacro:
return copy
+cons, var = ConstantExpression, VariableExpression
+deg_per_rad = 180 / math.pi
+
class GenericMacros:
- deg_per_rad = 180 / math.pi
- cons, var = VariableExpression
+
_generic_hole = lambda n: [
- ap.Circle(exposure=0, diameter=var(n), x=0, y=0),
- ap.Rectangle(exposure=0, w=var(n), h=var(n+1), x=0, y=0, rotation=var(n+2) * deg_per_rad)]
+ ap.Circle(None, [0, var(n), 0, 0]),
+ ap.CenterLine(None, [0, var(n), var(n+1), 0, 0, var(n+2) * deg_per_rad])]
- circle = ApertureMacro([
- ap.Circle(exposure=1, diameter=var(1), x=0, y=0, rotation=var(4) * deg_per_rad),
+ # Initialize all these with "None" units so they inherit file units, and do not convert their arguments.
+ circle = ApertureMacro('GNC', [
+ ap.Circle(None, [1, var(1), 0, 0, var(4) * deg_per_rad]),
*_generic_hole(2)])
- rect = ApertureMacro([
- ap.Rectangle(exposure=1, w=var(1), h=var(2), x=0, y=0, rotation=var(5) * deg_per_rad),
+ rect = ApertureMacro('GNR', [
+ ap.CenterLine(None, [1, var(1), var(2), 0, 0, var(5) * deg_per_rad]),
*_generic_hole(3) ])
# w must be larger than h
- obround = ApertureMacro([
- ap.Rectangle(exposure=1, w=var(1), h=var(2), x=0, y=0, rotation=var(5) * deg_per_rad),
- ap.Circle(exposure=1, diameter=var(2), x=+var(1)/2, y=0, rotation=var(5) * deg_per_rad),
- ap.Circle(exposure=1, diameter=var(2), x=-var(1)/2, y=0, rotation=var(5) * deg_per_rad),
+ obround = ApertureMacro('GNO', [
+ ap.CenterLine(None, [1, var(1), var(2), 0, 0, var(5) * deg_per_rad]),
+ ap.Circle(None, [1, var(2), +var(1)/2, 0, var(5) * deg_per_rad]),
+ ap.Circle(None, [1, var(2), -var(1)/2, 0, var(5) * deg_per_rad]),
*_generic_hole(3) ])
- polygon = ApertureMacro([
- ap.Polygon(exposure=1, n_vertices=var(2), x=0, y=0, diameter=var(1), rotation=var(3) * deg_per_rad),
- pa.Circle(exposure=0, diameter=var(4), x=0, y=0)])
+ polygon = ApertureMacro('GNP', [
+ ap.Polygon(None, [1, var(2), 0, 0, var(1), var(3) * deg_per_rad]),
+ ap.Circle(None, [0, var(4), 0, 0])])
if __name__ == '__main__':
diff --git a/gerbonara/gerber/aperture_macros/primitive.py b/gerbonara/gerber/aperture_macros/primitive.py
index aeb38c4..4d3e597 100644
--- a/gerbonara/gerber/aperture_macros/primitive.py
+++ b/gerbonara/gerber/aperture_macros/primitive.py
@@ -7,9 +7,9 @@
import contextlib
import math
-from expression import Expression, UnitExpression, ConstantExpression, expr
+from .expression import Expression, UnitExpression, ConstantExpression, expr
-from .. import graphic_primitivese as gp
+from .. import graphic_primitives as gp
def point_distance(a, b):
@@ -41,7 +41,7 @@ class Primitive:
raise ValueError(f'Too few arguments ({len(args)}) for aperture macro primitive {self.code} ({type(self)})')
def to_gerber(self, unit=None):
- return self.code + ',' + ','.join(
+ return f'{self.code},' + ','.join(
getattr(self, name).to_gerber(unit) for name in type(self).__annotations__) + '*'
def __str__(self):
@@ -149,6 +149,7 @@ class Polygon(Primitive):
class Thermal(Primitive):
code = 7
+ exposure : Expression
# center x/y
x : UnitExpression
y : UnitExpression
@@ -216,6 +217,8 @@ class Outline(Primitive):
class Comment:
+ code = 0
+
def __init__(self, comment):
self.comment = comment
@@ -233,6 +236,6 @@ PRIMITIVE_CLASSES = {
Thermal,
]},
# alternative codes
- 2: VectorLinePrimitive,
+ 2: VectorLine,
}
diff --git a/gerbonara/gerber/apertures.py b/gerbonara/gerber/apertures.py
index 2c03a37..b478ad9 100644
--- a/gerbonara/gerber/apertures.py
+++ b/gerbonara/gerber/apertures.py
@@ -1,9 +1,11 @@
import math
-from dataclasses import dataclass, replace
-from aperture_macros.parse import GenericMacros
+from dataclasses import dataclass, replace, astuple
+
+from .aperture_macros.parse import GenericMacros
+
+from . import graphic_primitives as gp
-import graphic_primitives as gp
def _flash_hole(self, x, y):
if self.hole_rect_h is not None:
@@ -11,6 +13,13 @@ def _flash_hole(self, x, y):
else:
return self.primitives(x, y), Circle((x, y), self.hole_dia, polarity_dark=False)
+def strip_right(*args):
+ args = list(args)
+ while args and args[-1] is None:
+ args.pop()
+ return args
+
+
class Aperture:
@property
def hole_shape(self):
@@ -25,12 +34,12 @@ class Aperture:
@property
def params(self):
- return dataclasses.astuple(self)
+ return astuple(self)
def flash(self, x, y):
return self.primitives(x, y)
- @parameter
+ @property
def equivalent_width(self):
raise ValueError('Non-circular aperture used in interpolation statement, line width is not properly defined.')
@@ -39,8 +48,8 @@ class Aperture:
# we emulate this parameter. Our circle, rectangle and oblong classes below have a rotation parameter. Only at
# export time during to_gerber, this parameter is evaluated.
actual_inst = self._rotated()
- params = 'X'.join(f'{par:.4}' for par in actual_inst.params)
- return f'{actual_inst.aperture.gerber_shape_code},{params}'
+ params = 'X'.join(f'{float(par):.4}' for par in actual_inst.params if par is not None)
+ return f'{actual_inst.gerber_shape_code},{params}'
def __eq__(self, other):
return hasattr(other, to_gerber) and self.to_gerber() == other.to_gerber()
@@ -57,7 +66,7 @@ class CircleAperture(Aperture):
gerber_shape_code = 'C'
human_readable_shape = 'circle'
diameter : float
- hole_dia : float = 0
+ hole_dia : float = None
hole_rect_h : float = None
rotation : float = 0 # radians; for rectangular hole; see hack in Aperture.to_gerber
@@ -69,12 +78,12 @@ class CircleAperture(Aperture):
flash = _flash_hole
- @parameter
+ @property
def equivalent_width(self):
return self.diameter
- def rotated(self):
- if math.isclose(rotation % (2*math.pi), 0) or self.hole_rect_h is None:
+ def _rotated(self):
+ if math.isclose(self.rotation % (2*math.pi), 0) or self.hole_rect_h is None:
return self
else:
return self.to_macro(self.rotation)
@@ -82,6 +91,10 @@ class CircleAperture(Aperture):
def to_macro(self):
return ApertureMacroInstance(GenericMacros.circle, *self.params)
+ @property
+ def params(self):
+ return strip_right(self.diameter, self.hole_dia, self.hole_rect_h)
+
@dataclass(frozen=True)
class RectangleAperture(Aperture):
@@ -89,7 +102,7 @@ class RectangleAperture(Aperture):
human_readable_shape = 'rect'
w : float
h : float
- hole_dia : float = 0
+ hole_dia : float = None
hole_rect_h : float = None
rotation : float = 0 # radians
@@ -101,7 +114,7 @@ class RectangleAperture(Aperture):
flash = _flash_hole
- @parameter
+ @property
def equivalent_width(self):
return math.sqrt(self.w**2 + self.h**2)
@@ -116,6 +129,10 @@ class RectangleAperture(Aperture):
def to_macro(self):
return ApertureMacroInstance(GenericMacros.rect, *self.params)
+ @property
+ def params(self):
+ return strip_right(self.w, self.h, self.hole_dia, self.hole_rect_h)
+
@dataclass(frozen=True)
class ObroundAperture(Aperture):
@@ -123,7 +140,7 @@ class ObroundAperture(Aperture):
human_readable_shape = 'obround'
w : float
h : float
- hole_dia : float = 0
+ hole_dia : float = None
hole_rect_h : float = None
rotation : float = 0
@@ -148,6 +165,10 @@ class ObroundAperture(Aperture):
inst = self if self.w > self.h else replace(self, w=self.h, h=self.w, **_rotate_hole_90(self))
return ApertureMacroInstance(GenericMacros.obround, *inst.params)
+ @property
+ def params(self):
+ return strip_right(self.w, self.h, self.hole_dia, self.hole_rect_h)
+
@dataclass(frozen=True)
class PolygonAperture(Aperture):
@@ -155,7 +176,7 @@ class PolygonAperture(Aperture):
diameter : float
n_vertices : int
rotation : float = 0
- hole_dia : float = 0
+ hole_dia : float = None
def primitives(self, x, y):
return [ gp.RegularPolygon(x, y, diameter, n_vertices, rotation=self.rotation) ]
@@ -172,6 +193,15 @@ class PolygonAperture(Aperture):
def to_macro(self):
return ApertureMacroInstance(GenericMacros.polygon, *self.params)
+ @property
+ def params(self):
+ if self.hole_dia is not None:
+ return self.diameter, self.n_vertices, self.rotation, self.hole_dia
+ elif self.rotation:
+ return self.diameter, self.n_vertices, self.rotation
+ else:
+ return self.diameter, self.n_vertices
+
class ApertureMacroInstance(Aperture):
params : [float]
@@ -204,4 +234,8 @@ class ApertureMacroInstance(Aperture):
hasattr(other, 'params') and self.params == other.params and \
hasattr(other, 'rotation') and self.rotation == other.rotation
+ @property
+ def params(self):
+ return astuple(self)[:-1]
+
diff --git a/gerbonara/gerber/cam.py b/gerbonara/gerber/cam.py
index fa46ba2..2917fc5 100644
--- a/gerbonara/gerber/cam.py
+++ b/gerbonara/gerber/cam.py
@@ -16,7 +16,7 @@
# limitations under the License.
from dataclasses import dataclass
-
+from copy import deepcopy
@dataclass
class FileSettings:
@@ -28,7 +28,7 @@ class FileSettings:
suppressed, while the Excellon format specifies which zeros are
included. This function uses the Gerber-file convention, so an
Excellon file in LZ (leading zeros) mode would use
- `zero_suppression='trailing'`
+ `zeros='trailing'`
'''
notation : str = 'absolute'
units : str = 'inch'
@@ -38,24 +38,27 @@ class FileSettings:
# input validation
def __setattr__(self, name, value):
- elif name == 'notation' and value not in ['inch', 'mm']:
- raise ValueError('Units must be either "inch" or "mm"')
- elif name == 'units' and value not in ['absolute', 'incremental']:
- raise ValueError('Notation must be either "absolute" or "incremental"')
+ if name == 'units' and value not in ['inch', 'mm']:
+ raise ValueError(f'Units must be either "inch" or "mm", not {value}')
+ elif name == 'notation' and value not in ['absolute', 'incremental']:
+ raise ValueError(f'Notation must be either "absolute" or "incremental", not {value}')
elif name == 'angle_units' and value not in ('degrees', 'radians'):
- raise ValueError('Angle units may be "degrees" or "radians"')
+ raise ValueError(f'Angle units may be "degrees" or "radians", not {value}')
elif name == 'zeros' and value not in [None, 'leading', 'trailing']:
- raise ValueError('zero_suppression must be either "leading" or "trailing" or None')
+ raise ValueError(f'zeros must be either "leading" or "trailing" or None, not {value}')
elif name == 'number_format':
if len(value) != 2:
- raise ValueError('Number format must be a (integer, fractional) tuple of integers')
+ raise ValueError(f'Number format must be a (integer, fractional) tuple of integers, not {value}')
if value[0] > 6 or value[1] > 7:
- raise ValueError('Requested precision is too high. Only up to 6.7 digits are supported by spec.')
+ raise ValueError(f'Requested precision of {value} is too high. Only up to 6.7 digits are supported by spec.')
super().__setattr__(name, value)
+ def copy(self):
+ return deepcopy(self)
+
def __str__(self):
return f'<File settings: units={self.units}/{self.angle_units} notation={self.notation} zeros={self.zeros} number_format={self.number_format}>'
@@ -74,9 +77,7 @@ class FileSettings:
sign = '-' if value[0] == '-' else ''
value = value.lstrip('+-')
- missing_digits = MAX_DIGITS - len(value)
-
- if self.zero_suppression == 'leading':
+ if self.zeros == 'leading':
return float(sign + value[:-decimal_digits] + '.' + value[-decimal_digits:])
else: # no or trailing zero suppression
@@ -90,13 +91,13 @@ class FileSettings:
# negative sign affects padding, so deal with it at the end...
sign = '-' if value < 0 else ''
- num = format(abs(value), f'0{integer_digits+decimal_digits+1}.{decimal_digits}f')
+ num = format(abs(value), f'0{integer_digits+decimal_digits+1}.{decimal_digits}f').replace('.', '')
# Suppression...
- if self.zero_suppression == 'trailing':
+ if self.zeros == 'trailing':
num = num.rstrip('0')
- elif self.zero_suppression == 'leading':
+ elif self.zeros == 'leading':
num = num.lstrip('0')
# Edge case. Per Gerber spec if the value is 0 we should return a single '0' in all cases, see page 77.
@@ -106,49 +107,11 @@ class FileSettings:
return sign + (num or '0')
-class CamFile(object):
- """ Base class for Gerber/Excellon files.
-
- Provides a common set of settings parameters.
-
- Parameters
- ----------
- settings : FileSettings
- The current file configuration.
-
- primitives : iterable
- List of primitives in the file.
-
- filename : string
- Name of the file that this CamFile represents.
-
- layer_name : string
- Name of the PCB layer that the file represents
-
- Attributes
- ----------
- settings : FileSettings
- File settings as a FileSettings object
-
- notation : string
- File notation setting. May be either 'absolute' or 'incremental'
-
- units : string
- File units setting. May be 'inch' or 'mm'
-
- zero_suppression : string
- File zero-suppression setting. May be either 'leading' or 'trailling'
-
- format : tuple (<int>, <int>)
- File decimal representation format as a tuple of (integer digits,
- decimal digits)
- """
-
- def __init__(self, settings=None, primitives=None,
- filename=None, layer_name=None):
- self.settings = settings if settings is not None else FileSettings()
+class CamFile:
+ def __init__(self, filename=None, layer_name=None):
self.filename = filename
self.layer_name = layer_name
+ self.import_settings = None
@property
def bounds(self):
diff --git a/gerbonara/gerber/excellon.py b/gerbonara/gerber/excellon.py
index 5a9d16d..27aaedd 100755
--- a/gerbonara/gerber/excellon.py
+++ b/gerbonara/gerber/excellon.py
@@ -29,7 +29,7 @@ import operator
from .cam import CamFile, FileSettings
from .excellon_statements import *
from .excellon_tool import ExcellonToolDefinitionParser
-from .primitives import Drill, Slot
+from .graphic_objects import Drill, Slot
from .utils import inch, metric
diff --git a/gerbonara/gerber/excellon_statements.py b/gerbonara/gerber/excellon_statements.py
index 2c50ef9..38563a2 100644
--- a/gerbonara/gerber/excellon_statements.py
+++ b/gerbonara/gerber/excellon_statements.py
@@ -24,7 +24,7 @@ Excellon Statements
import re
import uuid
import itertools
-from .utils import (parse_gerber_value, write_gerber_value, decimal_string,
+from .utils import (decimal_string,
inch, metric)
@@ -155,23 +155,21 @@ class ExcellonTool(ExcellonStatement):
commands = pairwise(re.split('([BCFHSTZ])', line)[1:])
args = {}
args['id'] = id
- nformat = settings.format
- zero_suppression = settings.zero_suppression
for cmd, val in commands:
if cmd == 'B':
- args['retract_rate'] = parse_gerber_value(val, nformat, zero_suppression)
+ args['retract_rate'] = settings.parse_gerber_value(val)
elif cmd == 'C':
- args['diameter'] = parse_gerber_value(val, nformat, zero_suppression)
+ args['diameter'] = settings.parse_gerber_value(val)
elif cmd == 'F':
- args['feed_rate'] = parse_gerber_value(val, nformat, zero_suppression)
+ args['feed_rate'] = settings.parse_gerber_value(val)
elif cmd == 'H':
- args['max_hit_count'] = parse_gerber_value(val, nformat, zero_suppression)
+ args['max_hit_count'] = settings.parse_gerber_value(val)
elif cmd == 'S':
- args['rpm'] = 1000 * parse_gerber_value(val, nformat, zero_suppression)
+ args['rpm'] = 1000 * settings.parse_gerber_value(val)
elif cmd == 'T':
args['number'] = int(val)
elif cmd == 'Z':
- args['depth_offset'] = parse_gerber_value(val, nformat, zero_suppression)
+ args['depth_offset'] = settings.parse_gerber_value(val)
if plated != ExcellonTool.PLATED_UNKNOWN:
# Sometimees we can can parse the plating status
@@ -215,24 +213,22 @@ class ExcellonTool(ExcellonStatement):
def to_excellon(self, settings=None):
if self.settings and not settings:
settings = self.settings
- fmt = settings.format
- zs = settings.zero_suppression
stmt = 'T%02d' % self.number
if self.retract_rate is not None:
- stmt += 'B%s' % write_gerber_value(self.retract_rate, fmt, zs)
+ stmt += 'B%s' % settings.write_gerber_value(self.retract_rate)
if self.feed_rate is not None:
- stmt += 'F%s' % write_gerber_value(self.feed_rate, fmt, zs)
+ stmt += 'F%s' % settings.write_gerber_value(self.feed_rate)
if self.max_hit_count is not None:
- stmt += 'H%s' % write_gerber_value(self.max_hit_count, fmt, zs)
+ stmt += 'H%s' % settings.write_gerber_value(self.max_hit_count)
if self.rpm is not None:
if self.rpm < 100000.:
- stmt += 'S%s' % write_gerber_value(self.rpm / 1000., fmt, zs)
+ stmt += 'S%s' % settings.write_gerber_value(self.rpm / 1000.)
else:
stmt += 'S%g' % (self.rpm / 1000.)
if self.diameter is not None:
stmt += 'C%s' % decimal_string(self.diameter, fmt[1], True)
if self.depth_offset is not None:
- stmt += 'Z%s' % write_gerber_value(self.depth_offset, fmt, zs)
+ stmt += 'Z%s' % settings.write_gerber_value(self.depth_offset)
return stmt
def to_inch(self):
@@ -381,14 +377,11 @@ class CoordinateStmt(ExcellonStatement):
y_coord = None
if line[0] == 'X':
splitline = line.strip('X').split('Y')
- x_coord = parse_gerber_value(splitline[0], settings.format,
- settings.zero_suppression)
+ x_coord = settings.parse_gerber_value(splitline[0])
if len(splitline) == 2:
- y_coord = parse_gerber_value(splitline[1], settings.format,
- settings.zero_suppression)
+ y_coord = settings.parse_gerber_value(splitline[1])
else:
- y_coord = parse_gerber_value(line.strip(' Y'), settings.format,
- settings.zero_suppression)
+ y_coord = settings.parse_gerber_value(line.strip(' Y'))
c = cls(x_coord, y_coord, **kwargs)
c.units = settings.units
return c
@@ -406,11 +399,9 @@ class CoordinateStmt(ExcellonStatement):
if self.mode == "LINEAR":
stmt += "G01"
if self.x is not None:
- stmt += 'X%s' % write_gerber_value(self.x, settings.format,
- settings.zero_suppression)
+ stmt += 'X%s' % settings.write_gerber_value(self.x)
if self.y is not None:
- stmt += 'Y%s' % write_gerber_value(self.y, settings.format,
- settings.zero_suppression)
+ stmt += 'Y%s' % settings.write_gerber_value(self.y)
return stmt
def to_inch(self):
@@ -453,11 +444,9 @@ class RepeatHoleStmt(ExcellonStatement):
'(?P<ydelta>[+\-]?\d*\.?\d*)?').match(line)
stmt = match.groupdict()
count = int(stmt['rcount'])
- xdelta = (parse_gerber_value(stmt['xdelta'], settings.format,
- settings.zero_suppression)
+ xdelta = (settings.parse_gerber_value(stmt['xdelta'])
if stmt['xdelta'] is not '' else None)
- ydelta = (parse_gerber_value(stmt['ydelta'], settings.format,
- settings.zero_suppression)
+ ydelta = (settings.parse_gerber_value(stmt['ydelta'])
if stmt['ydelta'] is not '' else None)
c = cls(count, xdelta, ydelta, **kwargs)
c.units = settings.units
@@ -472,11 +461,9 @@ class RepeatHoleStmt(ExcellonStatement):
def to_excellon(self, settings):
stmt = 'R%d' % self.count
if self.xdelta is not None and self.xdelta != 0.0:
- stmt += 'X%s' % write_gerber_value(self.xdelta, settings.format,
- settings.zero_suppression)
+ stmt += 'X%s' % settings.write_gerber_value(self.xdelta)
if self.ydelta is not None and self.ydelta != 0.0:
- stmt += 'Y%s' % write_gerber_value(self.ydelta, settings.format,
- settings.zero_suppression)
+ stmt += 'Y%s' % settings.write_gerber_value(self.ydelta)
return stmt
def to_inch(self):
@@ -604,11 +591,9 @@ class EndOfProgramStmt(ExcellonStatement):
match = re.compile(r'M30X?(?P<x>\d*\.?\d*)?Y?'
'(?P<y>\d*\.?\d*)?').match(line)
stmt = match.groupdict()
- x = (parse_gerber_value(stmt['x'], settings.format,
- settings.zero_suppression)
+ x = (settings.parse_gerber_value(stmt['x'])
if stmt['x'] is not '' else None)
- y = (parse_gerber_value(stmt['y'], settings.format,
- settings.zero_suppression)
+ y = (settings.parse_gerber_value(stmt['y'])
if stmt['y'] is not '' else None)
c = cls(x, y, **kwargs)
c.units = settings.units
@@ -619,12 +604,12 @@ class EndOfProgramStmt(ExcellonStatement):
self.x = x
self.y = y
- def to_excellon(self, settings=None):
+ def to_excellon(self, settings):
stmt = 'M30'
if self.x is not None:
- stmt += 'X%s' % write_gerber_value(self.x)
+ stmt += 'X%s' % settings.write_gerber_value(self.x)
if self.y is not None:
- stmt += 'Y%s' % write_gerber_value(self.y)
+ stmt += 'Y%s' % settings.write_gerber_value(self.y)
return stmt
def to_inch(self):
@@ -878,14 +863,11 @@ class SlotStmt(ExcellonStatement):
if line[0] == 'X':
splitline = line.strip('X').split('Y')
- x_coord = parse_gerber_value(splitline[0], settings.format,
- settings.zero_suppression)
+ x_coord = settings.parse_gerber_value(splitline[0])
if len(splitline) == 2:
- y_coord = parse_gerber_value(splitline[1], settings.format,
- settings.zero_suppression)
+ y_coord = settings.parse_gerber_value(splitline[1])
else:
- y_coord = parse_gerber_value(line.strip(' Y'), settings.format,
- settings.zero_suppression)
+ y_coord = settings.parse_gerber_value(line.strip(' Y'))
return (x_coord, y_coord)
@@ -902,20 +884,16 @@ class SlotStmt(ExcellonStatement):
stmt = ''
if self.x_start is not None:
- stmt += 'X%s' % write_gerber_value(self.x_start, settings.format,
- settings.zero_suppression)
+ stmt += 'X%s' % settings.write_gerber_value(self.x_start)
if self.y_start is not None:
- stmt += 'Y%s' % write_gerber_value(self.y_start, settings.format,
- settings.zero_suppression)
+ stmt += 'Y%s' % settings.write_gerber_value(self.y_start)
stmt += 'G85'
if self.x_end is not None:
- stmt += 'X%s' % write_gerber_value(self.x_end, settings.format,
- settings.zero_suppression)
+ stmt += 'X%s' % settings.write_gerber_value(self.x_end)
if self.y_end is not None:
- stmt += 'Y%s' % write_gerber_value(self.y_end, settings.format,
- settings.zero_suppression)
+ stmt += 'Y%s' % settings.write_gerber_value(self.y_end)
return stmt
diff --git a/gerbonara/gerber/gerber_statements.py b/gerbonara/gerber/gerber_statements.py
index 7555a18..5f3363e 100644
--- a/gerbonara/gerber/gerber_statements.py
+++ b/gerbonara/gerber/gerber_statements.py
@@ -20,7 +20,6 @@ Gerber (RS-274X) Statements
**Gerber RS-274X file statement classes**
"""
-from utils import parse_gerber_value, write_gerber_value, decimal_string, inch, metric
class Statement:
pass
@@ -38,7 +37,7 @@ class FormatSpecStmt(ParamStmt):
""" FS - Gerber Format Specification Statement """
def to_gerber(self, settings):
- zeros = 'L' if settings.zero_suppression == 'leading' else 'T'
+ zeros = 'T' if settings.zeros == 'trailing' else 'L' # default to leading if "None" is specified
notation = 'A' if settings.notation == 'absolute' else 'I'
number_format = str(settings.number_format[0]) + str(settings.number_format[1])
@@ -84,7 +83,7 @@ class ApertureDefStmt(ParamStmt):
self.aperture = aperture
def to_gerber(self, settings=None):
- return '%ADD{self.number}{self.aperture.to_gerber()}*%'
+ return f'%ADD{self.number}{self.aperture.to_gerber()}*%'
def __str__(self):
return f'<AD aperture def for {str(self.aperture).strip("<>")}>'
@@ -96,7 +95,8 @@ class ApertureMacroStmt(ParamStmt):
def __init__(self, macro):
self.macro = macro
- def to_gerber(self, unit=None):
+ def to_gerber(self, settings=None):
+ unit = settings.units if settings else None
return f'%AM{self.macro.name}*\n{self.macro.to_gerber(unit=unit)}*\n%'
def __str__(self):
@@ -107,8 +107,8 @@ class ImagePolarityStmt(ParamStmt):
""" IP - Image Polarity Statement. (Deprecated) """
def to_gerber(self, settings):
- ip = 'POS' if settings.image_polarity == 'positive' else 'NEG'
- return f'%IP{ip}*%'
+ #ip = 'POS' if settings.image_polarity == 'positive' else 'NEG'
+ return f'%IPPOS*%'
def __str__(self):
return '<IP Image Polarity>'
@@ -125,16 +125,16 @@ class CoordStmt(Statement):
for var in 'xyij':
val = getattr(self, var)
if val is not None:
- ret += var.upper() + write_gerber_value(val, settings.number_format, settings.zero_suppression)
+ ret += var.upper() + settings.write_gerber_value(val)
return ret + self.code + '*'
def __str__(self):
if self.i is None:
return f'<{self.__name__.strip()} x={self.x} y={self.y}>'
- else
- return f'<{self.__name__.strip()} x={self.x} y={self.y} i={self.i} j={self.j]>'
+ else:
+ return f'<{self.__name__.strip()} x={self.x} y={self.y} i={self.i} j={self.j}>'
-class InterpolateStmt(Statement):
+class InterpolateStmt(CoordStmt):
""" D01 Interpolation """
code = 'D01'
@@ -148,7 +148,7 @@ class FlashStmt(CoordStmt):
class InterpolationModeStmt(Statement):
""" G01 / G02 / G03 interpolation mode statement """
- def to_gerber(self, **_kwargs):
+ def to_gerber(self, settings=None):
return self.code + '*'
def __str__(self):
@@ -205,9 +205,6 @@ class CommentStmt(Statement):
class EofStmt(Statement):
""" M02 EOF Statement """
- def __init__(self):
- Statement.__init__(self, "EOF")
-
def to_gerber(self, settings=None):
return 'M02*'
@@ -218,7 +215,7 @@ class UnknownStmt(Statement):
def __init__(self, line):
self.line = line
- def to_gerber(self, settings):
+ def to_gerber(self, settings=None):
return self.line
def __str__(self):
diff --git a/gerbonara/gerber/graphic_objects.py b/gerbonara/gerber/graphic_objects.py
index 55d1b9c..47ed718 100644
--- a/gerbonara/gerber/graphic_objects.py
+++ b/gerbonara/gerber/graphic_objects.py
@@ -1,6 +1,10 @@
-import graphic_primitives as gp
+from dataclasses import dataclass, KW_ONLY
+from . import graphic_primitives as gp
+from .gerber_statements import *
+
+@dataclass
class GerberObject:
_ : KW_ONLY
polarity_dark : bool = True
@@ -73,6 +77,7 @@ class Region(GerberObject):
yield RegionEndStmt()
+@dataclass
class Line(GerberObject):
# Line with *round* end caps.
x1 : float
@@ -109,6 +114,52 @@ class Line(GerberObject):
yield InterpolateStmt(*self.p2)
+@dataclass
+class Drill(GerberObject):
+ x : float
+ y : float
+ diameter : float
+
+ def with_offset(self, dx, dy):
+ return replace(self, x=self.x+dx, y=self.y+dy)
+
+ def rotate(self, angle, cx=None, cy=None):
+ self.x, self.y = gp.rotate_point(self.x, self.y, angle, cx, cy)
+
+ def to_primitives(self):
+ yield gp.Circle(self.x, self.y, self.diameter/2)
+
+
+@dataclass
+class Slot(GerberObject):
+ x1 : float
+ y1 : float
+ x2 : float
+ y2 : float
+ width : float
+
+ def with_offset(self, dx, dy):
+ return replace(self, x1=self.x1+dx, y1=self.y1+dy, x2=self.x2+dx, y2=self.y2+dy)
+
+ def rotate(self, rotation, cx=None, cy=None):
+ if cx is None:
+ cx = (self.x1 + self.x2) / 2
+ cy = (self.y1 + self.y2) / 2
+ self.x1, self.y1 = gp.rotate_point(self.x1, self.y1, rotation, cx, cy)
+ self.x2, self.y2 = gp.rotate_point(self.x2, self.y2, rotation, cx, cy)
+
+ @property
+ def p1(self):
+ return self.x1, self.y1
+
+ @property
+ def p2(self):
+ return self.x2, self.y2
+
+ def to_primitives(self):
+ yield gp.Line(*self.p1, *self.p2, self.width, polarity_dark=self.polarity_dark)
+
+
class Arc(GerberObject):
x : float
y : float
diff --git a/gerbonara/gerber/graphic_primitives.py b/gerbonara/gerber/graphic_primitives.py
index 391a452..9518501 100644
--- a/gerbonara/gerber/graphic_primitives.py
+++ b/gerbonara/gerber/graphic_primitives.py
@@ -4,7 +4,7 @@ import itertools
from dataclasses import dataclass, KW_ONLY, replace
-from gerber_statements import *
+from .gerber_statements import *
class GraphicPrimitive:
@@ -69,10 +69,10 @@ class ArcPoly(GraphicPrimitive):
# list of (x : float, y : float) tuples. Describes closed outline, i.e. first and last point are considered
# connected.
- outline : list(tuple(float))
+ outline : [(float,)]
# list of radii of segments, must be either None (all segments are straight lines) or same length as outline.
# Straight line segments have None entry.
- arc_centers : list(tuple(float))
+ arc_centers : [(float,)]
@property
def segments(self):
@@ -116,7 +116,7 @@ class Rectangle(GraphicPrimitive):
def bounds(self):
return ((self.x, self.y), (self.x+self.w, self.y+self.h))
- @prorperty
+ @property
def center(self):
return self.x + self.w/2, self.y + self.h/2
diff --git a/gerbonara/gerber/ipc356.py b/gerbonara/gerber/ipc356.py
index 55c079a..23382e3 100644
--- a/gerbonara/gerber/ipc356.py
+++ b/gerbonara/gerber/ipc356.py
@@ -16,10 +16,10 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+from dataclasses import dataclass
import math
import re
from .cam import CamFile, FileSettings
-from .primitives import TestRecord
# Net Name Variables
_NNAME = re.compile(r'^NNAME\d+$')
@@ -50,6 +50,11 @@ def read(filename):
# File object should use settings from source file by default.
return IPCNetlist.from_file(filename)
+@dataclass
+class TestRecord:
+ position : [float]
+ net_name : str
+ layer : str
def loads(data, filename=None):
""" Generate an IPCNetlist object from IPC-D-356 data in memory
diff --git a/gerbonara/gerber/layers.py b/gerbonara/gerber/layers.py
index 90518ac..c221324 100644
--- a/gerbonara/gerber/layers.py
+++ b/gerbonara/gerber/layers.py
@@ -19,7 +19,6 @@ import os
import re
from collections import namedtuple
-from . import common
from .excellon import ExcellonFile
from .ipc356 import IPCNetlist
@@ -294,3 +293,105 @@ class InternalLayer(PCBLayer):
if not hasattr(other, 'order'):
raise TypeError()
return (self.order <= other.order)
+
+class PCB:
+
+ @classmethod
+ def from_directory(cls, directory, board_name=None, verbose=False):
+ layers = []
+ names = set()
+
+ # Validate
+ directory = os.path.abspath(directory)
+ if not os.path.isdir(directory):
+ raise TypeError('{} is not a directory.'.format(directory))
+
+ # Load gerber files
+ for filename in os.listdir(directory):
+ try:
+ camfile = gerber_read(os.path.join(directory, filename))
+ layer = PCBLayer.from_cam(camfile)
+ layers.append(layer)
+ name = os.path.splitext(filename)[0]
+ if len(os.path.splitext(filename)) > 1:
+ _name, ext = os.path.splitext(name)
+ if ext[1:] in layer_signatures(layer.layer_class):
+ name = _name
+ if layer.layer_class == 'drill' and 'drill' in ext:
+ name = _name
+ names.add(name)
+ if verbose:
+ print('[PCB]: Added {} layer <{}>'.format(layer.layer_class,
+ filename))
+ except ParseError:
+ if verbose:
+ print('[PCB]: Skipping file {}'.format(filename))
+ except IOError:
+ if verbose:
+ print('[PCB]: Skipping file {}'.format(filename))
+
+ # Try to guess board name
+ if board_name is None:
+ if len(names) == 1:
+ board_name = names.pop()
+ else:
+ board_name = os.path.basename(directory)
+ # Return PCB
+ return cls(layers, board_name)
+
+ def __init__(self, layers, name=None):
+ self.layers = sort_layers(layers)
+ self.name = name
+
+ def __len__(self):
+ return len(self.layers)
+
+ @property
+ def top_layers(self):
+ board_layers = [l for l in reversed(self.layers) if l.layer_class in
+ ('topsilk', 'topmask', 'top')]
+ drill_layers = [l for l in self.drill_layers if 'top' in l.layers]
+ # Drill layer goes under soldermask for proper rendering of tented vias
+ return [board_layers[0]] + drill_layers + board_layers[1:]
+
+ @property
+ def bottom_layers(self):
+ board_layers = [l for l in self.layers if l.layer_class in
+ ('bottomsilk', 'bottommask', 'bottom')]
+ drill_layers = [l for l in self.drill_layers if 'bottom' in l.layers]
+ # Drill layer goes under soldermask for proper rendering of tented vias
+ return [board_layers[0]] + drill_layers + board_layers[1:]
+
+ @property
+ def drill_layers(self):
+ return [l for l in self.layers if l.layer_class == 'drill']
+
+ @property
+ def copper_layers(self):
+ return list(reversed([layer for layer in self.layers if
+ layer.layer_class in
+ ('top', 'bottom', 'internal')]))
+
+ @property
+ def outline_layer(self):
+ for layer in self.layers:
+ if layer.layer_class == 'outline':
+ return layer
+
+ @property
+ def layer_count(self):
+ """ Number of *COPPER* layers
+ """
+ return len([l for l in self.layers if l.layer_class in
+ ('top', 'bottom', 'internal')])
+
+ @property
+ def board_bounds(self):
+ for layer in self.layers:
+ if layer.layer_class == 'outline':
+ return layer.bounding_box
+
+ for layer in self.layers:
+ if layer.layer_class == 'top':
+ return layer.bounding_box
+
diff --git a/gerbonara/gerber/pcb.py b/gerbonara/gerber/pcb.py
deleted file mode 100644
index 8b11cf5..0000000
--- a/gerbonara/gerber/pcb.py
+++ /dev/null
@@ -1,125 +0,0 @@
-#! /usr/bin/env python
-# -*- coding: utf-8 -*-
-
-# copyright 2015 Hamilton Kibbe <ham@hamiltonkib.be>
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-
-# http://www.apache.org/licenses/LICENSE-2.0
-
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-
-import os
-from .exceptions import ParseError
-from .layers import PCBLayer, sort_layers, layer_signatures
-from .common import read as gerber_read
-
-
-class PCB(object):
-
- @classmethod
- def from_directory(cls, directory, board_name=None, verbose=False):
- layers = []
- names = set()
-
- # Validate
- directory = os.path.abspath(directory)
- if not os.path.isdir(directory):
- raise TypeError('{} is not a directory.'.format(directory))
-
- # Load gerber files
- for filename in os.listdir(directory):
- try:
- camfile = gerber_read(os.path.join(directory, filename))
- layer = PCBLayer.from_cam(camfile)
- layers.append(layer)
- name = os.path.splitext(filename)[0]
- if len(os.path.splitext(filename)) > 1:
- _name, ext = os.path.splitext(name)
- if ext[1:] in layer_signatures(layer.layer_class):
- name = _name
- if layer.layer_class == 'drill' and 'drill' in ext:
- name = _name
- names.add(name)
- if verbose:
- print('[PCB]: Added {} layer <{}>'.format(layer.layer_class,
- filename))
- except ParseError:
- if verbose:
- print('[PCB]: Skipping file {}'.format(filename))
- except IOError:
- if verbose:
- print('[PCB]: Skipping file {}'.format(filename))
-
- # Try to guess board name
- if board_name is None:
- if len(names) == 1:
- board_name = names.pop()
- else:
- board_name = os.path.basename(directory)
- # Return PCB
- return cls(layers, board_name)
-
- def __init__(self, layers, name=None):
- self.layers = sort_layers(layers)
- self.name = name
-
- def __len__(self):
- return len(self.layers)
-
- @property
- def top_layers(self):
- board_layers = [l for l in reversed(self.layers) if l.layer_class in
- ('topsilk', 'topmask', 'top')]
- drill_layers = [l for l in self.drill_layers if 'top' in l.layers]
- # Drill layer goes under soldermask for proper rendering of tented vias
- return [board_layers[0]] + drill_layers + board_layers[1:]
-
- @property
- def bottom_layers(self):
- board_layers = [l for l in self.layers if l.layer_class in
- ('bottomsilk', 'bottommask', 'bottom')]
- drill_layers = [l for l in self.drill_layers if 'bottom' in l.layers]
- # Drill layer goes under soldermask for proper rendering of tented vias
- return [board_layers[0]] + drill_layers + board_layers[1:]
-
- @property
- def drill_layers(self):
- return [l for l in self.layers if l.layer_class == 'drill']
-
- @property
- def copper_layers(self):
- return list(reversed([layer for layer in self.layers if
- layer.layer_class in
- ('top', 'bottom', 'internal')]))
-
- @property
- def outline_layer(self):
- for layer in self.layers:
- if layer.layer_class == 'outline':
- return layer
-
- @property
- def layer_count(self):
- """ Number of *COPPER* layers
- """
- return len([l for l in self.layers if l.layer_class in
- ('top', 'bottom', 'internal')])
-
- @property
- def board_bounds(self):
- for layer in self.layers:
- if layer.layer_class == 'outline':
- return layer.bounding_box
-
- for layer in self.layers:
- if layer.layer_class == 'top':
- return layer.bounding_box
-
diff --git a/gerbonara/gerber/primitives.py b/gerbonara/gerber/primitives.py
deleted file mode 100644
index d505ddb..0000000
--- a/gerbonara/gerber/primitives.py
+++ /dev/null
@@ -1,932 +0,0 @@
-#! /usr/bin/env python
-# -*- coding: utf-8 -*-
-
-# copyright 2016 Hamilton Kibbe <ham@hamiltonkib.be>
-
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-
-# http://www.apache.org/licenses/LICENSE-2.0
-
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-
-import math
-from operator import add
-from itertools import combinations
-from .utils import validate_coordinates, inch, metric, convex_hull
-from .utils import rotate_point, nearly_equal
-
-
-
-class Primitive:
- def __init__(self, polarity_dark=True, rotation=0, **meta):
- self.polarity_dark = polarity_dark
- self.meta = meta
- self.rotation = rotation
-
- def __eq__(self, other):
- return self.__dict__ == other.__dict__
-
- def aperture(self):
- return None
-
-
-class Line(Primitive):
- def __init__(self, start, end, aperture=None, polarity_dark=True, rotation=0, **meta):
- super().__init__(polarity_dark, rotation, **meta)
- self.start = start
- self.end = end
- self.aperture = aperture
-
- @property
- def angle(self):
- delta_x, delta_y = tuple(end - start for end, start in zip(self.end, self.start))
- return math.atan2(delta_y, delta_x)
-
- @property
- def bounding_box(self):
- if isinstance(self.aperture, Circle):
- width_2 = self.aperture.radius
- height_2 = width_2
- else:
- width_2 = self.aperture.width / 2.
- height_2 = self.aperture.height / 2.
- min_x = min(self.start[0], self.end[0]) - width_2
- max_x = max(self.start[0], self.end[0]) + width_2
- min_y = min(self.start[1], self.end[1]) - height_2
- max_y = max(self.start[1], self.end[1]) + height_2
- return (min_x, min_y), (max_x, max_y)
-
- @property
- def bounding_box_no_aperture(self):
- '''Gets the bounding box without the aperture'''
- min_x = min(self.start[0], self.end[0])
- max_x = max(self.start[0], self.end[0])
- min_y = min(self.start[1], self.end[1])
- max_y = max(self.start[1], self.end[1])
- return ((min_x, min_y), (max_x, max_y))
-
- @property
- def vertices(self):
- if self._vertices is None:
- start = self.start
- end = self.end
- if isinstance(self.aperture, Rectangle):
- width = self.aperture.width
- height = self.aperture.height
-
- # Find all the corners of the start and end position
- start_ll = (start[0] - (width / 2.), start[1] - (height / 2.))
- start_lr = (start[0] + (width / 2.), start[1] - (height / 2.))
- start_ul = (start[0] - (width / 2.), start[1] + (height / 2.))
- start_ur = (start[0] + (width / 2.), start[1] + (height / 2.))
- end_ll = (end[0] - (width / 2.), end[1] - (height / 2.))
- end_lr = (end[0] + (width / 2.), end[1] - (height / 2.))
- end_ul = (end[0] - (width / 2.), end[1] + (height / 2.))
- end_ur = (end[0] + (width / 2.), end[1] + (height / 2.))
-
- # The line is defined by the convex hull of the points
- self._vertices = convex_hull((start_ll, start_lr, start_ul, start_ur, end_ll, end_lr, end_ul, end_ur))
- elif isinstance(self.aperture, Polygon):
- points = [map(add, point, vertex)
- for vertex in self.aperture.vertices
- for point in (start, end)]
- self._vertices = convex_hull(points)
- return self._vertices
-
- def offset(self, x_offset=0, y_offset=0):
- self._changed()
- self.start = tuple([coord + offset for coord, offset
- in zip(self.start, (x_offset, y_offset))])
- self.end = tuple([coord + offset for coord, offset
- in zip(self.end, (x_offset, y_offset))])
-
- def equivalent(self, other, offset):
-
- if not isinstance(other, Line):
- return False
-
- equiv_start = tuple(map(add, other.start, offset))
- equiv_end = tuple(map(add, other.end, offset))
-
-
- return nearly_equal(self.start, equiv_start) and nearly_equal(self.end, equiv_end)
-
- def __str__(self):
- return "<Line {} to {}>".format(self.start, self.end)
-
- def __repr__(self):
- return str(self)
-
-class Arc(Primitive):
- def __init__(self, start, end, center, direction, aperture, level_polarity=None, **kwargs):
- super(Arc, self).__init__(**kwargs)
- self.level_polarity = level_polarity
- self._start = start
- self._end = end
- self._center = center
- self.direction = direction
- self.aperture = aperture
- self._to_convert = ['start', 'end', 'center', 'aperture']
-
- @property
- def flashed(self):
- return False
-
- @property
- def start(self):
- return self._start
-
- @start.setter
- def start(self, value):
- self._changed()
- self._start = value
-
- @property
- def end(self):
- return self._end
-
- @end.setter
- def end(self, value):
- self._changed()
- self._end = value
-
- @property
- def center(self):
- return self._center
-
- @center.setter
- def center(self, value):
- self._changed()
- self._center = value
-
- @property
- def radius(self):
- dy, dx = tuple([start - center for start, center
- in zip(self.start, self.center)])
- return math.sqrt(dy ** 2 + dx ** 2)
-
- @property
- def start_angle(self):
- dx, dy = tuple([start - center for start, center
- in zip(self.start, self.center)])
- return math.atan2(dy, dx)
-
- @property
- def end_angle(self):
- dx, dy = tuple([end - center for end, center
- in zip(self.end, self.center)])
- return math.atan2(dy, dx)
-
- @property
- def sweep_angle(self):
- two_pi = 2 * math.pi
- theta0 = (self.start_angle + two_pi) % two_pi
- theta1 = (self.end_angle + two_pi) % two_pi
- if self.direction == 'counterclockwise':
- return abs(theta1 - theta0)
- else:
- theta0 += two_pi
- return abs(theta0 - theta1) % two_pi
-
- @property
- def bounding_box(self):
- if self._bounding_box is None:
- two_pi = 2 * math.pi
- theta0 = (self.start_angle + two_pi) % two_pi
- theta1 = (self.end_angle + two_pi) % two_pi
- points = [self.start, self.end]
- x, y = zip(*points)
- if hasattr(self.aperture, 'radius'):
- min_x = min(x) - self.aperture.radius
- max_x = max(x) + self.aperture.radius
- min_y = min(y) - self.aperture.radius
- max_y = max(y) + self.aperture.radius
- else:
- min_x = min(x) - self.aperture.width
- max_x = max(x) + self.aperture.width
- min_y = min(y) - self.aperture.height
- max_y = max(y) + self.aperture.height
-
- self._bounding_box = ((min_x, min_y), (max_x, max_y))
- return self._bounding_box
-
- @property
- def bounding_box_no_aperture(self):
- '''Gets the bounding box without considering the aperture'''
- two_pi = 2 * math.pi
- theta0 = (self.start_angle + two_pi) % two_pi
- theta1 = (self.end_angle + two_pi) % two_pi
- points = [self.start, self.end]
- x, y = zip(*points)
-
- min_x = min(x)
- max_x = max(x)
- min_y = min(y)
- max_y = max(y)
- return ((min_x, min_y), (max_x, max_y))
-
- def offset(self, x_offset=0, y_offset=0):
- self._changed()
- self.start = tuple(map(add, self.start, (x_offset, y_offset)))
- self.end = tuple(map(add, self.end, (x_offset, y_offset)))
- self.center = tuple(map(add, self.center, (x_offset, y_offset)))
-
-
-class Circle(Primitive):
- def __init__(self, position, diameter, polarity_dark=True):
- super(Circle, self).__init__(**kwargs)
- validate_coordinates(position)
- self._position = position
- self._diameter = diameter
- self.hole_diameter = hole_diameter
- self.hole_width = hole_width
- self.hole_height = hole_height
- self._to_convert = ['position', 'diameter', 'hole_diameter', 'hole_width', 'hole_height']
-
- @property
- def flashed(self):
- return True
-
- @property
- def position(self):
- return self._position
-
- @position.setter
- def position(self, value):
- self._changed()
- self._position = value
-
- @property
- def diameter(self):
- return self._diameter
-
- @diameter.setter
- def diameter(self, value):
- self._changed()
- self._diameter = value
-
- @property
- def radius(self):
- return self.diameter / 2.
-
- @property
- def hole_radius(self):
- if self.hole_diameter != None:
- return self.hole_diameter / 2.
- return None
-
- @property
- def bounding_box(self):
- if self._bounding_box is None:
- min_x = self.position[0] - self.radius
- max_x = self.position[0] + self.radius
- min_y = self.position[1] - self.radius
- max_y = self.position[1] + self.radius
- self._bounding_box = ((min_x, min_y), (max_x, max_y))
- return self._bounding_box
-
- def offset(self, x_offset=0, y_offset=0):
- self.position = tuple(map(add, self.position, (x_offset, y_offset)))
-
- def equivalent(self, other, offset):
- '''Is this the same as the other circle, ignoring the offiset?'''
-
- if not isinstance(other, Circle):
- return False
-
- if self.diameter != other.diameter or self.hole_diameter != other.hole_diameter:
- return False
-
- equiv_position = tuple(map(add, other.position, offset))
-
- return nearly_equal(self.position, equiv_position)
-
-
-class Rectangle(Primitive):
- """
- When rotated, the rotation is about the center point.
-
- Only aperture macro generated Rectangle objects can be rotated. If you aren't in a AMGroup,
- then you don't need to worry about rotation
- """
-
- def __init__(self, position, width, height, hole_diameter=0,
- hole_width=0, hole_height=0, **kwargs):
- super(Rectangle, self).__init__(**kwargs)
- validate_coordinates(position)
- self._position = position
- self._width = width
- self._height = height
- self.hole_diameter = hole_diameter
- self.hole_width = hole_width
- self.hole_height = hole_height
- self._to_convert = ['position', 'width', 'height', 'hole_diameter',
- 'hole_width', 'hole_height']
- # TODO These are probably wrong when rotated
- self._lower_left = None
- self._upper_right = None
-
- @property
- def flashed(self):
- return True
-
- @property
- def position(self):
- return self._position
-
- @position.setter
- def position(self, value):
- self._changed()
- self._position = value
-
- @property
- def width(self):
- return self._width
-
- @width.setter
- def width(self, value):
- self._changed()
- self._width = value
-
- @property
- def height(self):
- return self._height
-
- @height.setter
- def height(self, value):
- self._changed()
- self._height = value
-
- @property
- def hole_radius(self):
- """The radius of the hole. If there is no hole, returns None"""
- if self.hole_diameter != None:
- return self.hole_diameter / 2.
- return None
-
- @property
- def upper_right(self):
- return (self.position[0] + (self.axis_aligned_width / 2.),
- self.position[1] + (self.axis_aligned_height / 2.))
-
- @property
- def lower_left(self):
- return (self.position[0] - (self.axis_aligned_width / 2.),
- self.position[1] - (self.axis_aligned_height / 2.))
-
- @property
- def bounding_box(self):
- if self._bounding_box is None:
- ll = (self.position[0] - (self.axis_aligned_width / 2.),
- self.position[1] - (self.axis_aligned_height / 2.))
- ur = (self.position[0] + (self.axis_aligned_width / 2.),
- self.position[1] + (self.axis_aligned_height / 2.))
- self._bounding_box = ((ll[0], ll[1]), (ur[0], ur[1]))
- return self._bounding_box
-
- @property
- def vertices(self):
- if self._vertices is None:
- delta_w = self.width / 2.
- delta_h = self.height / 2.
- ll = ((self.position[0] - delta_w), (self.position[1] - delta_h))
- ul = ((self.position[0] - delta_w), (self.position[1] + delta_h))
- ur = ((self.position[0] + delta_w), (self.position[1] + delta_h))
- lr = ((self.position[0] + delta_w), (self.position[1] - delta_h))
- self._vertices = [((x * self._cos_theta - y * self._sin_theta),
- (x * self._sin_theta + y * self._cos_theta))
- for x, y in [ll, ul, ur, lr]]
- return self._vertices
-
- @property
- def axis_aligned_width(self):
- return (self._cos_theta * self.width + self._sin_theta * self.height)
-
- @property
- def axis_aligned_height(self):
- return (self._cos_theta * self.height + self._sin_theta * self.width)
-
- def equivalent(self, other, offset):
- """Is this the same as the other rect, ignoring the offset?"""
-
- if not isinstance(other, Rectangle):
- return False
-
- if self.width != other.width or self.height != other.height or self.rotation != other.rotation or self.hole_diameter != other.hole_diameter:
- return False
-
- equiv_position = tuple(map(add, other.position, offset))
-
- return nearly_equal(self.position, equiv_position)
-
- def __str__(self):
- return "<Rectangle W {} H {} R {}>".format(self.width, self.height, self.rotation * 180/math.pi)
-
- def __repr__(self):
- return self.__str__()
-
-
-class Obround(Primitive):
- def __init__(self, position, width, height, hole_diameter=0,
- hole_width=0,hole_height=0, **kwargs):
- super(Obround, self).__init__(**kwargs)
- validate_coordinates(position)
- self._position = position
- self._width = width
- self._height = height
- self.hole_diameter = hole_diameter
- self.hole_width = hole_width
- self.hole_height = hole_height
- self._to_convert = ['position', 'width', 'height', 'hole_diameter',
- 'hole_width', 'hole_height' ]
-
- @property
- def flashed(self):
- return True
-
- @property
- def position(self):
- return self._position
-
- @position.setter
- def position(self, value):
- self._changed()
- self._position = value
-
- @property
- def width(self):
- return self._width
-
- @width.setter
- def width(self, value):
- self._changed()
- self._width = value
-
- @property
- def height(self):
- return self._height
-
- @height.setter
- def height(self, value):
- self._changed()
- self._height = value
-
- @property
- def hole_radius(self):
- """The radius of the hole. If there is no hole, returns None"""
- if self.hole_diameter != None:
- return self.hole_diameter / 2.
-
- return None
-
- @property
- def orientation(self):
- return 'vertical' if self.height > self.width else 'horizontal'
-
- @property
- def bounding_box(self):
- if self._bounding_box is None:
- ll = (self.position[0] - (self.axis_aligned_width / 2.),
- self.position[1] - (self.axis_aligned_height / 2.))
- ur = (self.position[0] + (self.axis_aligned_width / 2.),
- self.position[1] + (self.axis_aligned_height / 2.))
- self._bounding_box = ((ll[0], ll[1]), (ur[0], ur[1]))
- return self._bounding_box
-
- @property
- def subshapes(self):
- if self.orientation == 'vertical':
- circle1 = Circle((self.position[0], self.position[1] +
- (self.height - self.width) / 2.), self.width)
- circle2 = Circle((self.position[0], self.position[1] -
- (self.height - self.width) / 2.), self.width)
- rect = Rectangle(self.position, self.width,
- (self.height - self.width))
- else:
- circle1 = Circle((self.position[0]
- - (self.height - self.width) / 2.,
- self.position[1]), self.height)
- circle2 = Circle((self.position[0]
- + (self.height - self.width) / 2.,
- self.position[1]), self.height)
- rect = Rectangle(self.position, (self.width - self.height),
- self.height)
- return {'circle1': circle1, 'circle2': circle2, 'rectangle': rect}
-
- @property
- def axis_aligned_width(self):
- return (self._cos_theta * self.width +
- self._sin_theta * self.height)
-
- @property
- def axis_aligned_height(self):
- return (self._cos_theta * self.height +
- self._sin_theta * self.width)
-
-
-class Polygon(Primitive):
- """
- Polygon flash defined by a set number of sides.
- """
- def __init__(self, position, sides, radius, hole_diameter=0,
- hole_width=0, hole_height=0, **kwargs):
- super(Polygon, self).__init__(**kwargs)
- validate_coordinates(position)
- self._position = position
- self.sides = sides
- self._radius = radius
- self.hole_diameter = hole_diameter
- self.hole_width = hole_width
- self.hole_height = hole_height
- self._to_convert = ['position', 'radius', 'hole_diameter',
- 'hole_width', 'hole_height']
-
- @property
- def flashed(self):
- return True
-
- @property
- def diameter(self):
- return self.radius * 2
-
- @property
- def hole_radius(self):
- if self.hole_diameter != None:
- return self.hole_diameter / 2.
- return None
-
- @property
- def position(self):
- return self._position
-
- @position.setter
- def position(self, value):
- self._changed()
- self._position = value
-
- @property
- def radius(self):
- return self._radius
-
- @radius.setter
- def radius(self, value):
- self._changed()
- self._radius = value
-
- @property
- def bounding_box(self):
- if self._bounding_box is None:
- min_x = self.position[0] - self.radius
- max_x = self.position[0] + self.radius
- min_y = self.position[1] - self.radius
- max_y = self.position[1] + self.radius
- self._bounding_box = ((min_x, min_y), (max_x, max_y))
- return self._bounding_box
-
- def offset(self, x_offset=0, y_offset=0):
- self.position = tuple(map(add, self.position, (x_offset, y_offset)))
-
- @property
- def vertices(self):
-
- offset = self.rotation
- delta_angle = 360.0 / self.sides
-
- points = []
- for i in range(self.sides):
- points.append(
- rotate_point((self.position[0] + self.radius, self.position[1]), offset + delta_angle * i, self.position))
- return points
-
-
- def equivalent(self, other, offset):
- """
- Is this the outline the same as the other, ignoring the position offset?
- """
-
- # Quick check if it even makes sense to compare them
- if type(self) != type(other) or self.sides != other.sides or self.radius != other.radius:
- return False
-
- equiv_pos = tuple(map(add, other.position, offset))
-
- return nearly_equal(self.position, equiv_pos)
-
-
-class AMGroup(Primitive):
- """
- """
- def __init__(self, amprimitives, stmt = None, **kwargs):
- """
-
- stmt : The original statment that generated this, since it is really hard to re-generate from primitives
- """
- super(AMGroup, self).__init__(**kwargs)
-
- self.primitives = []
- for amprim in amprimitives:
- prim = amprim.to_primitive(self.units)
- if isinstance(prim, list):
- for p in prim:
- self.primitives.append(p)
- elif prim:
- self.primitives.append(prim)
- self._position = None
- self._to_convert = ['_position', 'primitives']
- self.stmt = stmt
-
- def to_inch(self):
- if self.units == 'metric':
- super(AMGroup, self).to_inch()
-
- # If we also have a stmt, convert that too
- if self.stmt:
- self.stmt.to_inch()
-
-
- def to_metric(self):
- if self.units == 'inch':
- super(AMGroup, self).to_metric()
-
- # If we also have a stmt, convert that too
- if self.stmt:
- self.stmt.to_metric()
-
- @property
- def flashed(self):
- return True
-
- @property
- def bounding_box(self):
- # TODO Make this cached like other items
- xlims, ylims = zip(*[p.bounding_box for p in self.primitives])
- minx, maxx = zip(*xlims)
- miny, maxy = zip(*ylims)
- min_x = min(minx)
- max_x = max(maxx)
- min_y = min(miny)
- max_y = max(maxy)
- return ((min_x, max_x), (min_y, max_y))
-
- @property
- def position(self):
- return self._position
-
- def offset(self, x_offset=0, y_offset=0):
- self._position = tuple(map(add, self._position, (x_offset, y_offset)))
-
- for primitive in self.primitives:
- primitive.offset(x_offset, y_offset)
-
- @position.setter
- def position(self, new_pos):
- '''
- Sets the position of the AMGroup.
- This offset all of the objects by the specified distance.
- '''
-
- if self._position:
- dx = new_pos[0] - self._position[0]
- dy = new_pos[1] - self._position[1]
- else:
- dx = new_pos[0]
- dy = new_pos[1]
-
- for primitive in self.primitives:
- primitive.offset(dx, dy)
-
- self._position = new_pos
-
- def equivalent(self, other, offset):
- '''
- Is this the macro group the same as the other, ignoring the position offset?
- '''
-
- if len(self.primitives) != len(other.primitives):
- return False
-
- # We know they have the same number of primitives, so now check them all
- for i in range(0, len(self.primitives)):
- if not self.primitives[i].equivalent(other.primitives[i], offset):
- return False
-
- # If we didn't find any differences, then they are the same
- return True
-
-class Outline(Primitive):
- """
- Outlines only exist as the rendering for a apeture macro outline.
- They don't exist outside of AMGroup objects
- """
-
- def __init__(self, primitives, **kwargs):
- super(Outline, self).__init__(**kwargs)
- self.primitives = primitives
- self._to_convert = ['primitives']
-
- if self.primitives[0].start != self.primitives[-1].end:
- raise ValueError('Outline must be closed')
-
- @property
- def flashed(self):
- return True
-
- @property
- def bounding_box(self):
- if self._bounding_box is None:
- xlims, ylims = zip(*[p.bounding_box for p in self.primitives])
- minx, maxx = zip(*xlims)
- miny, maxy = zip(*ylims)
- min_x = min(minx)
- max_x = max(maxx)
- min_y = min(miny)
- max_y = max(maxy)
- self._bounding_box = ((min_x, max_x), (min_y, max_y))
- return self._bounding_box
-
- def offset(self, x_offset=0, y_offset=0):
- self._changed()
- for p in self.primitives:
- p.offset(x_offset, y_offset)
-
- @property
- def vertices(self):
- if self._vertices is None:
- theta = math.radians(360/self.sides)
- vertices = [(self.position[0] + (math.cos(theta * side) * self.radius),
- self.position[1] + (math.sin(theta * side) * self.radius))
- for side in range(self.sides)]
- self._vertices = [(((x * self._cos_theta) - (y * self._sin_theta)),
- ((x * self._sin_theta) + (y * self._cos_theta)))
- for x, y in vertices]
- return self._vertices
-
- @property
- def width(self):
- bounding_box = self.bounding_box()
- return bounding_box[1][0] - bounding_box[0][0]
-
- def equivalent(self, other, offset):
- '''
- Is this the outline the same as the other, ignoring the position offset?
- '''
-
- # Quick check if it even makes sense to compare them
- if type(self) != type(other) or len(self.primitives) != len(other.primitives):
- return False
-
- for i in range(0, len(self.primitives)):
- if not self.primitives[i].equivalent(other.primitives[i], offset):
- return False
-
- return True
-
-class Region(Primitive):
- """
- """
-
- def __init__(self, primitives, **kwargs):
- super(Region, self).__init__(**kwargs)
- self.primitives = primitives
- self._to_convert = ['primitives']
-
- @property
- def flashed(self):
- return False
-
- @property
- def bounding_box(self):
- if self._bounding_box is None:
- xlims, ylims = zip(*[p.bounding_box for p in self.primitives])
- minx, maxx = zip(*xlims)
- miny, maxy = zip(*ylims)
- min_x = min(minx)
- max_x = max(maxx)
- min_y = min(miny)
- max_y = max(maxy)
- self._bounding_box = ((min_x, min_y), (max_x, max_y))
- return self._bounding_box
-
- def offset(self, x_offset=0, y_offset=0):
- self._changed()
- for p in self.primitives:
- p.offset(x_offset, y_offset)
-
-
-class Drill(Primitive):
- """ A drill hole
- """
- def __init__(self, position, diameter, **kwargs):
- super(Drill, self).__init__('dark', **kwargs)
- validate_coordinates(position)
- self._position = position
- self._diameter = diameter
- self._to_convert = ['position', 'diameter']
-
- @property
- def flashed(self):
- return False
-
- @property
- def position(self):
- return self._position
-
- @position.setter
- def position(self, value):
- self._changed()
- self._position = value
-
- @property
- def diameter(self):
- return self._diameter
-
- @diameter.setter
- def diameter(self, value):
- self._changed()
- self._diameter = value
-
- @property
- def radius(self):
- return self.diameter / 2.
-
- @property
- def bounding_box(self):
- if self._bounding_box is None:
- min_x = self.position[0] - self.radius
- max_x = self.position[0] + self.radius
- min_y = self.position[1] - self.radius
- max_y = self.position[1] + self.radius
- self._bounding_box = ((min_x, min_y), (max_x, max_y))
- return self._bounding_box
-
- def offset(self, x_offset=0, y_offset=0):
- self._changed()
- self.position = tuple(map(add, self.position, (x_offset, y_offset)))
-
- def __str__(self):
- return '<Drill %f %s (%f, %f)>' % (self.diameter, self.units, self.position[0], self.position[1])
-
-
-class Slot(Primitive):
- """ A drilled slot
- """
- def __init__(self, start, end, diameter, **kwargs):
- super(Slot, self).__init__('dark', **kwargs)
- validate_coordinates(start)
- validate_coordinates(end)
- self.start = start
- self.end = end
- self.diameter = diameter
- self._to_convert = ['start', 'end', 'diameter']
-
-
- @property
- def flashed(self):
- return False
-
- @property
- def bounding_box(self):
- if self._bounding_box is None:
- radius = self.diameter / 2.
- min_x = min(self.start[0], self.end[0]) - radius
- max_x = max(self.start[0], self.end[0]) + radius
- min_y = min(self.start[1], self.end[1]) - radius
- max_y = max(self.start[1], self.end[1]) + radius
- self._bounding_box = ((min_x, min_y), (max_x, max_y))
- return self._bounding_box
-
- def offset(self, x_offset=0, y_offset=0):
- self.start = tuple(map(add, self.start, (x_offset, y_offset)))
- self.end = tuple(map(add, self.end, (x_offset, y_offset)))
-
-
-class TestRecord(Primitive):
- """ Netlist Test record
- """
- __test__ = False # This is not a PyTest unit test.
-
- def __init__(self, position, net_name, layer, **kwargs):
- super(TestRecord, self).__init__(**kwargs)
- validate_coordinates(position)
- self.position = position
- self.net_name = net_name
- self.layer = layer
- self._to_convert = ['position']
-
-class RegionGroup:
- def __init__(self):
- self.outline = []
-
- def __bool__(self):
- return bool(self.outline)
-
- def append(self, primitive):
- self.outline.append(primitive)
-
diff --git a/gerbonara/gerber/rs274x.py b/gerbonara/gerber/rs274x.py
index 1b62cc4..98e8d53 100644
--- a/gerbonara/gerber/rs274x.py
+++ b/gerbonara/gerber/rs274x.py
@@ -34,9 +34,10 @@ from io import StringIO
from .gerber_statements import *
from .cam import CamFile, FileSettings
from .utils import sq_distance, rotate_point
-from aperture_macros.parse import ApertureMacro, GenericMacros
-import graphic_primitives as gp
-import graphic_objects as go
+from .aperture_macros.parse import ApertureMacro, GenericMacros
+from . import graphic_primitives as gp
+from . import graphic_objects as go
+from . import apertures
class GerberFile(CamFile):
@@ -75,9 +76,9 @@ class GerberFile(CamFile):
# dedup aperture macros
macros = { m.to_gerber(): m
- for m in [ GenericMacros.circle, GenericMacros.rect, GenericMacros.oblong, GenericMacros.polygon] }
+ for m in [ GenericMacros.circle, GenericMacros.rect, GenericMacros.obround, GenericMacros.polygon] }
for ap in new_apertures:
- if isinstance(aperture, ApertureMacroInstance):
+ if isinstance(aperture, apertures.ApertureMacroInstance):
macro_grb = ap.macro.to_gerber() # use native units to compare macros
if macro_grb in macros:
ap.macro = macros[macro_grb]
@@ -128,6 +129,7 @@ class GerberFile(CamFile):
yield FormatSpecStmt()
yield ImagePolarityStmt()
yield SingleQuadrantModeStmt()
+ yield LoadPolarityStmt(True)
if not drop_comments:
yield CommentStmt('File processed by Gerbonara. Original comments:')
@@ -139,14 +141,14 @@ class GerberFile(CamFile):
# and they are only a few bytes anyway.
yield ApertureMacroStmt(GenericMacros.circle)
yield ApertureMacroStmt(GenericMacros.rect)
- yield ApertureMacroStmt(GenericMacros.oblong)
+ yield ApertureMacroStmt(GenericMacros.obround)
yield ApertureMacroStmt(GenericMacros.polygon)
processed_macros = set()
aperture_map = {}
for number, aperture in enumerate(self.apertures, start=10):
- if isinstance(aperture, ApertureMacroInstance):
+ if isinstance(aperture, apertures.ApertureMacroInstance):
macro_grb = aperture.macro.to_gerber() # use native units to compare macros
if macro_grb not in processed_macros:
processed_macros.add(macro_grb)
@@ -170,8 +172,16 @@ class GerberFile(CamFile):
def save(self, filename):
with open(filename, 'w', encoding='utf-8') as f: # Encoding is specified as UTF-8 by spec.
- for stmt in self.generate_statements():
- print(stmt.to_gerber(self.settings), file=f)
+ f.write(self.to_gerber())
+
+ def to_gerber(self, settings=None):
+ # Use given settings, or use same settings as original file if not given, or use defaults if not imported from a
+ # file
+ if settings is None:
+ settings = self.import_settings.copy() or FileSettings()
+ settings.zeros = None
+ settings.number_format = (5,6)
+ return '\n'.join(stmt.to_gerber(settings) for stmt in self.generate_statements())
def offset(self, dx=0, dy=0):
# TODO round offset to file resolution
@@ -207,8 +217,8 @@ class GraphicsState:
polarity_dark : bool = True
image_polarity : str = 'positive' # IP image polarity; deprecated
point : tuple = None
- aperture : Aperture = None
- interpolation_mode : InterpolationModeStmt = None
+ aperture : apertures.Aperture = None
+ interpolation_mode : InterpolationModeStmt = LinearModeStmt
multi_quadrant_mode : bool = None # used only for syntax checking
aperture_mirroring = (False, False) # LM mirroring (x, y)
aperture_rotation = 0 # LR rotation in degrees, ccw
@@ -267,13 +277,13 @@ class GraphicsState:
a *= self.image_scale[0]
d *= self.image_scale[1]
- if ir == 90:
+ if self.image_rotation == 90:
a, b, c, d = 0, -d, a, 0
off_x, off_y = off_y, -off_x
- elif ir == 180:
+ elif self.image_rotation == 180:
a, b, c, d = -a, 0, 0, -d
off_x, off_y = -off_x, -off_y
- elif ir == 270:
+ elif self.image_rotation == 270:
a, b, c, d = 0, d, -a, 0
off_x, off_y = -off_y, off_x
@@ -283,11 +293,11 @@ class GraphicsState:
def map_coord(self, x, y, relative=False):
if self._mat is None:
self._update_xform()
- a, b, c, d = self.mat
+ a, b, c, d = self._mat
if not relative:
return (a*x + b*y + self.image_offset[0]), (c*x + d*y + self.image_offset[1])
- else
+ else:
# Apply mirroring, scale and rotation, but do not apply offset
return (a*x + b*y), (c*x + d*y)
@@ -305,14 +315,14 @@ class GraphicsState:
return self._create_arc(x, y, i, j, aperture)
def _create_line(self, x, y, aperture=True):
- old_point, self.point = self.point, self._map_coord(x, y)
- return go.Line(old_point, self.point, self.aperture if aperture else None, self.polarity_dark)
+ old_point, self.point = self.point, self.map_coord(x, y)
+ return go.Line(*old_point, *self.point, self.aperture if aperture else None, polarity_dark=self.polarity_dark)
def _create_arc(self, x, y, i, j, aperture=True):
- old_point, self.point = self.point, self._map_coord(x, y)
+ old_point, self.point = self.point, self.map_coord(x, y)
direction = 'ccw' if self.interpolation_mode == CircularCCWModeStmt else 'cw'
return go.Arc.from_coords(old_point, self.point, *self.map_coord(i, j, relative=True),
- flipped=(direction == 'cw'), self.aperture if aperture else None, self.polarity_dark)
+ flipped=(direction == 'cw'), aperture=(self.aperture if aperture else None), polarity_dark=self.polarity_dark)
# Helpers for gerber generation
def set_polarity(self, polarity_dark):
@@ -343,12 +353,12 @@ class GerberParser:
STATEMENT_REGEXES = {
'unit_mode': r"MO(?P<unit>(MM|IN))",
- 'interpolation_mode': r"(?P<code>G0?[123]|G74|G75)?",
+ 'interpolation_mode': r"(?P<code>G0?[123]|G74|G75)",
'coord': fr"(X(?P<x>{NUMBER}))?(Y(?P<y>{NUMBER}))?" \
fr"(I(?P<i>{NUMBER}))?(J(?P<j>{NUMBER}))?" \
- fr"(?P<operation>D0?[123])?\*",
- 'aperture': r"(G54|G55)?D(?P<number>\d+)\*",
- 'comment': r"G0?4(?P<comment>[^*]*)(\*)?",
+ fr"(?P<operation>D0?[123])$",
+ 'aperture': r"(G54|G55)?D(?P<number>\d+)",
+ 'comment': r"G0?4(?P<comment>[^*]*)",
'format_spec': r"FS(?P<zero>(L|T|D))?(?P<notation>(A|I))[NG0-9]*X(?P<x>[0-7][0-7])Y(?P<y>[0-7][0-7])[DM0-9]*",
'load_polarity': r"LP(?P<polarity>(D|C))",
# FIXME LM, LR, LS
@@ -363,12 +373,12 @@ class GerberParser:
'scale_factor': fr"SF(A(?P<a>{DECIMAL}))?(B(?P<b>{DECIMAL}))?",
'aperture_definition': fr"ADD(?P<number>\d+)(?P<shape>C|R|O|P|{NAME})[,]?(?P<modifiers>[^,%]*)",
'aperture_macro': fr"AM(?P<name>{NAME})\*(?P<macro>[^%]*)",
- 'region_start': r'G36\*',
- 'region_end': r'G37\*',
- 'old_unit':r'(?P<mode>G7[01])\*',
- 'old_notation': r'(?P<mode>G9[01])\*',
- 'eof': r"M0?[02]\*",
- 'ignored': r"(?P<stmt>M01)\*",
+ 'region_start': r'G36',
+ 'region_end': r'G37',
+ 'old_unit':r'(?P<mode>G7[01])',
+ 'old_notation': r'(?P<mode>G9[01])',
+ 'eof': r"M0?[02]",
+ 'ignored': r"(?P<stmt>M01)",
}
STATEMENT_REGEXES = { key: re.compile(value) for key, value in STATEMENT_REGEXES.items() }
@@ -382,6 +392,7 @@ class GerberParser:
self.file_settings = FileSettings()
self.graphics_state = GraphicsState()
self.aperture_map = {}
+ self.aperture_macros = {}
self.current_region = None
self.eof_found = False
self.multi_quadrant_mode = None # used only for syntax checking
@@ -400,44 +411,46 @@ class GerberParser:
for pos, c in enumerate(data):
if c == '%':
if extended_command:
- yield data[start:pos+1]
+ yield data[start:pos]
extended_command = False
- start = pos + 1
else:
extended_command = True
+ start = pos + 1
continue
elif extended_command:
continue
if c == '\r' or c == '\n' or c == '*':
- word_command = data[start:pos+1].strip()
+ word_command = data[start:pos].strip()
if word_command and word_command != '*':
yield word_command
- start = cur + 1
+ start = pos + 1
def parse(self, data):
for line in self._split_commands(data):
+ if not line.strip():
+ continue
+ line = line.rstrip('*').strip()
# We cannot assume input gerber to use well-formed statement delimiters. Thus, we may need to parse
# multiple statements from one line.
- while line:
- if line.strip() and self.eof_found:
- warnings.warn('Data found in gerber file after EOF.', SyntaxWarning)
- for name, le_regex in self.STATEMENT_REGEXES.items():
- if (match := le_regex.match(line)):
- getattr(self, f'_parse_{name}')(self, match.groupdict())
- line = line[match.end(0):]
- break
+ if line.strip() and self.eof_found:
+ warnings.warn('Data found in gerber file after EOF.', SyntaxWarning)
- else:
- if line[-1] == '*':
- warnings.warn(f'Unknown statement found: "{line}", ignoring.', SyntaxWarning)
- self.target.comments.append(f'Unknown statement found: "{line}", ignoring.')
- line = ''
+ for name, le_regex in self.STATEMENT_REGEXES.items():
+ if (match := le_regex.match(line)):
+ getattr(self, f'_parse_{name}')(match.groupdict())
+ line = line[match.end(0):]
+ break
+
+ else:
+ warnings.warn(f'Unknown statement found: "{line}", ignoring.', SyntaxWarning)
+ self.target.comments.append(f'Unknown statement found: "{line}", ignoring.')
self.target.apertures = list(self.aperture_map.values())
+ self.target.import_settings = self.file_settings
if not self.eof_found:
warnings.warn('File is missing mandatory M02 EOF marker. File may be truncated.', SyntaxWarning)
@@ -519,17 +532,17 @@ class GerberParser:
modifiers = [ float(val) for val in match['modifiers'].split(',') ]
aperture_classes = {
- 'C': ApertureCircle,
- 'R': ApertureRectangle,
- 'O': ApertureObround,
- 'P': AperturePolygon,
+ 'C': apertures.CircleAperture,
+ 'R': apertures.RectangleAperture,
+ 'O': apertures.ObroundAperture,
+ 'P': apertures.PolygonAperture,
}
if (kls := aperture_classes.get(match['shape'])):
new_aperture = kls(*modifiers)
- elif (macro := self.target.aperture_macros.get(match['shape'])):
- new_aperture = ApertureMacroInstance(match['shape'], macro, modifiers)
+ elif (macro := self.aperture_macros.get(match['shape'])):
+ new_aperture = apertures.ApertureMacroInstance(match['shape'], macro, modifiers)
else:
raise ValueError(f'Aperture shape "{match["shape"]}" is unknown')
@@ -537,11 +550,12 @@ class GerberParser:
self.aperture_map[int(match['number'])] = new_aperture
def _parse_aperture_macro(self, match):
- self.target.aperture_macros[match['name']] = ApertureMacro.parse(match['macro'])
+ self.aperture_macros[match['name']] = ApertureMacro.parse_macro(
+ match['name'], match['macro'], self.file_settings.units)
def _parse_format_spec(self, match):
# This is a common problem in Eagle files, so just suppress it
- self.file_settings.zero_suppression = {'L': 'leading', 'T': 'trailing'}.get(match['zero'], 'leading')
+ self.file_settings.zeros = {'L': 'leading', 'T': 'trailing'}.get(match['zero'], 'leading')
self.file_settings.notation = 'absolute' if match['notation'] == 'A' else 'incremental'
if match['x'] != match['y']:
@@ -604,7 +618,7 @@ class GerberParser:
def _parse_image_polarity(self, match):
warnings.warn('Deprecated IP (image polarity) statement found. This deprecated since rev. I4 (Oct 2013).',
DeprecationWarning)
- self.graphics_state.image_polarity = match['polarity']
+ self.graphics_state.image_polarity = dict(POS='positive', NEG='negative')[match['polarity']]
def _parse_image_rotation(self, match):
warnings.warn('Deprecated IR (image rotation) statement found. This deprecated since rev. I1 (Dec 2012).',
@@ -673,3 +687,11 @@ def _match_one_from_many(exprs, data):
return ({}, None)
+if __name__ == '__main__':
+ import argparse
+ parser = argparse.ArgumentParser()
+ parser.add_argument('testfile')
+ args = parser.parse_args()
+
+ print(GerberFile.open(args.testfile).to_gerber())
+
diff --git a/gerbonara/gerber/tests/conftest.py b/gerbonara/gerber/tests/conftest.py
new file mode 100644
index 0000000..0ad2555
--- /dev/null
+++ b/gerbonara/gerber/tests/conftest.py
@@ -0,0 +1,22 @@
+
+import pytest
+
+from .image_support import ImageDifference
+
+@pytest.hookimpl(tryfirst=True, hookwrapper=True)
+def pytest_assertrepr_compare(op, left, right):
+ if isinstance(left, ImageDifference) or isinstance(right, ImageDifference):
+ diff = left if isinstance(left, ImageDifference) else right
+ return [
+ f'Image difference assertion failed.',
+ f' Reference: {diff.ref_path}',
+ f' Actual: {diff.out_path}',
+ f' Calculated difference: {diff}', ]
+
+# store report in node object so tmp_gbr can determine if the test failed.
+@pytest.hookimpl(tryfirst=True, hookwrapper=True)
+def pytest_runtest_makereport(item, call):
+ outcome = yield
+ rep = outcome.get_result()
+ setattr(item, f'rep_{rep.when}', rep)
+
diff --git a/gerbonara/gerber/tests/image_support.py b/gerbonara/gerber/tests/image_support.py
new file mode 100644
index 0000000..ee8e6b9
--- /dev/null
+++ b/gerbonara/gerber/tests/image_support.py
@@ -0,0 +1,63 @@
+import subprocess
+from pathlib import Path
+import tempfile
+
+import numpy as np
+
+class ImageDifference(float):
+ def __init__(self, value, ref_path, out_path):
+ super().__init__(value)
+ self.ref_path, self.out_path = ref_path, out_path
+
+def run_cargo_cmd(cmd, args, **kwargs):
+ if cmd.upper() in os.environ:
+ return subprocess.run([os.environ[cmd.upper()], *args], **kwargs)
+
+ try:
+ return subprocess.run([cmd, *args], **kwargs)
+
+ except FileNotFoundError:
+ return subprocess.run([str(Path.home() / '.cargo' / 'bin' / cmd), *args], **kwargs)
+
+def svg_to_png(in_svg, out_png):
+ run_cargo_cmd('resvg', [in_svg, out_png], check=True, stdout=subprocess.DEVNULL)
+
+def gbr_to_svg(in_gbr, out_svg):
+ cmd = ['gerbv', '-x', 'svg',
+ '--border=0',
+ #f'--origin={origin_x:.6f}x{origin_y:.6f}', f'--window_inch={width:.6f}x{height:.6f}',
+ '--foreground=#ffffff',
+ '-o', str(out_svg), str(in_gbr)]
+ subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+
+def gerber_difference(reference, actual):
+ with tempfile.NamedTemporaryFile(suffix='.svg') as out_svg,\
+ tempfile.NamedTemporaryFile(suffix='.svg') as ref_svg:
+
+ gbr_to_svg(reference, ref_svg.name)
+ gbr_to_svg(actual, act_svg.name)
+
+ diff = svg_difference(ref_svg.name, act_svg.name)
+ diff.ref_path, diff.act_path = reference, actual
+ return diff
+
+def svg_difference(reference, actual):
+ with tempfile.NamedTemporaryFile(suffix='.png') as ref_png,\
+ tempfile.NamedTemporaryFile(suffix='.png') as act_png:
+
+ svg_to_png(reference, ref_png.name)
+ svg_to_png(actual, act_png.name)
+
+ diff = image_difference(ref_png.name, act_png.name)
+ diff.ref_path, diff.act_path = reference, actual
+ return diff
+
+def image_difference(reference, actual):
+ ref = np.array(Image.open(reference)).astype(float)
+ out = np.array(Image.open(actual)).astype(float)
+
+ ref, out = ref.mean(axis=2), out.mean(axis=2) # convert to grayscale
+ delta = np.abs(out - ref).astype(float) / 255
+ return ImageDifference(delta.mean(), ref, out)
+
+
diff --git a/gerbonara/gerber/tests/panelize/test_rs274x.py b/gerbonara/gerber/tests/panelize/test_rs274x.py
deleted file mode 100644
index 73f3172..0000000
--- a/gerbonara/gerber/tests/panelize/test_rs274x.py
+++ /dev/null
@@ -1,70 +0,0 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-
-# Copyright 2019 Hiroshi Murayama <opiopan@gmail.com>
-
-import os
-import tempfile
-from pathlib import Path
-from contextlib import contextmanager
-import unittest
-from ...rs274x import read
-
-class TestRs274x(unittest.TestCase):
- @classmethod
- def setUpClass(cls):
- here = Path(__file__).parent
- cls.EXPECTSDIR = here / 'expects'
- cls.METRIC_FILE = here / 'data' / 'ref_gerber_metric.gtl'
- cls.INCH_FILE = here / 'data' / 'ref_gerber_inch.gtl'
- cls.SQ_FILE = here / 'data' / 'ref_gerber_single_quadrant.gtl'
-
- @contextmanager
- def _check_result(self, reference_fn):
- with tempfile.NamedTemporaryFile('rb') as tmp_out:
- yield tmp_out.name
-
- actual = tmp_out.read()
- expected = (self.EXPECTSDIR / reference_fn).read_bytes()
-
- print('==== ACTUAL ===')
- print(actual.decode())
- print()
- print()
- print('==== EXPECTED ===')
- print(expected.decode())
- print()
- print()
- self.assertEqual(actual, expected)
-
- def test_save(self):
- with self._check_result('RS2724x_save.gtl') as outfile:
- gerber = read(self.METRIC_FILE)
- gerber.write(outfile)
-
- def test_to_inch(self):
- with self._check_result('RS2724x_to_inch.gtl') as outfile:
- gerber = read(self.METRIC_FILE)
- gerber.to_inch()
- gerber.format = (2,5)
- gerber.write(outfile)
-
- def test_to_metric(self):
- with self._check_result('RS2724x_to_metric.gtl') as outfile:
- gerber = read(self.INCH_FILE)
- gerber.to_metric()
- gerber.format = (3, 4)
- gerber.write(outfile)
-
- def test_offset(self):
- with self._check_result('RS2724x_offset.gtl') as outfile:
- gerber = read(self.METRIC_FILE)
- gerber.offset(11, 5)
- gerber.write(outfile)
-
- def test_rotate(self):
- with self._check_result('RS2724x_rotate.gtl') as outfile:
- gerber = read(self.METRIC_FILE)
- gerber.rotate(20, (10,10))
- gerber.write(outfile)
-
diff --git a/gerbonara/gerber/tests/resources/example_outline_with_arcs.gbr b/gerbonara/gerber/tests/resources/example_outline_with_arcs.gbr
new file mode 100644
index 0000000..62c5693
--- /dev/null
+++ b/gerbonara/gerber/tests/resources/example_outline_with_arcs.gbr
@@ -0,0 +1,33 @@
+G04 Layer_Color=16711935*
+%FSLAX25Y25*%
+%MOIN*%
+G70*
+G01*
+G75*
+%ADD26C,0.01000*%
+D26*
+X354331Y177165D02*
+G03*
+X334646Y196850I-19685J0D01*
+G01*
+Y0D02*
+G03*
+X354331Y19685I0J19685D01*
+G01*
+X0D02*
+G03*
+X19685Y0I19685J0D01*
+G01*
+Y196850D02*
+G03*
+X0Y177165I0J-19685D01*
+G01*
+X354331Y19685D02*
+Y177165D01*
+X19685Y196850D02*
+X334646D01*
+X19685Y0D02*
+X334646D01*
+X0Y19685D02*
+Y177165D01*
+M02*
diff --git a/gerbonara/gerber/tests/test_rs274x.py b/gerbonara/gerber/tests/test_rs274x.py
index e430f36..beaea11 100644
--- a/gerbonara/gerber/tests/test_rs274x.py
+++ b/gerbonara/gerber/tests/test_rs274x.py
@@ -4,52 +4,87 @@
# Author: Hamilton Kibbe <ham@hamiltonkib.be>
import os
import pytest
+import functools
+import tempfile
+import shutil
+from argparse import Namespace
+from pathlib import Path
+
+from ..rs274x import GerberFile
+
+from .image_support import gerber_difference
+
+
+fail_dir = Path('gerbonara_test_failures')
+@pytest.fixture(scope='session', autouse=True)
+def clear_failure_dir(request):
+ if fail_dir.is_dir():
+ shutil.rmtree(fail_dir)
+
+@pytest.fixture
+def tmp_gbr(request):
+ with tempfile.NamedTemporaryFile(suffix='.gbr') as tmp_out_gbr:
+
+ yield Path(tmp_out_gbr.name)
+
+ if request.node.rep_call.failed:
+ module, _, test_name = request.node.nodeid.rpartition('::')
+ _test, _, test_name = test_name.partition('_')
+ test_name = test_name.replace('[', '_').replace(']', '_')
+ fail_dir.mkdir(exist_ok=True)
+ perm_path = fail_dir / f'failure_{test_name}.gbr'
+ shutil.copy(tmp_out_gbr.name, perm_path)
+ print('Failing output saved to {perm_path}')
+
+@pytest.mark.parametrize('reference', [ l.strip() for l in '''
+board_outline.GKO
+example_outline_with_arcs.gbr
+'''
+#example_two_square_boxes.gbr
+#example_coincident_hole.gbr
+#example_cutin.gbr
+#example_cutin_multiple.gbr
+#example_flash_circle.gbr
+#example_flash_obround.gbr
+#example_flash_polygon.gbr
+#example_flash_rectangle.gbr
+#example_fully_coincident.gbr
+#example_guess_by_content.g0
+#example_holes_dont_clear.gbr
+#example_level_holes.gbr
+#example_not_overlapping_contour.gbr
+#example_not_overlapping_touching.gbr
+#example_overlapping_contour.gbr
+#example_overlapping_touching.gbr
+#example_simple_contour.gbr
+#example_single_contour_1.gbr
+#example_single_contour_2.gbr
+#example_single_contour_3.gbr
+#example_am_exposure_modifier.gbr
+#bottom_copper.GBL
+#bottom_mask.GBS
+#bottom_silk.GBO
+#eagle_files/copper_bottom_l4.gbr
+#eagle_files/copper_inner_l2.gbr
+#eagle_files/copper_inner_l3.gbr
+#eagle_files/copper_top_l1.gbr
+#eagle_files/profile.gbr
+#eagle_files/silkscreen_bottom.gbr
+#eagle_files/silkscreen_top.gbr
+#eagle_files/soldermask_bottom.gbr
+#eagle_files/soldermask_top.gbr
+#eagle_files/solderpaste_bottom.gbr
+#eagle_files/solderpaste_top.gbr
+#multiline_read.ger
+#test_fine_lines_x.gbr
+#test_fine_lines_y.gbr
+#top_copper.GTL
+#top_mask.GTS
+#top_silk.GTO
+'''
+'''.splitlines() if l ])
+def test_round_trip(tmp_gbr, reference):
+ ref = Path(__file__).parent / 'resources' / reference
+ GerberFile.open(ref).save(tmp_gbr)
+ assert gerber_difference(ref, tmp_gbr) < 0.02
-from ..rs274x import read, GerberFile
-
-
-TOP_COPPER_FILE = os.path.join(os.path.dirname(__file__), "resources/top_copper.GTL")
-
-MULTILINE_READ_FILE = os.path.join(
- os.path.dirname(__file__), "resources/multiline_read.ger"
-)
-
-
-def test_read():
- top_copper = read(TOP_COPPER_FILE)
- assert isinstance(top_copper, GerberFile)
-
-
-def test_multiline_read():
- multiline = read(MULTILINE_READ_FILE)
- assert isinstance(multiline, GerberFile)
- assert 11 == len(multiline.statements)
-
-
-def test_comments_parameter():
- top_copper = read(TOP_COPPER_FILE)
- assert top_copper.comments[0] == "This is a comment,:"
-
-
-def test_size_parameter():
- top_copper = read(TOP_COPPER_FILE)
- size = top_copper.size
- pytest.approx(size[0], 2.256900, 6)
- pytest.approx(size[1], 1.500000, 6)
-
-
-def test_conversion():
- top_copper = read(TOP_COPPER_FILE)
- assert top_copper.units == "inch"
- top_copper_inch = read(TOP_COPPER_FILE)
- top_copper.to_metric()
- for statement in top_copper_inch.statements:
- statement.to_metric()
- for primitive in top_copper_inch.primitives:
- primitive.to_metric()
- assert top_copper.units == "metric"
- for i, m in zip(top_copper.statements, top_copper_inch.statements):
- assert i == m
-
- for i, m in zip(top_copper.primitives, top_copper_inch.primitives):
- assert i == m