summaryrefslogtreecommitdiff
path: root/gerbonara
diff options
context:
space:
mode:
Diffstat (limited to 'gerbonara')
-rw-r--r--gerbonara/gerber/am_read.py4
-rw-r--r--gerbonara/gerber/cam.py199
-rw-r--r--gerbonara/gerber/gerber_statements.py890
-rw-r--r--gerbonara/gerber/panelize/__init__.py7
-rw-r--r--gerbonara/gerber/panelize/utility.py8
-rw-r--r--gerbonara/gerber/render/__init__.py31
-rw-r--r--gerbonara/gerber/render/cairo_backend.py640
-rw-r--r--gerbonara/gerber/rs274x.py546
-rw-r--r--gerbonara/gerber/tests/panelize/data/ref_gerber_inch.gtl1
-rw-r--r--gerbonara/gerber/tests/panelize/data/ref_gerber_metric.gtl1
-rw-r--r--gerbonara/gerber/tests/panelize/data/ref_gerber_single_quadrant.gtl40
-rw-r--r--gerbonara/gerber/tests/panelize/expects/RS2724x_offset.gtl1
-rw-r--r--gerbonara/gerber/tests/panelize/expects/RS2724x_rotate.gtl1
-rw-r--r--gerbonara/gerber/tests/panelize/expects/RS2724x_save.gtl1
-rw-r--r--gerbonara/gerber/tests/panelize/expects/RS2724x_single_quadrant.gtl35
-rw-r--r--gerbonara/gerber/tests/panelize/expects/RS2724x_to_inch.gtl1
-rw-r--r--gerbonara/gerber/tests/panelize/expects/RS2724x_to_metric.gtl1
-rw-r--r--gerbonara/gerber/tests/panelize/test_am_expression.py13
-rw-r--r--gerbonara/gerber/tests/panelize/test_rs274x.py5
-rw-r--r--gerbonara/gerber/tests/test_am_statements.py395
-rw-r--r--gerbonara/gerber/tests/test_cairo_backend.py382
-rw-r--r--gerbonara/gerber/tests/test_gerber_statements.py64
-rw-r--r--gerbonara/gerber/utils.py20
23 files changed, 448 insertions, 2838 deletions
diff --git a/gerbonara/gerber/am_read.py b/gerbonara/gerber/am_read.py
index 906aaa9..c50bd01 100644
--- a/gerbonara/gerber/am_read.py
+++ b/gerbonara/gerber/am_read.py
@@ -19,7 +19,6 @@
"""
from .am_opcode import OpCode
-from .am_primitive import eval_macro
import string
@@ -251,5 +250,6 @@ if __name__ == '__main__':
print_instructions(instructions)
print("eval:")
- for primitive in eval_macro(instructions):
+ from .am_primitive import eval_macro
+ for primitive in eval_macro(instructions, 'mm'):
print(primitive)
diff --git a/gerbonara/gerber/cam.py b/gerbonara/gerber/cam.py
index 4f20283..5da8600 100644
--- a/gerbonara/gerber/cam.py
+++ b/gerbonara/gerber/cam.py
@@ -14,164 +14,50 @@
# 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.
-"""
-CAM File
-============
-**AM file classes**
-This module provides common base classes for Excellon/Gerber CNC files
-"""
-
-
-class FileSettings(object):
- """ CAM File Settings
-
- Provides a common representation of gerber/excellon file settings
-
- Parameters
- ----------
- notation: string
- notation format. either 'absolute' or 'incremental'
-
- units : string
- Measurement units. 'inch' or 'metric'
-
- zero_suppression: string
- 'leading' to suppress leading zeros, 'trailing' to suppress trailing zeros.
- This is the convention used in Gerber files.
-
- format : tuple (int, int)
- Decimal format
-
- zeros : string
- 'leading' to include leading zeros, 'trailing to include trailing zeros.
- This is the convention used in Excellon files
-
- Notes
- -----
- Either `zeros` or `zero_suppression` should be specified, there is no need to
- specify both. `zero_suppression` will take on the opposite value of `zeros`
- and vice versa
- """
-
- def __init__(self, notation='absolute', units='inch',
- zero_suppression=None, format=(2, 5), zeros=None,
- angle_units='degrees'):
- if notation not in ['absolute', 'incremental']:
- raise ValueError('Notation must be either absolute or incremental')
- self.notation = notation
-
- if units not in ['inch', 'metric']:
- raise ValueError('Units must be either inch or metric')
- self.units = units
-
- if zero_suppression is None and zeros is None:
- self.zero_suppression = 'trailing'
-
- elif zero_suppression == zeros:
- raise ValueError('Zeros and Zero Suppression must be different. \
- Best practice is to specify only one.')
-
- elif zero_suppression is not None:
- if zero_suppression not in ['leading', 'trailing']:
- # This is a common problem in Eagle files, so just suppress it
- self.zero_suppression = 'leading'
- else:
- self.zero_suppression = zero_suppression
-
- elif zeros is not None:
- if zeros not in ['leading', 'trailing']:
- raise ValueError('Zeros must be either leading or trailling')
- self.zeros = zeros
-
- if len(format) != 2:
- raise ValueError('Format must be a tuple(n=2) of integers')
- self.format = format
-
- if angle_units not in ('degrees', 'radians'):
- raise ValueError('Angle units may be degrees or radians')
- self.angle_units = angle_units
-
- @property
- def zero_suppression(self):
- return self._zero_suppression
-
- @zero_suppression.setter
- def zero_suppression(self, value):
- self._zero_suppression = value
- self._zeros = 'leading' if value == 'trailing' else 'trailing'
-
- @property
- def zeros(self):
- return self._zeros
-
- @zeros.setter
- def zeros(self, value):
-
- self._zeros = value
- self._zero_suppression = 'leading' if value == 'trailing' else 'trailing'
-
- def __getitem__(self, key):
- if key == 'notation':
- return self.notation
- elif key == 'units':
- return self.units
- elif key == 'zero_suppression':
- return self.zero_suppression
- elif key == 'zeros':
- return self.zeros
- elif key == 'format':
- return self.format
- elif key == 'angle_units':
- return self.angle_units
- else:
- raise KeyError()
-
- def __setitem__(self, key, value):
- if key == 'notation':
- if value not in ['absolute', 'incremental']:
- raise ValueError('Notation must be either \
- absolute or incremental')
- self.notation = value
- elif key == 'units':
- if value not in ['inch', 'metric']:
- raise ValueError('Units must be either inch or metric')
- self.units = value
-
- elif key == 'zero_suppression':
- if value not in ['leading', 'trailing']:
- raise ValueError('Zero suppression must be either leading or \
- trailling')
- self.zero_suppression = value
-
- elif key == 'zeros':
- if value not in ['leading', 'trailing']:
- raise ValueError('Zeros must be either leading or trailling')
- self.zeros = value
-
- elif key == 'format':
- if len(value) != 2:
- raise ValueError('Format must be a tuple(n=2) of integers')
- self.format = value
-
- elif key == 'angle_units':
- if value not in ('degrees', 'radians'):
- raise ValueError('Angle units may be degrees or radians')
- self.angle_units = value
-
- else:
- raise KeyError('%s is not a valid key' % key)
-
- def __eq__(self, other):
- return (self.notation == other.notation and
- self.units == other.units and
- self.zero_suppression == other.zero_suppression and
- self.format == other.format and
- self.angle_units == other.angle_units)
+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)
+ scale_factor : tuple = (1.0, 1.0) # For deprecated SF statement
+ notation : str = 'absolute'
+ units : str = 'inch'
+ angle_units : str = 'degrees'
+ zeros : bool = None
+ number_format : tuple = (2, 5)
+
+ # 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 == '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']:
+ raise ValueError('Notation must be either "absolute" or "incremental"')
+ elif name == 'angle_units' and value not in ('degrees', 'radians'):
+ 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')
+
+ super().__setattr__(name, value)
def __str__(self):
- return ('<Settings: %s %s %s %s %s>' %
- (self.units, self.notation, self.zero_suppression, self.format, self.angle_units))
+ return f'<File settings: units={self.units}/{self.angle_units} notation={self.notation} zeros={self.zeros} number_format={self.number_format}>'
class CamFile(object):
@@ -202,7 +88,7 @@ class CamFile(object):
File notation setting. May be either 'absolute' or 'incremental'
units : string
- File units setting. May be 'inch' or 'metric'
+ File units setting. May be 'inch' or 'mm'
zero_suppression : string
File zero-suppression setting. May be either 'leading' or 'trailling'
@@ -226,6 +112,7 @@ class CamFile(object):
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
diff --git a/gerbonara/gerber/gerber_statements.py b/gerbonara/gerber/gerber_statements.py
index 9296d1b..d2f67c1 100644
--- a/gerbonara/gerber/gerber_statements.py
+++ b/gerbonara/gerber/gerber_statements.py
@@ -29,7 +29,7 @@ from .am_primitive import eval_macro
from .primitives import AMGroup
-class Statement(object):
+class Statement:
""" Gerber statement Base class
The statement class provides a type attribute.
@@ -45,10 +45,6 @@ class Statement(object):
String identifying the statement type.
"""
- def __init__(self, stype, units='inch'):
- self.type = stype
- self.units = units
-
def __str__(self):
s = "<{0} ".format(self.__class__.__name__)
@@ -58,12 +54,6 @@ class Statement(object):
s = s.rstrip() + ">"
return s
- def to_inch(self):
- self.units = 'inch'
-
- def to_metric(self):
- self.units = 'metric'
-
def offset(self, x_offset=0, y_offset=0):
pass
@@ -72,201 +62,51 @@ class Statement(object):
class ParamStmt(Statement):
- """ Gerber parameter statement Base class
-
- The parameter statement class provides a parameter type attribute.
-
- Parameters
- ----------
- param : string
- two-character code identifying the parameter statement type.
-
- Attributes
- ----------
- param : string
- Parameter type code
- """
-
- def __init__(self, param):
- Statement.__init__(self, "PARAM")
- self.param = param
-
-
-class FSParamStmt(ParamStmt):
- """ FS - Gerber Format Specification Statement
- """
+ pass
- @classmethod
- def from_settings(cls, settings):
+class FormatSpecStmt(ParamStmt):
+ """ FS - Gerber Format Specification Statement """
+ code = 'FS'
- return cls('FS', settings.zero_suppression, settings.notation, settings.format)
+ def to_gerber(self, settings):
+ zeros = 'L' if settings.zero_suppression == 'leading' else 'T'
+ notation = 'A' if settings.notation == 'absolute' else 'I'
+ fmt = settings.number_format
+ number_format = str(settings.number_format[0]) + str(settings.number_format[1])
- @classmethod
- def from_dict(cls, stmt_dict):
- """
- """
- param = stmt_dict.get('param')
-
- if stmt_dict.get('zero') == 'L':
- zeros = 'leading'
- elif stmt_dict.get('zero') == 'T':
- zeros = 'trailing'
- else:
- zeros = 'none'
-
- notation = 'absolute' if stmt_dict.get('notation') == 'A' else 'incremental'
- fmt = tuple(map(int, stmt_dict.get('x')))
- return cls(param, zeros, notation, fmt)
-
- def __init__(self, param, zero_suppression='leading',
- notation='absolute', format=(2, 4)):
- """ Initialize FSParamStmt class
-
- .. note::
- The FS command specifies the format of the coordinate data. It
- must only be used once at the beginning of a file. It must be
- specified before the first use of coordinate data.
-
- Parameters
- ----------
- param : string
- Parameter.
-
- zero_suppression : string
- Zero-suppression mode. May be either 'leading', 'trailing' or 'none' (all zeros are present)
-
- notation : string
- Notation mode. May be either 'absolute' or 'incremental'
-
- format : tuple (int, int)
- Gerber precision format expressed as a tuple containing:
- (number of integer-part digits, number of decimal-part digits)
-
- Returns
- -------
- ParamStmt : FSParamStmt
- Initialized FSParamStmt class.
-
- """
- ParamStmt.__init__(self, param)
- self.zero_suppression = zero_suppression
- self.notation = notation
- self.format = format
-
- def to_gerber(self, settings=None):
- if settings:
- zero_suppression = 'L' if settings.zero_suppression == 'leading' else 'T'
- notation = 'A' if settings.notation == 'absolute' else 'I'
- fmt = ''.join(map(str, settings.format))
- else:
- zero_suppression = 'L' if self.zero_suppression == 'leading' else 'T'
- notation = 'A' if self.notation == 'absolute' else 'I'
- fmt = ''.join(map(str, self.format))
-
- return '%FS{0}{1}X{2}Y{3}*%'.format(zero_suppression, notation, fmt, fmt)
+ return f'%FS{zeros}{notation}X{number_format}Y{number_format}*%'
def __str__(self):
- return ('<Format Spec: %d:%d %s zero suppression %s notation>' %
- (self.format[0], self.format[1], self.zero_suppression, self.notation))
+ return '<FS Format Specification>'
-class MOParamStmt(ParamStmt):
- """ MO - Gerber Mode (measurement units) Statement.
- """
+class UnitStmt(ParamStmt):
+ """ MO - Coordinate unit mode statement """
- @classmethod
- def from_units(cls, units):
- return cls(None, units)
-
- @classmethod
- def from_dict(cls, stmt_dict):
- param = stmt_dict.get('param')
- if stmt_dict.get('mo') is None:
- mo = None
- elif stmt_dict.get('mo').lower() not in ('in', 'mm'):
- raise ValueError('Mode may be mm or in')
- elif stmt_dict.get('mo').lower() == 'in':
- mo = 'inch'
- else:
- mo = 'metric'
- return cls(param, mo)
-
- def __init__(self, param, mo):
- """ Initialize MOParamStmt class
-
- Parameters
- ----------
- param : string
- Parameter.
-
- mo : string
- Measurement units. May be either 'inch' or 'metric'
-
- Returns
- -------
- ParamStmt : MOParamStmt
- Initialized MOParamStmt class.
-
- """
- ParamStmt.__init__(self, param)
- self.mode = mo
-
- def to_gerber(self, settings=None):
- mode = 'MM' if self.mode == 'metric' else 'IN'
- return '%MO{0}*%'.format(mode)
-
- def to_inch(self):
- self.mode = 'inch'
-
- def to_metric(self):
- self.mode = 'metric'
+ def to_gerber(self, settings):
+ return '%MOMM*%' if settings.units == 'mm' else '%MOIN*%'
def __str__(self):
- mode_str = 'millimeters' if self.mode == 'metric' else 'inches'
- return ('<Mode: %s>' % mode_str)
-
-
-class LPParamStmt(ParamStmt):
- """ LP - Gerber Level Polarity statement
- """
-
- @classmethod
- def from_dict(cls, stmt_dict):
- param = stmt_dict['param']
- lp = 'clear' if stmt_dict.get('lp') == 'C' else 'dark'
- return cls(param, lp)
+ return ('<MO Coordinate unit mode statement>' % mode_str)
- def __init__(self, param, lp):
- """ Initialize LPParamStmt class
- Parameters
- ----------
- param : string
- Parameter
+class LoadPolarityStmt(ParamStmt):
+ """ LP - Gerber Load Polarity statement """
- lp : string
- Level polarity. May be either 'clear' or 'dark'
-
- Returns
- -------
- ParamStmt : LPParamStmt
- Initialized LPParamStmt class.
-
- """
- ParamStmt.__init__(self, param)
- self.lp = lp
+ def __init__(self, dark):
+ self.dark = dark
def to_gerber(self, settings=None):
- lp = 'C' if self.lp == 'clear' else 'D'
- return '%LP{0}*%'.format(lp)
+ lp = 'D' if self.dark else 'C'
+ return f'%LP{lp}*%'
def __str__(self):
- return '<Level Polarity: %s>' % self.lp
+ lp = 'dark' if self.dark else 'clear'
+ return f'<LP Level Polarity: {lp}>'
class ADParamStmt(ParamStmt):
- """ AD - Gerber Aperture Definition Statement
- """
+ """ AD - Aperture Definition Statement """
@classmethod
def rect(cls, dcode, width, height, hole_diameter=None, hole_width=None, hole_height=None):
@@ -418,10 +258,7 @@ class AMParamStmt(ParamStmt):
self.name = name
self.macro = macro
self.units = units
- self.primitives = list(eval_macro(self.instructions))
-
- def read(self, macro):
- return read_macro(macro)
+ self.primitives = list(eval_macro(read_macro(macro), units))
@classmethod
def circle(cls, name, units):
@@ -457,20 +294,8 @@ class AMParamStmt(ParamStmt):
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_inch(self):
- if self.units == 'metric':
- self.units = 'inch'
- for primitive in self.primitives:
- primitive.to_inch()
-
- def to_metric(self):
- if self.units == 'inch':
- self.units = 'metric'
- for primitive in self.primitives:
- primitive.to_metric()
-
- def to_gerber(self, settings=None):
- primitive_defs = '\n'.join(primitive.to_gerber() for primitive in self.primitives)
+ 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):
@@ -478,444 +303,81 @@ class AMParamStmt(ParamStmt):
primitive_def.rotate(angle, center)
def __str__(self):
- return '<Aperture Macro %s: %s>' % (self.name, self.macro)
-
-
-class ASParamStmt(ParamStmt):
- """ AS - Axis Select. (Deprecated) """
- @classmethod
- def from_dict(cls, stmt_dict):
- param = stmt_dict.get('param')
- mode = stmt_dict.get('mode')
- return cls(param, mode)
+ return '<AM Aperture Macro %s: %s>' % (self.name, self.macro)
- def __init__(self, param, mode):
- """ Initialize ASParamStmt class
- Parameters
- ----------
- param : string
- Parameter string.
+class AxisSelectionStmt(ParamStmt):
+ """ AS - Axis Selection Statement. (Deprecated) """
- mode : string
- Axis select. May be either 'AXBY' or 'AYBX'
-
- Returns
- -------
- ParamStmt : ASParamStmt
- Initialized ASParamStmt class.
-
- """
- ParamStmt.__init__(self, param)
- self.mode = mode
-
- def to_gerber(self, settings=None):
- return '%AS{0}*%'.format(self.mode)
+ def to_gerber(self, settings):
+ return f'%AS{settings.output_axes}*%'
def __str__(self):
- return ('<Axis Select: %s>' % self.mode)
-
-
-class INParamStmt(ParamStmt):
- """ IN - Image Name Statement (Deprecated)
- """
- @classmethod
- def from_dict(cls, stmt_dict):
- return cls(**stmt_dict)
-
- def __init__(self, param, name):
- """ Initialize INParamStmt class
-
- Parameters
- ----------
- param : string
- Parameter code
+ return '<AS Axis Select>'
- name : string
- Image name
+class ImagePolarityStmt(ParamStmt):
+ """ IP - Image Polarity Statement. (Deprecated) """
- Returns
- -------
- ParamStmt : INParamStmt
- Initialized INParamStmt class.
-
- """
- ParamStmt.__init__(self, param)
- self.name = name
-
- def to_gerber(self, settings=None):
- return '%IN{0}*%'.format(self.name)
+ def to_gerber(self, settings):
+ ip = 'POS' if settings.image_polarity == 'positive' else 'NEG'
+ return f'%IP{ip}*%'
def __str__(self):
- return '<Image Name: %s>' % self.name
-
-
-class IPParamStmt(ParamStmt):
- """ IP - Gerber Image Polarity Statement. (Deprecated)
- """
- @classmethod
- def from_dict(cls, stmt_dict):
- param = stmt_dict.get('param')
- ip = 'positive' if stmt_dict.get('ip') == 'POS' else 'negative'
- return cls(param, ip)
+ return '<IP Image Polarity>'
- def __init__(self, param, ip):
- """ Initialize IPParamStmt class
- Parameters
- ----------
- param : string
- Parameter string.
-
- ip : string
- Image polarity. May be either'positive' or 'negative'
-
- Returns
- -------
- ParamStmt : IPParamStmt
- Initialized IPParamStmt class.
-
- """
- ParamStmt.__init__(self, param)
- self.ip = ip
+class ImageRotationStmt(ParamStmt):
+ """ IR - Image Rotation Statement. (Deprecated) """
- def to_gerber(self, settings=None):
- ip = 'POS' if self.ip == 'positive' else 'NEG'
- return '%IP{0}*%'.format(ip)
+ def to_gerber(self, settings):
+ return f'%IR{settings.image_rotation}*%'
def __str__(self):
- return ('<Image Polarity: %s>' % self.ip)
-
-
-class IRParamStmt(ParamStmt):
- """ IR - Image Rotation Param (Deprecated)
- """
- @classmethod
- def from_dict(cls, stmt_dict):
- angle = int(stmt_dict['angle'])
- return cls(stmt_dict['param'], angle)
-
- def __init__(self, param, angle):
- """ Initialize IRParamStmt class
-
- Parameters
- ----------
- param : string
- Parameter code
-
- angle : int
- Image angle
+ return '<IR Image Rotation>'
- Returns
- -------
- ParamStmt : IRParamStmt
- Initialized IRParamStmt class.
-
- """
- ParamStmt.__init__(self, param)
- self.angle = angle
+class MirrorImageStmt(ParamStmt):
+ """ MI - Mirror Image Statement. (Deprecated) """
- def to_gerber(self, settings=None):
- return '%IR{0}*%'.format(self.angle)
+ def to_gerber(self, settings):
+ return f'%SFA{int(bool(settings.mirror_image[0]))}B{int(bool(settings.mirror_image[1]))}*%'
def __str__(self):
- return '<Image Angle: %s>' % self.angle
+ return '<MI Mirror Image>'
+class OffsetStmt(ParamStmt):
+ """ OF - File Offset Statement. (Deprecated) """
-class MIParamStmt(ParamStmt):
- """ MI - Image Mirror Param (Deprecated)
- """
- @classmethod
- def from_dict(cls, stmt_dict):
- param = stmt_dict.get('param')
- a = int(stmt_dict.get('a', 0))
- b = int(stmt_dict.get('b', 0))
- return cls(param, a, b)
-
- def __init__(self, param, a, b):
- """ Initialize MIParamStmt class
-
- Parameters
- ----------
- param : string
- Parameter code
-
- a : int
- Mirror for A output devices axis (0=disabled, 1=mirrored)
-
- b : int
- Mirror for B output devices axis (0=disabled, 1=mirrored)
-
- Returns
- -------
- ParamStmt : MIParamStmt
- Initialized MIParamStmt class.
-
- """
- ParamStmt.__init__(self, param)
- self.a = a
- self.b = b
-
- def to_gerber(self, settings=None):
- ret = "%MI"
- if self.a is not None:
- ret += "A{0}".format(self.a)
- if self.b is not None:
- ret += "B{0}".format(self.b)
- ret += "*%"
- return ret
-
- def __str__(self):
- return '<Image Mirror: A=%d B=%d>' % (self.a, self.b)
-
-
-class OFParamStmt(ParamStmt):
- """ OF - Gerber Offset statement (Deprecated)
- """
-
- @classmethod
- def from_dict(cls, stmt_dict):
- param = stmt_dict.get('param')
- a = float(stmt_dict.get('a', 0))
- b = float(stmt_dict.get('b', 0))
- return cls(param, a, b)
-
- def __init__(self, param, a, b):
- """ Initialize OFParamStmt class
-
- Parameters
- ----------
- param : string
- Parameter
-
- a : float
- Offset along the output device A axis
-
- b : float
- Offset along the output device B axis
-
- Returns
- -------
- ParamStmt : OFParamStmt
- Initialized OFParamStmt class.
-
- """
- ParamStmt.__init__(self, param)
- self.a = a
- self.b = b
+ def __init__(self, a, b):
+ self.a, self.b = a, b
def to_gerber(self, settings=None):
- ret = '%OF'
- if self.a is not None:
- ret += 'A' + decimal_string(self.a, precision=5)
- if self.b is not None:
- ret += 'B' + decimal_string(self.b, precision=5)
- return ret + '*%'
-
- def to_inch(self):
- if self.units == 'metric':
- self.units = 'inch'
- if self.a is not None:
- self.a = inch(self.a)
- if self.b is not None:
- self.b = inch(self.b)
-
- def to_metric(self):
- if self.units == 'inch':
- self.units = 'metric'
- if self.a is not None:
- self.a = metric(self.a)
- if self.b is not None:
- self.b = metric(self.b)
-
- def offset(self, x_offset=0, y_offset=0):
- if self.a is not None:
- self.a += x_offset
- if self.b is not None:
- self.b += y_offset
+ # FIXME unit conversion
+ return f'%OFA{decimal_string(self.a, precision=5)}B{decimal_string(self.b, precision=5)}*%'
def __str__(self):
- offset_str = ''
- if self.a is not None:
- offset_str += ('X: %f ' % self.a)
- if self.b is not None:
- offset_str += ('Y: %f ' % self.b)
- return ('<Offset: %s>' % offset_str)
+ return f'<OF Offset a={self.a} b={self.b}>'
class SFParamStmt(ParamStmt):
- """ SF - Scale Factor Param (Deprecated)
- """
-
- @classmethod
- def from_dict(cls, stmt_dict):
- param = stmt_dict.get('param')
- a = float(stmt_dict.get('a', 1))
- b = float(stmt_dict.get('b', 1))
- return cls(param, a, b)
-
- def __init__(self, param, a, b):
- """ Initialize OFParamStmt class
-
- Parameters
- ----------
- param : string
- Parameter
-
- a : float
- Scale factor for the output device A axis
-
- b : float
- Scale factor for the output device B axis
-
- Returns
- -------
- ParamStmt : SFParamStmt
- Initialized SFParamStmt class.
-
- """
- ParamStmt.__init__(self, param)
- self.a = a
- self.b = b
-
- def to_gerber(self, settings=None):
- ret = '%SF'
- if self.a is not None:
- ret += 'A' + decimal_string(self.a, precision=5)
- if self.b is not None:
- ret += 'B' + decimal_string(self.b, precision=5)
- return ret + '*%'
-
- def to_inch(self):
- if self.units == 'metric':
- self.units = 'inch'
- if self.a is not None:
- self.a = inch(self.a)
- if self.b is not None:
- self.b = inch(self.b)
-
- def to_metric(self):
- if self.units == 'inch':
- self.units = 'metric'
- if self.a is not None:
- self.a = metric(self.a)
- if self.b is not None:
- self.b = metric(self.b)
-
- def offset(self, x_offset=0, y_offset=0):
- if self.a is not None:
- self.a += x_offset
- if self.b is not None:
- self.b += y_offset
-
- def __str__(self):
- scale_factor = ''
- if self.a is not None:
- scale_factor += ('X: %g ' % self.a)
- if self.b is not None:
- scale_factor += ('Y: %g' % self.b)
- return ('<Scale Factor: %s>' % scale_factor)
-
-
-class LNParamStmt(ParamStmt):
- """ LN - Level Name Statement (Deprecated)
- """
- @classmethod
- def from_dict(cls, stmt_dict):
- return cls(**stmt_dict)
-
- def __init__(self, param, name):
- """ Initialize LNParamStmt class
-
- Parameters
- ----------
- param : string
- Parameter code
-
- name : string
- Level name
-
- Returns
- -------
- ParamStmt : LNParamStmt
- Initialized LNParamStmt class.
-
- """
- ParamStmt.__init__(self, param)
- self.name = name
-
- def to_gerber(self, settings=None):
- return '%LN{0}*%'.format(self.name)
-
- def __str__(self):
- return '<Level Name: %s>' % self.name
-
+ """ SF - Scale Factor Statement. (Deprecated) """
-class DeprecatedStmt(Statement):
- """ Unimportant deprecated statement, will be parsed but not emitted.
- """
- @classmethod
- def from_gerber(cls, line):
- return cls(line)
-
- def __init__(self, line):
- """ Initialize DeprecatedStmt class
-
- Parameters
- ----------
- line : string
- Deprecated statement text
-
- Returns
- -------
- DeprecatedStmt
- Initialized DeprecatedStmt class.
-
- """
- Statement.__init__(self, "DEPRECATED")
- self.line = line
+ def __init__(self, a, b):
+ self.a, self.b = a, b
def to_gerber(self, settings=None):
- return self.line
+ return '%SFA{decimal_string(self.a, precision=5)}B{decimal_string(self.b, precision=5)}*%'
def __str__(self):
- return '<Deprecated Statement: \'%s\'>' % self.line
-
+ return '<SF Scale Factor>'
class CoordStmt(Statement):
- """ Coordinate Data Block
- """
-
- OP_DRAW = 'D01'
- OP_MOVE = 'D02'
- OP_FLASH = 'D03'
-
- FUNC_LINEAR = 'G01'
- FUNC_ARC_CW = 'G02'
- FUNC_ARC_CCW = 'G03'
+ """ D01 - D03 operation statements """
- @classmethod
- def from_dict(cls, stmt_dict, settings):
- function = stmt_dict['function']
- x = stmt_dict.get('x')
- y = stmt_dict.get('y')
- i = stmt_dict.get('i')
- j = stmt_dict.get('j')
- op = stmt_dict.get('op')
-
- if x is not None:
- x = parse_gerber_value(stmt_dict.get('x'), settings.format,
- settings.zero_suppression)
- if y is not None:
- y = parse_gerber_value(stmt_dict.get('y'), settings.format,
- settings.zero_suppression)
- if i is not None:
- i = parse_gerber_value(stmt_dict.get('i'), settings.format,
- settings.zero_suppression)
- if j is not None:
- j = parse_gerber_value(stmt_dict.get('j'), settings.format,
- settings.zero_suppression)
- return cls(function, x, y, i, j, op, settings)
+ def __init__(self, x, y, i, j):
+ self.x = x
+ self.y = y
+ self.i = i
+ self.j = j
@classmethod
def move(cls, func, point):
@@ -943,94 +405,20 @@ class CoordStmt(Statement):
else:
return cls(None, None, None, None, None, CoordStmt.OP_FLASH, None)
- def __init__(self, function, x, y, i, j, op, settings):
- """ Initialize CoordStmt class
-
- Parameters
- ----------
- function : string
- function
-
- x : float
- X coordinate
-
- y : float
- Y coordinate
-
- i : float
- Coordinate offset in the X direction
-
- j : float
- Coordinate offset in the Y direction
-
- op : string
- Operation code
-
- settings : dict {'zero_suppression', 'format'}
- Gerber file coordinate format
-
- Returns
- -------
- Statement : CoordStmt
- Initialized CoordStmt class.
-
- """
- Statement.__init__(self, "COORD")
- self.function = function
- self.x = x
- self.y = y
- self.i = i
- self.j = j
- self.op = op
-
def to_gerber(self, settings=None):
ret = ''
- if self.function:
- ret += self.function
if self.x is not None:
- ret += 'X{0}'.format(write_gerber_value(self.x, settings.format,
- settings.zero_suppression))
+ ret += 'X{0}'.format(write_gerber_value(self.x, settings.format, settings.zero_suppression))
if self.y is not None:
- ret += 'Y{0}'.format(write_gerber_value(self.y, settings.format,
- settings.zero_suppression))
+ ret += 'Y{0}'.format(write_gerber_value(self.y, settings.format, settings.zero_suppression))
if self.i is not None:
- ret += 'I{0}'.format(write_gerber_value(self.i, settings.format,
- settings.zero_suppression))
+ ret += 'I{0}'.format(write_gerber_value(self.i, settings.format, settings.zero_suppression))
if self.j is not None:
- ret += 'J{0}'.format(write_gerber_value(self.j, settings.format,
- settings.zero_suppression))
+ ret += 'J{0}'.format(write_gerber_value(self.j, settings.format, settings.zero_suppression))
if self.op:
ret += self.op
return ret + '*'
- def to_inch(self):
- if self.units == 'metric':
- self.units = 'inch'
- if self.x is not None:
- self.x = inch(self.x)
- if self.y is not None:
- self.y = inch(self.y)
- if self.i is not None:
- self.i = inch(self.i)
- if self.j is not None:
- self.j = inch(self.j)
- if self.function == "G71":
- self.function = "G70"
-
- def to_metric(self):
- if self.units == 'inch':
- self.units = 'metric'
- if self.x is not None:
- self.x = metric(self.x)
- if self.y is not None:
- self.y = metric(self.y)
- if self.i is not None:
- self.i = metric(self.i)
- if self.j is not None:
- self.j = metric(self.j)
- if self.function == "G70":
- self.function = "G71"
-
def offset(self, x_offset=0, y_offset=0):
if self.x is not None:
self.x += x_offset
@@ -1076,13 +464,56 @@ class CoordStmt(Statement):
# TODO this isn't required
return self.function != None and self.op == None and self.x == None and self.y == None and self.i == None and self.j == None
+class InterpolateStmt(CoordStmt):
+ """ D01 interpolation operation """
+ code = 'D01'
-class ApertureStmt(Statement):
- """ Aperture Statement
- """
+class MoveStmt(CoordStmt):
+ """ D02 move operation """
+ code = 'D02'
+
+class FlashStmt(CoordStmt):
+ """ D03 flash operation """
+ code = 'D03'
+
+class InterpolationStmt(Statement):
+ """ G01 / G02 / G03 interpolation mode statement """
+ def to_gerber(self, **_kwargs):
+ return self.code + '*'
+
+ def __str__(self):
+ return f'<{self.__doc__.strip()}>'
+
+class LinearModeStmt(InterpolationStmt):
+ """ G01 linear interpolation mode statement """
+ code = 'G01'
- def __init__(self, d, deprecated=None):
- Statement.__init__(self, "APERTURE")
+class CircularCWModeStmt(InterpolationStmt):
+ """ G02 circular interpolation mode statement """
+ code = 'G02'
+
+class CircularCCWModeStmt(InterpolationStmt):
+ """ G03 circular interpolation mode statement """
+ code = 'G03'
+
+class SingleQuadrantModeStmt(InterpolationStmt):
+ """ G75 single-quadrant arc interpolation mode statement """
+ code = 'G75'
+
+class MultiQuadrantModeStmt(InterpolationStmt):
+ """ G74 multi-quadrant arc interpolation mode statement """
+ code = 'G74'
+
+class RegionStartStatement(InterpolationStmt):
+ """ G36 Region Mode Start Statement. """
+ code = 'G36'
+
+class RegionEndStatement(InterpolationStmt):
+ """ G37 Region Mode End Statement. """
+ code = 'G37'
+
+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
@@ -1097,23 +528,20 @@ class ApertureStmt(Statement):
class CommentStmt(Statement):
- """ Comment Statment
- """
+ """ G04 Comment Statment """
def __init__(self, comment):
- Statement.__init__(self, "COMMENT")
self.comment = comment if comment is not None else ""
def to_gerber(self, settings=None):
- return 'G04{0}*'.format(self.comment)
+ return f'G04{self.comment}*'
def __str__(self):
- return '<Comment: %s>' % self.comment
+ return f'<G04 Comment: {self.comment}>'
class EofStmt(Statement):
- """ EOF Statement
- """
+ """ M02 EOF Statement """
def __init__(self):
Statement.__init__(self, "EOF")
@@ -1122,76 +550,14 @@ class EofStmt(Statement):
return 'M02*'
def __str__(self):
- return '<EOF Statement>'
-
-
-class QuadrantModeStmt(Statement):
-
- @classmethod
- def single(cls):
- return cls('single-quadrant')
-
- @classmethod
- def multi(cls):
- return cls('multi-quadrant')
-
- @classmethod
- def from_gerber(cls, line):
- if 'G74' not in line and 'G75' not in line:
- raise ValueError('%s is not a valid quadrant mode statement'
- % line)
- return (cls('single-quadrant') if line[:3] == 'G74'
- else cls('multi-quadrant'))
-
- def __init__(self, mode):
- super(QuadrantModeStmt, self).__init__('QuadrantMode')
- mode = mode.lower()
- if mode not in ['single-quadrant', 'multi-quadrant']:
- raise ValueError('Quadrant mode must be "single-quadrant" \
- or "multi-quadrant"')
- self.mode = mode
-
- def to_gerber(self, settings=None):
- return 'G74*' if self.mode == 'single-quadrant' else 'G75*'
-
-
-class RegionModeStmt(Statement):
-
- @classmethod
- def from_gerber(cls, line):
- if 'G36' not in line and 'G37' not in line:
- raise ValueError('%s is not a valid region mode statement' % line)
- return (cls('on') if line[:3] == 'G36' else cls('off'))
-
- @classmethod
- def on(cls):
- return cls('on')
-
- @classmethod
- def off(cls):
- return cls('off')
-
- def __init__(self, mode):
- super(RegionModeStmt, self).__init__('RegionMode')
- mode = mode.lower()
- if mode not in ['on', 'off']:
- raise ValueError('Valid modes are "on" or "off"')
- self.mode = mode
-
- def to_gerber(self, settings=None):
- return 'G36*' if self.mode == 'on' else 'G37*'
-
+ return '<M02 EOF Statement>'
class UnknownStmt(Statement):
- """ Unknown Statement
- """
-
def __init__(self, line):
- Statement.__init__(self, "UNKNOWN")
self.line = line
- def to_gerber(self, settings=None):
+ def to_gerber(self, settings):
return self.line
def __str__(self):
- return '<Unknown Statement: \'%s\'>' % self.line
+ return f'<Unknown Statement: "{self.line}">'
diff --git a/gerbonara/gerber/panelize/__init__.py b/gerbonara/gerber/panelize/__init__.py
deleted file mode 100644
index 7185aea..0000000
--- a/gerbonara/gerber/panelize/__init__.py
+++ /dev/null
@@ -1,7 +0,0 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-
-# Copyright 2019 Hiroshi Murayama <opiopan@gmail.com>
-
-from .composition import GerberComposition, DrillComposition
-# from .dxf import DxfFile
diff --git a/gerbonara/gerber/panelize/utility.py b/gerbonara/gerber/panelize/utility.py
index fc44e79..0d57979 100644
--- a/gerbonara/gerber/panelize/utility.py
+++ b/gerbonara/gerber/panelize/utility.py
@@ -5,14 +5,6 @@
from math import cos, sin, pi, sqrt
-# TODO: replace with ..utils.rotate
-#def rotate(x, y, angle, center):
-# x0 = x - center[0]
-# y0 = y - center[1]
-# angle = angle * pi / 180.0
-# return (cos(angle) * x0 - sin(angle) * y0 + center[0],
-# sin(angle) * x0 + cos(angle) * y0 + center[1])
-
def is_equal_value(a, b, error_range=0):
return (a - b) * (a - b) <= error_range * error_range
diff --git a/gerbonara/gerber/render/__init__.py b/gerbonara/gerber/render/__init__.py
deleted file mode 100644
index c7dbdd5..0000000
--- a/gerbonara/gerber/render/__init__.py
+++ /dev/null
@@ -1,31 +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.
-"""
-gerber.render
-============
-**Gerber Renderers**
-
-This module provides contexts for rendering images of gerber layers. Currently
-SVG is the only supported format.
-"""
-
-from .render import RenderSettings
-from .cairo_backend import GerberCairoContext
-
-available_renderers = {
- 'cairo': GerberCairoContext,
-}
diff --git a/gerbonara/gerber/render/cairo_backend.py b/gerbonara/gerber/render/cairo_backend.py
deleted file mode 100644
index 431ab94..0000000
--- a/gerbonara/gerber/render/cairo_backend.py
+++ /dev/null
@@ -1,640 +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.
-
-try:
- import cairo
-except ImportError:
- import cairocffi as cairo
-
-from operator import mul
-import tempfile
-import warnings
-import os
-
-from .render import GerberContext, RenderSettings
-from .theme import THEMES
-from ..primitives import *
-from ..utils import rotate_point
-
-from io import BytesIO
-
-
-class GerberCairoContext(GerberContext):
-
- def __init__(self, scale=300):
- super(GerberCairoContext, self).__init__()
- self.scale = (scale, scale)
- self.surface = None
- self.surface_buffer = None
- self.ctx = None
- self.active_layer = None
- self.active_matrix = None
- self.output_ctx = None
- self.has_bg = False
- self.origin_in_inch = None
- self.size_in_inch = None
- self._xform_matrix = None
- self._render_count = 0
-
- @property
- def origin_in_pixels(self):
- return (self.scale_point(self.origin_in_inch)
- if self.origin_in_inch is not None else (0.0, 0.0))
-
- @property
- def size_in_pixels(self):
- return (self.scale_point(self.size_in_inch)
- if self.size_in_inch is not None else (0.0, 0.0))
-
- def set_bounds(self, bounds, new_surface=False):
- origin_in_inch = (bounds[0][0], bounds[1][0])
- size_in_inch = (abs(bounds[0][1] - bounds[0][0]),
- abs(bounds[1][1] - bounds[1][0]))
- size_in_pixels = self.scale_point(size_in_inch)
- self.origin_in_inch = origin_in_inch if self.origin_in_inch is None else self.origin_in_inch
- self.size_in_inch = size_in_inch if self.size_in_inch is None else self.size_in_inch
- self._xform_matrix = cairo.Matrix(xx=1.0, yy=-1.0,
- x0=-self.origin_in_pixels[0],
- y0=self.size_in_pixels[1])
- if (self.surface is None) or new_surface:
- self.surface_buffer = tempfile.NamedTemporaryFile()
- self.surface = cairo.SVGSurface(self.surface_buffer, size_in_pixels[0], size_in_pixels[1])
- self.output_ctx = cairo.Context(self.surface)
-
- def render_layer(self, layer, filename=None, settings=None, bgsettings=None,
- verbose=False, bounds=None):
- if settings is None:
- settings = THEMES['default'].get(layer.layer_class, RenderSettings())
- if bgsettings is None:
- bgsettings = THEMES['default'].get('background', RenderSettings())
-
- if self._render_count == 0:
- if verbose:
- print('[Render]: Rendering Background.')
- self.clear()
- if bounds is not None:
- self.set_bounds(bounds)
- else:
- self.set_bounds(layer.bounds)
- self.paint_background(bgsettings)
- if verbose:
- print('[Render]: Rendering {} Layer.'.format(layer.layer_class))
- self._render_count += 1
- self._render_layer(layer, settings)
- if filename is not None:
- self.dump(filename, verbose)
-
- def render_layers(self, layers, filename, theme=THEMES['default'],
- verbose=False, max_width=800, max_height=600):
- """ Render a set of layers
- """
- # Calculate scale parameter
- x_range = [10000, -10000]
- y_range = [10000, -10000]
- for layer in layers:
- bounds = layer.bounds
- if bounds is not None:
- layer_x, layer_y = bounds
- x_range[0] = min(x_range[0], layer_x[0])
- x_range[1] = max(x_range[1], layer_x[1])
- y_range[0] = min(y_range[0], layer_y[0])
- y_range[1] = max(y_range[1], layer_y[1])
- width = x_range[1] - x_range[0]
- height = y_range[1] - y_range[0]
-
- scale = math.floor(min(float(max_width)/width, float(max_height)/height))
- self.scale = (scale, scale)
-
- self.clear()
-
- # Render layers
- bgsettings = theme['background']
- for layer in layers:
- settings = theme.get(layer.layer_class, RenderSettings())
- self.render_layer(layer, settings=settings, bgsettings=bgsettings,
- verbose=verbose)
- self.dump(filename, verbose)
-
- def dump(self, filename=None, verbose=False):
- """ Save image as `filename`
- """
- try:
- is_svg = os.path.splitext(filename.lower())[1] == '.svg'
- except:
- is_svg = False
-
- if verbose:
- print('[Render]: Writing image to {}'.format(filename))
-
- if is_svg:
- self.surface.finish()
- self.surface_buffer.flush()
- with open(filename, "wb") as f:
- self.surface_buffer.seek(0)
- f.write(self.surface_buffer.read())
- f.flush()
- else:
- return self.surface.write_to_png(filename)
-
- def dump_str(self):
- """ Return a byte-string containing the rendered image.
- """
- fobj = BytesIO()
- self.surface.write_to_png(fobj)
- return fobj.getvalue()
-
- def dump_svg_str(self):
- """ Return a string containg the rendered SVG.
- """
- self.surface.finish()
- self.surface_buffer.flush()
- return self.surface_buffer.read()
-
- def clear(self):
- self.surface = None
- self.output_ctx = None
- self.has_bg = False
- self.origin_in_inch = None
- self.size_in_inch = None
- self._xform_matrix = None
- self._render_count = 0
- self.surface_buffer = None
-
- def _new_mask(self):
- class Mask:
- def __enter__(msk):
- size_in_pixels = self.size_in_pixels
- msk.surface = cairo.SVGSurface(None, size_in_pixels[0],
- size_in_pixels[1])
- msk.ctx = cairo.Context(msk.surface)
- msk.ctx.translate(-self.origin_in_pixels[0], -self.origin_in_pixels[1])
- return msk
-
-
- def __exit__(msk, exc_type, exc_val, traceback):
- if hasattr(msk.surface, 'finish'):
- msk.surface.finish()
-
- return Mask()
-
- def _render_layer(self, layer, settings):
- self.invert = settings.invert
- # Get a new clean layer to render on
- self.new_render_layer(mirror=settings.mirror)
- for prim in layer.primitives:
- self.render(prim)
- # Add layer to image
- self.flatten(settings.color, settings.alpha)
-
- def _render_line(self, line, color):
- start = self.scale_point(line.start)
- end = self.scale_point(line.end)
- self.ctx.set_operator(cairo.OPERATOR_OVER
- if (not self.invert)
- and line.level_polarity == 'dark'
- else cairo.OPERATOR_CLEAR)
-
- with self._clip_primitive(line):
- with self._new_mask() as mask:
- if isinstance(line.aperture, Circle):
- width = line.aperture.diameter
- mask.ctx.set_line_width(width * self.scale[0])
- mask.ctx.set_line_cap(cairo.LINE_CAP_ROUND)
- mask.ctx.move_to(*start)
- mask.ctx.line_to(*end)
- mask.ctx.stroke()
-
- elif hasattr(line, 'vertices') and line.vertices is not None:
- points = [self.scale_point(x) for x in line.vertices]
- mask.ctx.set_line_width(0)
- mask.ctx.move_to(*points[-1])
- for point in points:
- mask.ctx.line_to(*point)
- mask.ctx.fill()
- self.ctx.mask_surface(mask.surface, self.origin_in_pixels[0])
-
- def _render_arc(self, arc, color):
- center = self.scale_point(arc.center)
- start = self.scale_point(arc.start)
- end = self.scale_point(arc.end)
- radius = self.scale[0] * arc.radius
- two_pi = 2 * math.pi
- angle1 = (arc.start_angle + two_pi) % two_pi
- angle2 = (arc.end_angle + two_pi) % two_pi
-
- if arc.quadrant_mode == 'single-quadrant':
- warnings.warn('Cairo backend does not support single-quadrant arcs.')
-
- if angle1 == angle2:
- # Make the angles slightly different otherwise Cario will draw nothing
- angle2 -= 0.000000001
- if isinstance(arc.aperture, Circle):
- width = arc.aperture.diameter if arc.aperture.diameter != 0 else 0.001
- else:
- width = max(arc.aperture.width, arc.aperture.height, 0.001)
-
- self.ctx.set_operator(cairo.OPERATOR_OVER
- if (not self.invert)
- and arc.level_polarity == 'dark'
- else cairo.OPERATOR_CLEAR)
- with self._clip_primitive(arc):
- with self._new_mask() as mask:
- mask.ctx.set_line_width(width * self.scale[0])
- mask.ctx.set_line_cap(cairo.LINE_CAP_ROUND if isinstance(arc.aperture, Circle) else cairo.LINE_CAP_SQUARE)
- mask.ctx.move_to(*start) # You actually have to do this...
- if arc.direction == 'counterclockwise':
- mask.ctx.arc(center[0], center[1], radius, angle1, angle2)
- else:
- mask.ctx.arc_negative(center[0], center[1], radius, angle1, angle2)
- mask.ctx.move_to(*end) # ...lame
- mask.ctx.stroke()
-
- #if isinstance(arc.aperture, Rectangle):
- # print("Flash Rectangle Ends")
- # print(arc.aperture.rotation * 180/math.pi)
- # rect = arc.aperture
- # width = self.scale[0] * rect.width
- # height = self.scale[1] * rect.height
- # for point, angle in zip((start, end), (angle1, angle2)):
- # print("{} w {} h{}".format(point, rect.width, rect.height))
- # mask.ctx.rectangle(point[0] - width/2.0,
- # point[1] - height/2.0, width, height)
- # mask.ctx.fill()
-
- self.ctx.mask_surface(mask.surface, self.origin_in_pixels[0])
-
- def _render_region(self, region, color):
- self.ctx.set_operator(cairo.OPERATOR_OVER
- if (not self.invert) and region.level_polarity == 'dark'
- else cairo.OPERATOR_CLEAR)
- with self._clip_primitive(region):
- with self._new_mask() as mask:
- mask.ctx.set_line_width(0)
- mask.ctx.set_line_cap(cairo.LINE_CAP_ROUND)
- mask.ctx.move_to(*self.scale_point(region.primitives[0].start))
- for prim in region.primitives:
- if isinstance(prim, Line):
- mask.ctx.line_to(*self.scale_point(prim.end))
- else:
- center = self.scale_point(prim.center)
- radius = self.scale[0] * prim.radius
- angle1 = prim.start_angle
- angle2 = prim.end_angle
-
- if angle1 == angle2:
- # Make the angles slightly different otherwise Cario will draw nothing
- angle2 -= 0.000000001
- if prim.direction == 'counterclockwise':
- mask.ctx.arc(center[0], center[1], radius,
- angle1, angle2)
- else:
- mask.ctx.arc_negative(center[0], center[1], radius,
- angle1, angle2)
- mask.ctx.fill()
- self.ctx.mask_surface(mask.surface, self.origin_in_pixels[0])
-
- def _render_circle(self, circle, color):
- center = self.scale_point(circle.position)
- self.ctx.set_operator(cairo.OPERATOR_OVER
- if (not self.invert)
- and circle.level_polarity == 'dark'
- else cairo.OPERATOR_CLEAR)
- with self._clip_primitive(circle):
- with self._new_mask() as mask:
- mask.ctx.set_line_width(0)
- mask.ctx.arc(center[0], center[1], (circle.radius * self.scale[0]), 0, (2 * math.pi))
- mask.ctx.fill()
-
- if hasattr(circle, 'hole_diameter') and circle.hole_diameter is not None and circle.hole_diameter > 0:
- mask.ctx.set_operator(cairo.OPERATOR_CLEAR)
- mask.ctx.arc(center[0], center[1], circle.hole_radius * self.scale[0], 0, 2 * math.pi)
- mask.ctx.fill()
-
- if (hasattr(circle, 'hole_width') and hasattr(circle, 'hole_height')
- and circle.hole_width is not None and circle.hole_height is not None
- and circle.hole_width > 0 and circle.hole_height > 0):
- mask.ctx.set_operator(cairo.OPERATOR_CLEAR
- if circle.level_polarity == 'dark'
- and (not self.invert)
- else cairo.OPERATOR_OVER)
- width, height = self.scale_point((circle.hole_width, circle.hole_height))
- lower_left = rotate_point(
- (center[0] - width / 2.0, center[1] - height / 2.0),
- circle.rotation, center)
- lower_right = rotate_point((center[0] + width / 2.0, center[1] - height / 2.0),
- circle.rotation, center)
- upper_left = rotate_point((center[0] - width / 2.0, center[1] + height / 2.0),
- circle.rotation, center)
- upper_right = rotate_point((center[0] + width / 2.0, center[1] + height / 2.0),
- circle.rotation, center)
- points = (lower_left, lower_right, upper_right, upper_left)
- mask.ctx.move_to(*points[-1])
- for point in points:
- mask.ctx.line_to(*point)
- mask.ctx.fill()
- self.ctx.mask_surface(mask.surface, self.origin_in_pixels[0])
-
- def _render_rectangle(self, rectangle, color):
- lower_left = self.scale_point(rectangle.lower_left)
- width, height = tuple([abs(coord) for coord in
- self.scale_point((rectangle.width,
- rectangle.height))])
- self.ctx.set_operator(cairo.OPERATOR_OVER
- if (not self.invert)
- and rectangle.level_polarity == 'dark'
- else cairo.OPERATOR_CLEAR)
- with self._clip_primitive(rectangle):
- with self._new_mask() as mask:
- mask.ctx.set_line_width(0)
- mask.ctx.rectangle(lower_left[0], lower_left[1], width, height)
- mask.ctx.fill()
-
- center = self.scale_point(rectangle.position)
- if rectangle.hole_diameter > 0:
- # Render the center clear
- mask.ctx.set_operator(cairo.OPERATOR_CLEAR
- if rectangle.level_polarity == 'dark'
- and (not self.invert)
- else cairo.OPERATOR_OVER)
-
- mask.ctx.arc(center[0], center[1], rectangle.hole_radius * self.scale[0], 0, 2 * math.pi)
- mask.ctx.fill()
-
- if rectangle.hole_width > 0 and rectangle.hole_height > 0:
- mask.ctx.set_operator(cairo.OPERATOR_CLEAR
- if rectangle.level_polarity == 'dark'
- and (not self.invert)
- else cairo.OPERATOR_OVER)
- width, height = self.scale_point((rectangle.hole_width, rectangle.hole_height))
- lower_left = rotate_point((center[0] - width/2.0, center[1] - height/2.0), rectangle.rotation, center)
- lower_right = rotate_point((center[0] + width/2.0, center[1] - height/2.0), rectangle.rotation, center)
- upper_left = rotate_point((center[0] - width / 2.0, center[1] + height / 2.0), rectangle.rotation, center)
- upper_right = rotate_point((center[0] + width / 2.0, center[1] + height / 2.0), rectangle.rotation, center)
- points = (lower_left, lower_right, upper_right, upper_left)
- mask.ctx.move_to(*points[-1])
- for point in points:
- mask.ctx.line_to(*point)
- mask.ctx.fill()
- self.ctx.mask_surface(mask.surface, self.origin_in_pixels[0])
-
- def _render_obround(self, obround, color):
- self.ctx.set_operator(cairo.OPERATOR_OVER
- if (not self.invert)
- and obround.level_polarity == 'dark'
- else cairo.OPERATOR_CLEAR)
- with self._clip_primitive(obround):
- with self._new_mask() as mask:
- mask.ctx.set_line_width(0)
-
- # Render circles
- for circle in (obround.subshapes['circle1'], obround.subshapes['circle2']):
- center = self.scale_point(circle.position)
- mask.ctx.arc(center[0], center[1], (circle.radius * self.scale[0]), 0, (2 * math.pi))
- mask.ctx.fill()
-
- # Render Rectangle
- rectangle = obround.subshapes['rectangle']
- lower_left = self.scale_point(rectangle.lower_left)
- width, height = tuple([abs(coord) for coord in
- self.scale_point((rectangle.width,
- rectangle.height))])
- mask.ctx.rectangle(lower_left[0], lower_left[1], width, height)
- mask.ctx.fill()
-
- center = self.scale_point(obround.position)
- if obround.hole_diameter > 0:
- # Render the center clear
- mask.ctx.set_operator(cairo.OPERATOR_CLEAR)
- mask.ctx.arc(center[0], center[1], obround.hole_radius * self.scale[0], 0, 2 * math.pi)
- mask.ctx.fill()
-
- if obround.hole_width > 0 and obround.hole_height > 0:
- mask.ctx.set_operator(cairo.OPERATOR_CLEAR
- if rectangle.level_polarity == 'dark'
- and (not self.invert)
- else cairo.OPERATOR_OVER)
- width, height =self.scale_point((obround.hole_width, obround.hole_height))
- lower_left = rotate_point((center[0] - width / 2.0, center[1] - height / 2.0),
- obround.rotation, center)
- lower_right = rotate_point((center[0] + width / 2.0, center[1] - height / 2.0),
- obround.rotation, center)
- upper_left = rotate_point((center[0] - width / 2.0, center[1] + height / 2.0),
- obround.rotation, center)
- upper_right = rotate_point((center[0] + width / 2.0, center[1] + height / 2.0),
- obround.rotation, center)
- points = (lower_left, lower_right, upper_right, upper_left)
- mask.ctx.move_to(*points[-1])
- for point in points:
- mask.ctx.line_to(*point)
- mask.ctx.fill()
-
- self.ctx.mask_surface(mask.surface, self.origin_in_pixels[0])
-
- def _render_polygon(self, polygon, color):
- self.ctx.set_operator(cairo.OPERATOR_OVER
- if (not self.invert)
- and polygon.level_polarity == 'dark'
- else cairo.OPERATOR_CLEAR)
- with self._clip_primitive(polygon):
- with self._new_mask() as mask:
-
- vertices = polygon.vertices
- mask.ctx.set_line_width(0)
- mask.ctx.set_line_cap(cairo.LINE_CAP_ROUND)
- # Start from before the end so it is easy to iterate and make sure
- # it is closed
- mask.ctx.move_to(*self.scale_point(vertices[-1]))
- for v in vertices:
- mask.ctx.line_to(*self.scale_point(v))
- mask.ctx.fill()
-
- center = self.scale_point(polygon.position)
- if polygon.hole_radius > 0:
- # Render the center clear
- mask.ctx.set_operator(cairo.OPERATOR_CLEAR
- if polygon.level_polarity == 'dark'
- and (not self.invert)
- else cairo.OPERATOR_OVER)
- mask.ctx.set_line_width(0)
- mask.ctx.arc(center[0],
- center[1],
- polygon.hole_radius * self.scale[0], 0, 2 * math.pi)
- mask.ctx.fill()
-
- if polygon.hole_width > 0 and polygon.hole_height > 0:
- mask.ctx.set_operator(cairo.OPERATOR_CLEAR
- if polygon.level_polarity == 'dark'
- and (not self.invert)
- else cairo.OPERATOR_OVER)
- width, height = self.scale_point((polygon.hole_width, polygon.hole_height))
- lower_left = rotate_point((center[0] - width / 2.0, center[1] - height / 2.0),
- polygon.rotation, center)
- lower_right = rotate_point((center[0] + width / 2.0, center[1] - height / 2.0),
- polygon.rotation, center)
- upper_left = rotate_point((center[0] - width / 2.0, center[1] + height / 2.0),
- polygon.rotation, center)
- upper_right = rotate_point((center[0] + width / 2.0, center[1] + height / 2.0),
- polygon.rotation, center)
- points = (lower_left, lower_right, upper_right, upper_left)
- mask.ctx.move_to(*points[-1])
- for point in points:
- mask.ctx.line_to(*point)
- mask.ctx.fill()
-
- self.ctx.mask_surface(mask.surface, self.origin_in_pixels[0])
-
- def _render_drill(self, circle, color=None):
- color = color if color is not None else self.drill_color
- self._render_circle(circle, color)
-
- def _render_slot(self, slot, color):
- start = map(mul, slot.start, self.scale)
- end = map(mul, slot.end, self.scale)
-
- width = slot.diameter
-
- self.ctx.set_operator(cairo.OPERATOR_OVER
- if slot.level_polarity == 'dark' and
- (not self.invert) else cairo.OPERATOR_CLEAR)
- with self._clip_primitive(slot):
- with self._new_mask() as mask:
- mask.ctx.set_line_width(width * self.scale[0])
- mask.ctx.set_line_cap(cairo.LINE_CAP_ROUND)
- mask.ctx.move_to(*start)
- mask.ctx.line_to(*end)
- mask.ctx.stroke()
- self.ctx.mask_surface(mask.surface, self.origin_in_pixels[0])
-
- def _render_amgroup(self, amgroup, color):
- # Since holes/clear areas in the mask group must be drawn as clear, we need to render the primitives of this
- # group into a mask context first, then composite this mask context on top of our output surface. This means
- # that a clear primitive inside the group will cover/draw over dark primitives inside the group, but it will
- # *not* cover/draw over dark primitives outside the group.
-
- mask_surface = cairo.SVGSurface(None, self.size_in_pixels[0], self.size_in_pixels[1])
- mask_ctx = cairo.Context(mask_surface)
- mask_ctx.set_matrix(self.ctx.get_matrix())
-
- old_surface, self.surface = self.surface, mask_surface
- old_ctx, self.ctx = self.ctx, mask_ctx
-
- for primitive in amgroup.primitives:
- self.render(primitive)
-
- old_ctx.mask_surface(mask_surface, self.origin_in_pixels[0])
- mask_surface.finish()
- self.surface, self.ctx = old_surface, old_ctx
-
- def _render_test_record(self, primitive, color):
- position = [pos + origin for pos, origin in
- zip(primitive.position, self.origin_in_inch)]
- self.ctx.select_font_face(
- 'monospace', cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_BOLD)
- self.ctx.set_font_size(13)
- self._render_circle(Circle(position, 0.015), color)
- self.ctx.set_operator(cairo.OPERATOR_OVER
- if primitive.level_polarity == 'dark' and
- (not self.invert) else cairo.OPERATOR_CLEAR)
- self.ctx.move_to(*[self.scale[0] * (coord + 0.015) for coord in position])
- self.ctx.scale(1, -1)
- self.ctx.show_text(primitive.net_name)
- self.ctx.scale(1, -1)
-
- def new_render_layer(self, color=None, mirror=False):
- size_in_pixels = self.scale_point(self.size_in_inch)
- m = self._xform_matrix
- matrix = cairo.Matrix(m.xx, m.yx, m.xy, m.yy, m.x0, m.y0)
- layer = cairo.SVGSurface(None, size_in_pixels[0], size_in_pixels[1])
- ctx = cairo.Context(layer)
-
- if self.invert:
- ctx.set_source_rgba(0.0, 0.0, 0.0, 1.0)
- ctx.set_operator(cairo.OPERATOR_OVER)
- ctx.paint()
- if mirror:
- matrix.xx = -1.0
- matrix.x0 = self.origin_in_pixels[0] + self.size_in_pixels[0]
- self.ctx = ctx
- self.ctx.set_matrix(matrix)
- self.active_layer = layer
- self.active_matrix = matrix
-
- def flatten(self, color=None, alpha=None):
- color = color if color is not None else self.color
- alpha = alpha if alpha is not None else self.alpha
- self.output_ctx.set_source_rgba(color[0], color[1], color[2], alpha)
- self.output_ctx.mask_surface(self.active_layer)
- self.ctx = None
- self.active_layer = None
- self.active_matrix = None
-
- def paint_background(self, settings=None):
- color = settings.color if settings is not None else self.background_color
- alpha = settings.alpha if settings is not None else 1.0
- if not self.has_bg:
- self.has_bg = True
- self.output_ctx.set_source_rgba(color[0], color[1], color[2], alpha)
- self.output_ctx.paint()
-
- def _clip_primitive(self, primitive):
- """ Clip rendering context to pixel-aligned bounding box
-
- Calculates pixel- and axis- aligned bounding box, and clips current
- context to that region. Improves rendering speed significantly. This
- returns a context manager, use as follows:
-
- with self._clip_primitive(some_primitive):
- do_rendering_stuff()
- do_more_rendering stuff(with, arguments)
-
- The context manager will reset the context's clipping region when it
- goes out of scope.
-
- """
- class Clip:
- def __init__(clp, primitive):
- (xmin, ymin), (xmax, ymax) = primitive.bounding_box
-
- # Round bounds to the nearest pixel outside of the primitive
- clp.xmin = math.floor(self.scale[0] * xmin)
- clp.xmax = math.ceil(self.scale[0] * xmax)
-
- # We need to offset Y to take care of the difference in y-pos
- # caused by flipping the axis.
- clp.ymin = math.floor(
- (self.scale[1] * ymin) - math.ceil(self.origin_in_pixels[1]))
- clp.ymax = math.ceil(
- (self.scale[1] * ymax) - math.floor(self.origin_in_pixels[1]))
-
- # Calculate width and height, rounded to the nearest pixel
- clp.width = abs(clp.xmax - clp.xmin)
- clp.height = abs(clp.ymax - clp.ymin)
-
- def __enter__(clp):
- # Clip current context to primitive's bounding box
- self.ctx.rectangle(clp.xmin, clp.ymin, clp.width, clp.height)
- self.ctx.clip()
-
- def __exit__(clp, exc_type, exc_val, traceback):
- # Reset context clip region
- self.ctx.reset_clip()
-
- return Clip(primitive)
-
- def scale_point(self, point):
- return tuple([coord * scale for coord, scale in zip(point, self.scale)])
diff --git a/gerbonara/gerber/rs274x.py b/gerbonara/gerber/rs274x.py
index 1d946e9..1ab030c 100644
--- a/gerbonara/gerber/rs274x.py
+++ b/gerbonara/gerber/rs274x.py
@@ -25,12 +25,10 @@ import json
import os
import re
import sys
+import warnings
+from pathlib import Path
from itertools import count, chain
-
-try:
- from cStringIO import StringIO
-except(ImportError):
- from io import StringIO
+from io import StringIO
from .gerber_statements import *
from .primitives import *
@@ -38,40 +36,6 @@ from .cam import CamFile, FileSettings
from .utils import sq_distance, rotate_point
-def read(filename):
- """ Read data from filename and return a GerberFile
-
- Parameters
- ----------
- filename : string
- Filename of file to parse
-
- Returns
- -------
- file : :class:`gerber.rs274x.GerberFile`
- A GerberFile created from the specified file.
- """
- return GerberParser().parse(filename)
-
-
-def loads(data, filename=None):
- """ Generate a GerberFile object from rs274x data in memory
-
- Parameters
- ----------
- data : string
- string containing gerber file contents
-
- filename : string, optional
- string containing the filename of the data source
-
- Returns
- -------
- file : :class:`gerber.rs274x.GerberFile`
- A GerberFile created from the specified file.
- """
- return GerberParser().parse_raw(data, filename)
-
class GerberFile(CamFile):
""" A class representing a single gerber file
@@ -148,18 +112,31 @@ class GerberFile(CamFile):
self.context.notation = 'absolute'
self.context.zeros = 'trailing'
+
+ @classmethod
+ def open(kls, filename, enable_includes=False, enable_include_dir=None):
+ with open(filename, "r") as f:
+ if enable_includes and enable_include_dir is None:
+ 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 [comment.comment for comment in self.statements if isinstance(comment, CommentStmt)]
+ return [stmt.comment for stmt in self.statements if isinstance(stmt, CommentStmt)]
@property
def size(self):
- (x0, y0), (x1, y1)= self.bounding_box
+ (x0, y0), (x1, y1) = self.bounding_box
return (x1 - x0, y1 - y0)
@property
def bounding_box(self):
- bounds = [ p.bounding_box for p in self.primitives ]
+ bounds = [ p.bounding_box for p in self.pDeprecatedrimitives ]
min_x = min(x0 for (x0, y0), (x1, y1) in bounds)
min_y = min(y0 for (x0, y0), (x1, y1) in bounds)
@@ -169,19 +146,19 @@ class GerberFile(CamFile):
return ((min_x, max_x), (min_y, max_y))
# TODO: re-add settings arg
- def write(self, filename=None):
- self.context.notation = 'absolute'
- self.context.zeros = 'trailing'
- self.context.format = self.format
+ def write(self, filename):
+ self.settings.notation = 'absolute'
+ self.settings.zeros = 'trailing'
+ self.settings.format = self.format
self.units = self.units
- with open(filename or self.filename, 'w') as f:
- print(MOParamStmt('MO', self.context.units).to_gerber(self.context), file=f)
- print(FSParamStmt('FS', self.context.zero_suppression, self.context.notation, self.context.format).to_gerber(self.context), file=f)
- print('%IPPOS*%', file=f)
+ with open(filename, 'w') as f:
+ print(UnitStmt().to_gerber(self.settings), file=f)
+ print(FormatSpecStmt().to_gerber(self.settings), file=f)
+ print(ImagePolarityStmt().to_gerber(self.settings), file=f)
for thing in chain(self.aperture_macros.values(), self.aperture_defs, self.main_statements):
- print(thing.to_gerber(self.context), file=f)
+ print(thing.to_gerber(self.settings), file=f)
print('M02*', file=f)
@@ -236,7 +213,6 @@ class GerberFile(CamFile):
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
@@ -269,68 +245,47 @@ class GerberFile(CamFile):
statement.shape = polygon
-class GerberParser(object):
- """ GerberParser
- """
+class GerberParser:
NUMBER = r"[\+-]?\d+"
DECIMAL = r"[\+-]?\d+([.]?\d+)?"
- STRING = r"[a-zA-Z0-9_+\-/!?<>”’(){}.\|&@# :]+"
NAME = r"[a-zA-Z_$\.][a-zA-Z_$\.0-9+\-]+"
- FS = r"(?P<param>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]*"
- MO = r"(?P<param>MO)(?P<mo>(MM|IN))"
- LP = r"(?P<param>LP)(?P<lp>(D|C))"
- AD_CIRCLE = r"(?P<param>AD)D(?P<d>\d+)(?P<shape>C)[,]?(?P<modifiers>[^,%]*)"
- AD_RECT = r"(?P<param>AD)D(?P<d>\d+)(?P<shape>R)[,](?P<modifiers>[^,%]*)"
- AD_OBROUND = r"(?P<param>AD)D(?P<d>\d+)(?P<shape>O)[,](?P<modifiers>[^,%]*)"
- AD_POLY = r"(?P<param>AD)D(?P<d>\d+)(?P<shape>P)[,](?P<modifiers>[^,%]*)"
- AD_MACRO = r"(?P<param>AD)D(?P<d>\d+)(?P<shape>{name})[,]?(?P<modifiers>[^,%]*)".format(name=NAME)
- AM = r"(?P<param>AM)(?P<name>{name})\*(?P<macro>[^%]*)".format(name=NAME)
- # Include File
- IF = r"(?P<param>IF)(?P<filename>.*)"
-
-
- # begin deprecated
- AS = r"(?P<param>AS)(?P<mode>(AXBY)|(AYBX))"
- IN = r"(?P<param>IN)(?P<name>.*)"
- IP = r"(?P<param>IP)(?P<ip>(POS|NEG))"
- IR = r"(?P<param>IR)(?P<angle>{number})".format(number=NUMBER)
- MI = r"(?P<param>MI)(A(?P<a>0|1))?(B(?P<b>0|1))?"
- OF = r"(?P<param>OF)(A(?P<a>{decimal}))?(B(?P<b>{decimal}))?".format(decimal=DECIMAL)
- SF = r"(?P<param>SF)(A(?P<a>{decimal}))?(B(?P<b>{decimal}))?".format(decimal=DECIMAL)
- LN = r"(?P<param>LN)(?P<name>.*)"
- DEPRECATED_UNIT = re.compile(r'(?P<mode>G7[01])\*')
- DEPRECATED_FORMAT = re.compile(r'(?P<format>G9[01])\*')
- # end deprecated
-
- PARAMS = (FS, MO, LP, AD_CIRCLE, AD_RECT, AD_OBROUND, AD_POLY,
- AD_MACRO, AM, AS, IF, IN, IP, IR, MI, OF, SF, LN)
-
- PARAM_STMT = [re.compile(r"%?{0}\*%?".format(p)) for p in PARAMS]
-
- COORD_FUNCTION = r"G0?[123]"
- COORD_OP = r"D0?[123]"
-
- COORD_STMT = re.compile((
- r"(?P<function>{function})?"
- r"(X(?P<x>{number}))?(Y(?P<y>{number}))?"
- r"(I(?P<i>{number}))?(J(?P<j>{number}))?"
- r"(?P<op>{op})?\*".format(number=NUMBER, function=COORD_FUNCTION, op=COORD_OP)))
-
- APERTURE_STMT = re.compile(r"(?P<deprecated>(G54)|(G55))?D(?P<d>\d+)\*")
-
- COMMENT_STMT = re.compile(r"G0?4(?P<comment>[^*]*)(\*)?")
-
- EOF_STMT = re.compile(r"(?P<eof>M[0]?[012])\*")
-
- REGION_MODE_STMT = re.compile(r'(?P<mode>G3[67])\*')
- QUAD_MODE_STMT = re.compile(r'(?P<mode>G7[45])\*')
-
- # Keep include loop from crashing us
- INCLUDE_FILE_RECURSION_LIMIT = 10
-
- def __init__(self):
- self.filename = None
+ 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}))?" \
+ 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>[^*]*)(\*)?",
+ '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))",
+ 'load_name': r"LN(?P<name>.*)",
+ 'offset': fr"OF(A(?P<a>{DECIMAL}))?(B(?P<b>{DECIMAL}))?",
+ 'include_file': r"IF(?P<filename>.*)",
+ 'image_name': r"IN(?P<name>.*)",
+ 'axis_selection': r"AS(?P<axes>AXBY|AYBX)",
+ 'image_polarity': r"IP(?P<polarity>(POS|NEG))",
+ 'image_rotation': fr"IR(?P<rotation>{NUMBER})",
+ 'mirror_image': r"MI(A(?P<a>0|1))?(B(?P<b>0|1))?",
+ '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_mode': r'(?P<mode>G3[67])\*',
+ 'quadrant_mode': r'(?P<mode>G7[45])\*',
+ '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() }
+
+
+ def __init__(self, include_dir=None):
+ """ Pass an include dir to enable IF include statements (potentially DANGEROUS!). """
+ self.include_dir = include_dir
+ self.include_stack = []
self.settings = FileSettings()
self.statements = []
self.primitives = []
@@ -339,6 +294,7 @@ class GerberParser(object):
self.current_region = None
self.x = 0
self.y = 0
+ self.last_operation = None
self.op = "D02"
self.aperture = 0
self.interpolation = 'linear'
@@ -348,17 +304,9 @@ class GerberParser(object):
self.region_mode = 'off'
self.quadrant_mode = 'multi-quadrant'
self.step_and_repeat = (1, 1, 0, 0)
- self._recursion_depth = 0
- def parse(self, filename):
- self.filename = filename
- with open(filename, "r") as fp:
- data = fp.read()
- return self.parse_raw(data, filename)
-
- def parse_raw(self, data, filename=None):
- self.filename = filename
- for stmt in self._parse(self._split_commands(data)):
+ def parse(self, data):
+ for stmt in self._parse(data):
self.evaluate(stmt)
self.statements.append(stmt)
@@ -366,38 +314,37 @@ class GerberParser(object):
for stmt in self.statements:
stmt.units = self.settings.units
- return GerberFile(self.statements, self.settings, self.primitives, self.apertures.values(), filename)
+ return GerberFile(self.statements, self.settings, self.primitives, self.apertures.values())
- def _split_commands(self, data):
+ @classmethod
+ def _split_commands(kls, data):
"""
Split the data into commands. Commands end with * (and also newline to help with some badly formatted files)
"""
- length = len(data)
start = 0
- in_header = True
+ extended_command = False
- for cur in range(0, length):
+ for pos, c in enumerate(data):
+ if c == '%':
+ if extended_command:
+ yield data[start:pos+1]
+ extended_command = False
+ start = pos + 1
- val = data[cur]
+ else:
+ extended_command = True
- if val == '%' and start == cur:
- in_header = True
continue
- if val == '\r' or val == '\n':
- if start != cur:
- yield data[start:cur]
- start = cur + 1
-
- elif not in_header and val == '*':
- yield data[start:cur + 1]
- start = cur + 1
+ elif extended_command:
+ continue
- elif in_header and val == '%':
- yield data[start:cur + 1]
+ if c == '\r' or c == '\n' or c == '*':
+ word_command = data[start:pos+1].strip()
+ if word_command and word_command != '*':
+ yield word_command
start = cur + 1
- in_header = False
def dump_json(self):
return json.dumps({"statements": [stmt.__dict__ for stmt in self.statements]})
@@ -406,164 +353,189 @@ class GerberParser(object):
return '\n'.join(str(stmt) for stmt in self.statements) + '\n'
def _parse(self, data):
- oldline = ''
-
- for line in data:
- line = oldline + line.strip()
-
- # skip empty lines
- if not len(line):
- continue
-
- # deal with multi-line parameters
- if line.startswith("%") and not line.endswith("%") and not "%" in line[1:]:
- oldline = line
- continue
-
- did_something = True # make sure we do at least one loop
- while did_something and len(line) > 0:
- did_something = False
-
- # consume empty data blocks
- if line[0] == '*':
- line = line[1:]
- did_something = True
- continue
-
- # coord
- (coord, r) = _match_one(self.COORD_STMT, line)
- if coord:
- yield CoordStmt.from_dict(coord, self.settings)
- line = r
- did_something = True
- continue
-
- # aperture selection
- (aperture, r) = _match_one(self.APERTURE_STMT, line)
- if aperture:
- yield ApertureStmt(**aperture)
- did_something = True
- line = r
- continue
+ 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:
+ 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())
+ line = line[match.end(0):]
+ break
- # parameter
- (param, r) = _match_one_from_many(self.PARAM_STMT, line)
-
- if param:
- if param["param"] == "FS":
- stmt = FSParamStmt.from_dict(param)
- self.settings.zero_suppression = stmt.zero_suppression
- self.settings.format = stmt.format
- self.settings.notation = stmt.notation
- yield stmt
- elif param["param"] == "MO":
- stmt = MOParamStmt.from_dict(param)
- self.settings.units = stmt.mode
- yield stmt
- elif param["param"] == "LP":
- yield LPParamStmt.from_dict(param)
- elif param["param"] == "AD":
- yield ADParamStmt.from_dict(param)
- elif param["param"] == "AM":
- yield AMParamStmt.from_dict(param, units=self.settings.units)
- elif param["param"] == "OF":
- yield OFParamStmt.from_dict(param)
- elif param["param"] == "IF":
- # Don't crash on include loop
- if self._recursion_depth < self.INCLUDE_FILE_RECURSION_LIMIT:
- self._recursion_depth += 1
- with open(os.path.join(os.path.dirname(self.filename), param["filename"]), 'r') as f:
- inc_data = f.read()
- for stmt in self._parse(self._split_commands(inc_data)):
- yield stmt
- self._recursion_depth -= 1
- else:
- raise IOError("Include file nesting depth limit exceeded.")
- elif param["param"] == "IN":
- yield INParamStmt.from_dict(param)
- elif param["param"] == "LN":
- yield LNParamStmt.from_dict(param)
- # deprecated commands AS, IN, IP, IR, MI, OF, SF, LN
- elif param["param"] == "AS":
- yield ASParamStmt.from_dict(param)
- elif param["param"] == "IN":
- yield INParamStmt.from_dict(param)
- elif param["param"] == "IP":
- yield IPParamStmt.from_dict(param)
- elif param["param"] == "IR":
- yield IRParamStmt.from_dict(param)
- elif param["param"] == "MI":
- yield MIParamStmt.from_dict(param)
- elif param["param"] == "OF":
- yield OFParamStmt.from_dict(param)
- elif param["param"] == "SF":
- yield SFParamStmt.from_dict(param)
- elif param["param"] == "LN":
- yield LNParamStmt.from_dict(param)
- else:
+ else:
+ if line[-1] == '*':
yield UnknownStmt(line)
+ line = ''
+
+ def _parse_interpolation_mode(self, match):
+ if match['code'] == 'G01':
+ yield LinearModeStmt()
+ elif match['code'] == 'G02':
+ yield CircularCWModeStmt()
+ elif match['code'] == 'G03':
+ yield CircularCCWModeStmt()
+ elif match['code'] == 'G74':
+ yield MultiQuadrantModeStmt()
+ elif match['code'] == 'G75':
+ yield SingleQuadrantModeStmt()
+
+ def _parse_coord(self, match):
+ x = parse_gerber_value(match.get('x'), self.settings)
+ y = parse_gerber_value(match.get('y'), self.settings)
+ i = parse_gerber_value(match.get('i'), self.settings)
+ j = parse_gerber_value(match.get('j'), self.settings)
+ if not (op := match['operation']):
+ if self.last_operation == 'D01':
+ warnings.warn('Coordinate statement without explicit operation code. This is forbidden by spec.',
+ SyntaxWarning)
+ op = 'D01'
+ else:
+ raise SyntaxError('Ambiguous coordinate statement. Coordinate statement does not have an operation '\
+ 'mode and the last operation statement was not D01.')
+
+ if op in ('D1', 'D01'):
+ yield InterpolateStmt(x, y, i, j)
+
+ if i is not None or j is not None:
+ raise SyntaxError("i/j coordinates given for D02/D03 operation (which doesn't take i/j)")
+
+ if op in ('D2', 'D02'):
+ yield MoveStmt(x, y, i, j)
+ else: # D03
+ yield FlashStmt(x, y, i, j)
+
+
+ def _parse_aperture(self, match):
+ number = int(match['number'])
+ if number < 10:
+ raise SyntaxError(f'Invalid aperture number {number}: Aperture number must be >= 10.')
+ yield ApertureStmt(number)
+
+ 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'
- did_something = True
- line = r
- continue
-
- # Region Mode
- (mode, r) = _match_one(self.REGION_MODE_STMT, line)
- if mode:
- yield RegionModeStmt.from_gerber(line)
- line = r
- did_something = True
- continue
+ 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])
- # Quadrant Mode
- (mode, r) = _match_one(self.QUAD_MODE_STMT, line)
- if mode:
- yield QuadrantModeStmt.from_gerber(line)
- line = r
- did_something = True
- continue
+ yield FormatSpecStmt()
- # comment
- (comment, r) = _match_one(self.COMMENT_STMT, line)
- if comment:
- yield CommentStmt(comment["comment"])
- did_something = True
- line = r
- continue
+ def _parse_unit_mode(self, match):
+ if match['unit'] == 'MM':
+ self.settings.units = 'mm'
+ else:
+ self.settings.units = 'inch'
- # deprecated codes
- (deprecated_unit, r) = _match_one(self.DEPRECATED_UNIT, line)
- if deprecated_unit:
- stmt = MOParamStmt(param="MO", mo="inch" if "G70" in
- deprecated_unit["mode"] else "metric")
- self.settings.units = stmt.mode
- yield stmt
- line = r
- did_something = True
- continue
+ yield MOParamStmt()
- (deprecated_format, r) = _match_one(self.DEPRECATED_FORMAT, line)
- if deprecated_format:
- yield DeprecatedStmt.from_gerber(line)
- line = r
- did_something = True
- continue
+ def _parse_load_polarity(self, match):
+ yield LoadPolarityStmt(dark=(match['polarity'] == 'D'))
- # eof
- (eof, r) = _match_one(self.EOF_STMT, line)
- if eof:
- yield EofStmt()
- did_something = True
- line = r
- continue
+ 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
+ yield OffsetStmt(a, b)
- if line.find('*') > 0:
- yield UnknownStmt(line)
- did_something = True
- line = ""
- continue
+ def _parse_include_file(self, match):
+ if self.include_dir is None:
+ warnings.warn('IF Include File 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)
+
+ include_file = self.include_dir / param["filename"]
+ if include_file in self.include_stack
+ raise ValueError("Recusive file 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.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"]}')
+
+ 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 AxisSelectionStmt()
+
+ 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 ImagePolarityStmt()
+
+ 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 ImageRotationStmt()
+
+ 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 MirrorImageStmt()
+
+ 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 ScaleFactorStmt()
+
+ def _parse_comment(self, match):
+ yield CommentStmt(match["comment"])
+
+ def _parse_region_mode(self, match):
+ yield RegionStartStatement() if match['mode'] == 'G36' else RegionEndStatement()
+
+ elif param["param"] == "AM":
+ yield AMParamStmt.from_dict(param, units=self.settings.units)
+ elif param["param"] == "AD":
+ yield ADParamStmt.from_dict(param)
+
+ def _parse_quadrant_mode(self, match):
+ if match['mode'] == 'G74':
+ warnings.warn('Deprecated G74 single quadrant mode statement found. This deprecated since 2021.',
+ DeprecationWarning)
+ yield SingleQuadrantModeStmt()
+ else:
+ yield MultiQuadrantModeStmt()
+
+ def _parse_old_unit(self, match):
+ self.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()
+
+ 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')
+
+ def _parse_eof(self, match):
+ yield EofStmt()
- oldline = line
+ def _parse_ignored(self, match):
+ yield CommentStmt(f'Ignoring {match{"stmt"]} statement.')
def evaluate(self, stmt):
""" Evaluate Gerber statement and update image accordingly.
diff --git a/gerbonara/gerber/tests/panelize/data/ref_gerber_inch.gtl b/gerbonara/gerber/tests/panelize/data/ref_gerber_inch.gtl
index 3ec60d8..f71c948 100644
--- a/gerbonara/gerber/tests/panelize/data/ref_gerber_inch.gtl
+++ b/gerbonara/gerber/tests/panelize/data/ref_gerber_inch.gtl
@@ -8,7 +8,6 @@
1,1,0.015748,-0.0472441,0,$1*
4,1,4,0.0472441,0,0.0551181,-0.00787402,0.0472441,-0.015748,0.0393701,-0.00787402,0.0472441,0,$1*
5,1,6,0.0472441,0.00787402,0.015748,$1*
-6,-0.0275591,0,0.019685,0.0019685,0.00590551,2,0.0019685,0.023622,$1*
7,0.0275591,0,0.023622,0.019685,0.00590551,$1*%
%ADD10C,0.0003937*%
%ADD11C,0.03937X0.01575*%
diff --git a/gerbonara/gerber/tests/panelize/data/ref_gerber_metric.gtl b/gerbonara/gerber/tests/panelize/data/ref_gerber_metric.gtl
index 8dfbdd4..98833de 100644
--- a/gerbonara/gerber/tests/panelize/data/ref_gerber_metric.gtl
+++ b/gerbonara/gerber/tests/panelize/data/ref_gerber_metric.gtl
@@ -8,7 +8,6 @@
1,1,0.4,-1.2,0,$1*
4,1,4,1.2,0,1.4,-0.2,1.2,-0.4,1,-0.2,1.2,0,$1*
5,1,6,1.2,0.2,0.4,$1*
-6,-0.7,0,0.5,0.05,0.15,2,0.05,0.6,$1*
7,0.7,0,0.6,0.5,0.15,$1*%
%ADD10C,0.01*%
%ADD11C,1X0.4*%
diff --git a/gerbonara/gerber/tests/panelize/data/ref_gerber_single_quadrant.gtl b/gerbonara/gerber/tests/panelize/data/ref_gerber_single_quadrant.gtl
deleted file mode 100644
index f31f1e7..0000000
--- a/gerbonara/gerber/tests/panelize/data/ref_gerber_single_quadrant.gtl
+++ /dev/null
@@ -1,40 +0,0 @@
-%MOMM*%
-%FSLAX34Y34*%
-%IPPOS*%
-%ADD10C,0.1*%
-G74*
-%LPD*%
-
-D10*
-
-G36*
-G01*
-X0Y10000D02*
-Y90000D01*
-G02*
-X10000Y100000I10000*
-X20000Y90000J10000*
-G01*
-Y20000*
-X40000*
-G02*
-X50000Y10000J10000*
-X40000Y0I10000*
-G01*
-X10000*
-G02*
-X0Y10000J10000*
-G37*
-
-G03*
-X70000Y50000D02*
-X60000Y60000I10000D01*
-X50000Y50000J10000*
-X60000Y40000I10000*
-X70000Y50000J10000*
-
-G02*
-X60000Y90000D02*
-X60000Y90000I10000D01*
-
-M02*
diff --git a/gerbonara/gerber/tests/panelize/expects/RS2724x_offset.gtl b/gerbonara/gerber/tests/panelize/expects/RS2724x_offset.gtl
index 3dc3e6a..0bebd07 100644
--- a/gerbonara/gerber/tests/panelize/expects/RS2724x_offset.gtl
+++ b/gerbonara/gerber/tests/panelize/expects/RS2724x_offset.gtl
@@ -7,7 +7,6 @@
1,1,0.4,-1.2,0,$1*
4,1,4,1.2,0,1.4,-0.2,1.2,-0.4,1,-0.2,1.2,0,$1*
5,1,6,1.2,0.2,0.4,$1*
-6,-0.7,0,0.5,0.05,0.15,2,0.05,0.6,$1*
7,0.7,0,0.6,0.5,0.15,$1*%
%ADD10C,0.01*%
%ADD11C,1X0.4*%
diff --git a/gerbonara/gerber/tests/panelize/expects/RS2724x_rotate.gtl b/gerbonara/gerber/tests/panelize/expects/RS2724x_rotate.gtl
index f7c82cd..00335b8 100644
--- a/gerbonara/gerber/tests/panelize/expects/RS2724x_rotate.gtl
+++ b/gerbonara/gerber/tests/panelize/expects/RS2724x_rotate.gtl
@@ -7,7 +7,6 @@
1,1,0.4,-1.2,0,($1)+(20)*
4,1,4,1.2,0,1.4,-0.2,1.2,-0.4,1,-0.2,1.2,0,($1)+(20)*
5,1,6,1.2,0.2,0.4,($1)+(20)*
-6,-0.7,0,0.5,0.05,0.15,2,0.05,0.6,($1)+(20)*
7,0.7,0,0.6,0.5,0.15,($1)+(20)*%
%AMMACR*
21,1,$1,$2,0,0,20*
diff --git a/gerbonara/gerber/tests/panelize/expects/RS2724x_save.gtl b/gerbonara/gerber/tests/panelize/expects/RS2724x_save.gtl
index 5053d99..b4fe9e1 100644
--- a/gerbonara/gerber/tests/panelize/expects/RS2724x_save.gtl
+++ b/gerbonara/gerber/tests/panelize/expects/RS2724x_save.gtl
@@ -7,7 +7,6 @@
1,1,0.4,-1.2,0,$1*
4,1,4,1.2,0,1.4,-0.2,1.2,-0.4,1,-0.2,1.2,0,$1*
5,1,6,1.2,0.2,0.4,$1*
-6,-0.7,0,0.5,0.05,0.15,2,0.05,0.6,$1*
7,0.7,0,0.6,0.5,0.15,$1*%
%ADD10C,0.01*%
%ADD11C,1X0.4*%
diff --git a/gerbonara/gerber/tests/panelize/expects/RS2724x_single_quadrant.gtl b/gerbonara/gerber/tests/panelize/expects/RS2724x_single_quadrant.gtl
deleted file mode 100644
index dbec705..0000000
--- a/gerbonara/gerber/tests/panelize/expects/RS2724x_single_quadrant.gtl
+++ /dev/null
@@ -1,35 +0,0 @@
-%MOMM*%
-%FSLAX34Y34*%
-%IPPOS*%
-%ADD10C,0.1*%
-G75*
-%LPD*%
-D10*
-G36*
-G01*
-X0Y10000D02*
-X0Y90000D01*
-G02*
-X10000Y100000I10000J0D01*
-X20000Y90000I0J-10000D01*
-G01*
-X20000Y20000D01*
-X40000Y20000D01*
-G02*
-X50000Y10000I0J-10000D01*
-X40000Y0I-10000J0D01*
-G01*
-X10000Y0D01*
-G02*
-X0Y10000I0J10000D01*
-G37*
-G03*
-X70000Y50000D02*
-X60000Y60000I-10000J0D01*
-X50000Y50000I0J-10000D01*
-X60000Y40000I10000J0D01*
-X70000Y50000I0J10000D01*
-G02*
-X60000Y90000D02*
-X60000Y90000I0J0D01*
-M02*
diff --git a/gerbonara/gerber/tests/panelize/expects/RS2724x_to_inch.gtl b/gerbonara/gerber/tests/panelize/expects/RS2724x_to_inch.gtl
index cb9234e..ed23a58 100644
--- a/gerbonara/gerber/tests/panelize/expects/RS2724x_to_inch.gtl
+++ b/gerbonara/gerber/tests/panelize/expects/RS2724x_to_inch.gtl
@@ -7,7 +7,6 @@
1,1,0.015748,-0.0472441,0,$1*
4,1,4,0.0472441,0,0.0551181,-0.00787402,0.0472441,-0.015748,0.0393701,-0.00787402,0.0472441,0,$1*
5,1,6,0.0472441,0.00787402,0.015748,$1*
-6,-0.0275591,0,0.019685,0.0019685,0.00590551,2,0.0019685,0.023622,$1*
7,0.0275591,0,0.023622,0.019685,0.00590551,$1*%
%ADD10C,0.0003937*%
%ADD11C,0.03937X0.01575*%
diff --git a/gerbonara/gerber/tests/panelize/expects/RS2724x_to_metric.gtl b/gerbonara/gerber/tests/panelize/expects/RS2724x_to_metric.gtl
index a8efda8..2a4df41 100644
--- a/gerbonara/gerber/tests/panelize/expects/RS2724x_to_metric.gtl
+++ b/gerbonara/gerber/tests/panelize/expects/RS2724x_to_metric.gtl
@@ -7,7 +7,6 @@
1,1,0.399999,-1.2,0,$1*
4,1,4,1.2,0,1.4,-0.2,1.2,-0.399999,1,-0.2,1.2,0,$1*
5,1,6,1.2,0.2,0.399999,$1*
-6,-0.700001,0,0.499999,0.0499999,0.15,2,0.0499999,0.599999,$1*
7,0.700001,0,0.599999,0.499999,0.15,$1*%
%ADD10C,0.01*%
%ADD11C,1X0.4*%
diff --git a/gerbonara/gerber/tests/panelize/test_am_expression.py b/gerbonara/gerber/tests/panelize/test_am_expression.py
index 45758b7..576be88 100644
--- a/gerbonara/gerber/tests/panelize/test_am_expression.py
+++ b/gerbonara/gerber/tests/panelize/test_am_expression.py
@@ -30,10 +30,6 @@ class TestAMConstantExpression(unittest.TestCase):
self.assertEqual(self.const_int.to_gerber(), '7')
self.assertEqual(self.const_float.to_gerber(), '1.2345')
- def test_to_instructions(self):
- self.const_int.to_instructions()
- self.const_float.to_instructions()
-
class TestAMVariableExpression(unittest.TestCase):
def setUp(self):
self.var1_num = 1
@@ -57,10 +53,6 @@ class TestAMVariableExpression(unittest.TestCase):
self.assertEqual(self.var1.to_gerber(), '$1')
self.assertEqual(self.var2.to_gerber(), '$512')
- def test_to_instructions(self):
- self.var1.to_instructions()
- self.var2.to_instructions()
-
class TestAMOperatorExpression(unittest.TestCase):
def setUp(self):
self.c1 = 10
@@ -132,11 +124,6 @@ class TestAMOperatorExpression(unittest.TestCase):
self.c1, self.c2, self.c1, self.c2
))
- def test_to_instructions(self):
- for of, expression in self.vc_exps + self.cv_exps + self.cc_exps:
- expression.to_instructions()
- self.composition.to_instructions()
-
class TestAMExpression(unittest.TestCase):
def setUp(self):
self.c1 = 10
diff --git a/gerbonara/gerber/tests/panelize/test_rs274x.py b/gerbonara/gerber/tests/panelize/test_rs274x.py
index 067717d..73f3172 100644
--- a/gerbonara/gerber/tests/panelize/test_rs274x.py
+++ b/gerbonara/gerber/tests/panelize/test_rs274x.py
@@ -68,8 +68,3 @@ class TestRs274x(unittest.TestCase):
gerber.rotate(20, (10,10))
gerber.write(outfile)
- def test_single_quadrant(self):
- with self._check_result('RS2724x_single_quadrant.gtl') as outfile:
- gerber = read(self.SQ_FILE)
- gerber.write(outfile)
-
diff --git a/gerbonara/gerber/tests/test_am_statements.py b/gerbonara/gerber/tests/test_am_statements.py
deleted file mode 100644
index 0d100b5..0000000
--- a/gerbonara/gerber/tests/test_am_statements.py
+++ /dev/null
@@ -1,395 +0,0 @@
-#! /usr/bin/env python
-# -*- coding: utf-8 -*-
-
-# Author: Hamilton Kibbe <ham@hamiltonkib.be>
-
-import pytest
-
-from ..am_statements import *
-from ..am_statements import inch, metric
-
-
-def test_AMPrimitive_ctor():
- for exposure in ("on", "off", "ON", "OFF"):
- for code in (0, 1, 2, 4, 5, 6, 7, 20, 21, 22):
- p = AMPrimitive(code, exposure)
- assert p.code == code
- assert p.exposure == exposure.lower()
-
-
-def test_AMPrimitive_validation():
- pytest.raises(TypeError, AMPrimitive, "1", "off")
- pytest.raises(ValueError, AMPrimitive, 0, "exposed")
- pytest.raises(ValueError, AMPrimitive, 3, "off")
-
-
-def test_AMPrimitive_conversion():
- p = AMPrimitive(4, "on")
- pytest.raises(NotImplementedError, p.to_inch)
- pytest.raises(NotImplementedError, p.to_metric)
-
-
-def test_AMCommentPrimitive_ctor():
- c = AMCommentPrimitive(0, " This is a comment *")
- assert c.code == 0
- assert c.comment == "This is a comment"
-
-
-def test_AMCommentPrimitive_validation():
- pytest.raises(ValueError, AMCommentPrimitive, 1, "This is a comment")
-
-
-def test_AMCommentPrimitive_factory():
- c = AMCommentPrimitive.from_gerber("0 Rectangle with rounded corners. *")
- assert c.code == 0
- assert c.comment == "Rectangle with rounded corners."
-
-
-def test_AMCommentPrimitive_dump():
- c = AMCommentPrimitive(0, "Rectangle with rounded corners.")
- assert c.to_gerber() == "0 Rectangle with rounded corners. *"
-
-
-def test_AMCommentPrimitive_conversion():
- c = AMCommentPrimitive(0, "Rectangle with rounded corners.")
- ci = c
- cm = c
- ci.to_inch()
- cm.to_metric()
- assert c == ci
- assert c == cm
-
-
-def test_AMCommentPrimitive_string():
- c = AMCommentPrimitive(0, "Test Comment")
- assert str(c) == "<Aperture Macro Comment: Test Comment>"
-
-
-def test_AMCirclePrimitive_ctor():
- test_cases = (
- (1, "on", 0, (0, 0)),
- (1, "off", 1, (0, 1)),
- (1, "on", 2.5, (0, 2)),
- (1, "off", 5.0, (3, 3)),
- )
- for code, exposure, diameter, position in test_cases:
- c = AMCirclePrimitive(code, exposure, diameter, position)
- assert c.code == code
- assert c.exposure == exposure
- assert c.diameter == diameter
- assert c.position == position
-
-
-def test_AMCirclePrimitive_validation():
- pytest.raises(ValueError, AMCirclePrimitive, 2, "on", 0, (0, 0))
-
-
-def test_AMCirclePrimitive_factory():
- c = AMCirclePrimitive.from_gerber("1,0,5,0,0*")
- assert c.code == 1
- assert c.exposure == "off"
- assert c.diameter == 5
- assert c.position == (0, 0)
-
-
-def test_AMCirclePrimitive_dump():
- c = AMCirclePrimitive(1, "off", 5, (0, 0))
- assert c.to_gerber() == "1,0,5,0,0*"
- c = AMCirclePrimitive(1, "on", 5, (0, 0))
- assert c.to_gerber() == "1,1,5,0,0*"
-
-
-def test_AMCirclePrimitive_conversion():
- c = AMCirclePrimitive(1, "off", 25.4, (25.4, 0))
- c.to_inch()
- assert c.diameter == 1
- assert c.position == (1, 0)
-
- c = AMCirclePrimitive(1, "off", 1, (1, 0))
- c.to_metric()
- assert c.diameter == 25.4
- assert c.position == (25.4, 0)
-
-
-def test_AMVectorLinePrimitive_validation():
- pytest.raises(
- ValueError, AMVectorLinePrimitive, 3, "on", 0.1, (0, 0), (3.3, 5.4), 0
- )
-
-
-def test_AMVectorLinePrimitive_factory():
- l = AMVectorLinePrimitive.from_gerber("20,1,0.9,0,0.45,12,0.45,0*")
- assert l.code == 20
- assert l.exposure == "on"
- assert l.width == 0.9
- assert l.start == (0, 0.45)
- assert l.end == (12, 0.45)
- assert l.rotation == 0
-
-
-def test_AMVectorLinePrimitive_dump():
- l = AMVectorLinePrimitive.from_gerber("20,1,0.9,0,0.45,12,0.45,0*")
- assert l.to_gerber() == "20,1,0.9,0.0,0.45,12.0,0.45,0.0*"
-
-
-def test_AMVectorLinePrimtive_conversion():
- l = AMVectorLinePrimitive(20, "on", 25.4, (0, 0), (25.4, 25.4), 0)
- l.to_inch()
- assert l.width == 1
- assert l.start == (0, 0)
- assert l.end == (1, 1)
-
- l = AMVectorLinePrimitive(20, "on", 1, (0, 0), (1, 1), 0)
- l.to_metric()
- assert l.width == 25.4
- assert l.start == (0, 0)
- assert l.end == (25.4, 25.4)
-
-
-def test_AMOutlinePrimitive_validation():
- pytest.raises(
- ValueError,
- AMOutlinePrimitive,
- 7,
- "on",
- (0, 0),
- [(3.3, 5.4), (4.0, 5.4), (0, 0)],
- 0,
- )
- pytest.raises(
- ValueError,
- AMOutlinePrimitive,
- 4,
- "on",
- (0, 0),
- [(3.3, 5.4), (4.0, 5.4), (0, 1)],
- 0,
- )
-
-
-def test_AMOutlinePrimitive_factory():
- o = AMOutlinePrimitive.from_gerber("4,1,3,0,0,3,3,3,0,0,0,0*")
- assert o.code == 4
- assert o.exposure == "on"
- assert o.start_point == (0, 0)
- assert o.points == [(3, 3), (3, 0), (0, 0)]
- assert o.rotation == 0
-
-
-def test_AMOUtlinePrimitive_dump():
- o = AMOutlinePrimitive(4, "on", (0, 0), [(3, 3), (3, 0), (0, 0)], 0)
- # New lines don't matter for Gerber, but we insert them to make it easier to remove
- # For test purposes we can ignore them
- assert o.to_gerber().replace("\n", "") == "4,1,3,0,0,3,3,3,0,0,0,0*"
-
-
-def test_AMOutlinePrimitive_conversion():
- o = AMOutlinePrimitive(4, "on", (0, 0), [(25.4, 25.4), (25.4, 0), (0, 0)], 0)
- o.to_inch()
- assert o.start_point == (0, 0)
- assert o.points == ((1.0, 1.0), (1.0, 0.0), (0.0, 0.0))
-
- o = AMOutlinePrimitive(4, "on", (0, 0), [(1, 1), (1, 0), (0, 0)], 0)
- o.to_metric()
- assert o.start_point == (0, 0)
- assert o.points == ((25.4, 25.4), (25.4, 0), (0, 0))
-
-
-def test_AMPolygonPrimitive_validation():
- pytest.raises(ValueError, AMPolygonPrimitive, 6, "on", 3, (3.3, 5.4), 3, 0)
- pytest.raises(ValueError, AMPolygonPrimitive, 5, "on", 2, (3.3, 5.4), 3, 0)
- pytest.raises(ValueError, AMPolygonPrimitive, 5, "on", 13, (3.3, 5.4), 3, 0)
-
-
-def test_AMPolygonPrimitive_factory():
- p = AMPolygonPrimitive.from_gerber("5,1,3,3.3,5.4,3,0")
- assert p.code == 5
- assert p.exposure == "on"
- assert p.vertices == 3
- assert p.position == (3.3, 5.4)
- assert p.diameter == 3
- assert p.rotation == 0
-
-
-def test_AMPolygonPrimitive_dump():
- p = AMPolygonPrimitive(5, "on", 3, (3.3, 5.4), 3, 0)
- assert p.to_gerber() == "5,1,3,3.3,5.4,3,0*"
-
-
-def test_AMPolygonPrimitive_conversion():
- p = AMPolygonPrimitive(5, "off", 3, (25.4, 0), 25.4, 0)
- p.to_inch()
- assert p.diameter == 1
- assert p.position == (1, 0)
-
- p = AMPolygonPrimitive(5, "off", 3, (1, 0), 1, 0)
- p.to_metric()
- assert p.diameter == 25.4
- assert p.position == (25.4, 0)
-
-
-def test_AMMoirePrimitive_validation():
- pytest.raises(
- ValueError, AMMoirePrimitive, 7, (0, 0), 5.1, 0.2, 0.4, 6, 0.1, 6.1, 0
- )
-
-
-def test_AMMoirePrimitive_factory():
- m = AMMoirePrimitive.from_gerber("6,0,0,5,0.5,0.5,2,0.1,6,0*")
- assert m.code == 6
- assert m.position == (0, 0)
- assert m.diameter == 5
- assert m.ring_thickness == 0.5
- assert m.gap == 0.5
- assert m.max_rings == 2
- assert m.crosshair_thickness == 0.1
- assert m.crosshair_length == 6
- assert m.rotation == 0
-
-
-def test_AMMoirePrimitive_dump():
- m = AMMoirePrimitive.from_gerber("6,0,0,5,0.5,0.5,2,0.1,6,0*")
- assert m.to_gerber() == "6,0,0,5.0,0.5,0.5,2,0.1,6.0,0.0*"
-
-
-def test_AMMoirePrimitive_conversion():
- m = AMMoirePrimitive(6, (25.4, 25.4), 25.4, 25.4, 25.4, 6, 25.4, 25.4, 0)
- m.to_inch()
- assert m.position == (1.0, 1.0)
- assert m.diameter == 1.0
- assert m.ring_thickness == 1.0
- assert m.gap == 1.0
- assert m.crosshair_thickness == 1.0
- assert m.crosshair_length == 1.0
-
- m = AMMoirePrimitive(6, (1, 1), 1, 1, 1, 6, 1, 1, 0)
- m.to_metric()
- assert m.position == (25.4, 25.4)
- assert m.diameter == 25.4
- assert m.ring_thickness == 25.4
- assert m.gap == 25.4
- assert m.crosshair_thickness == 25.4
- assert m.crosshair_length == 25.4
-
-
-def test_AMThermalPrimitive_validation():
- pytest.raises(ValueError, AMThermalPrimitive, 8, (0.0, 0.0), 7, 5, 0.2, 0.0)
- pytest.raises(TypeError, AMThermalPrimitive, 7, (0.0, "0"), 7, 5, 0.2, 0.0)
-
-
-def test_AMThermalPrimitive_factory():
- t = AMThermalPrimitive.from_gerber("7,0,0,7,6,0.2,45*")
- assert t.code == 7
- assert t.position == (0, 0)
- assert t.outer_diameter == 7
- assert t.inner_diameter == 6
- assert t.gap == 0.2
- assert t.rotation == 45
-
-
-def test_AMThermalPrimitive_dump():
- t = AMThermalPrimitive.from_gerber("7,0,0,7,6,0.2,30*")
- assert t.to_gerber() == "7,0,0,7.0,6.0,0.2,30.0*"
-
-
-def test_AMThermalPrimitive_conversion():
- t = AMThermalPrimitive(7, (25.4, 25.4), 25.4, 25.4, 25.4, 0.0)
- t.to_inch()
- assert t.position == (1.0, 1.0)
- assert t.outer_diameter == 1.0
- assert t.inner_diameter == 1.0
- assert t.gap == 1.0
-
- t = AMThermalPrimitive(7, (1, 1), 1, 1, 1, 0)
- t.to_metric()
- assert t.position == (25.4, 25.4)
- assert t.outer_diameter == 25.4
- assert t.inner_diameter == 25.4
- assert t.gap == 25.4
-
-
-def test_AMCenterLinePrimitive_validation():
- pytest.raises(ValueError, AMCenterLinePrimitive, 22, 1, 0.2, 0.5, (0, 0), 0)
-
-
-def test_AMCenterLinePrimtive_factory():
- l = AMCenterLinePrimitive.from_gerber("21,1,6.8,1.2,3.4,0.6,0*")
- assert l.code == 21
- assert l.exposure == "on"
- assert l.width == 6.8
- assert l.height == 1.2
- assert l.center == (3.4, 0.6)
- assert l.rotation == 0
-
-
-def test_AMCenterLinePrimitive_dump():
- l = AMCenterLinePrimitive.from_gerber("21,1,6.8,1.2,3.4,0.6,0*")
- assert l.to_gerber() == "21,1,6.8,1.2,3.4,0.6,0.0*"
-
-
-def test_AMCenterLinePrimitive_conversion():
- l = AMCenterLinePrimitive(21, "on", 25.4, 25.4, (25.4, 25.4), 0)
- l.to_inch()
- assert l.width == 1.0
- assert l.height == 1.0
- assert l.center == (1.0, 1.0)
-
- l = AMCenterLinePrimitive(21, "on", 1, 1, (1, 1), 0)
- l.to_metric()
- assert l.width == 25.4
- assert l.height == 25.4
- assert l.center == (25.4, 25.4)
-
-
-def test_AMLowerLeftLinePrimitive_validation():
- pytest.raises(ValueError, AMLowerLeftLinePrimitive, 23, 1, 0.2, 0.5, (0, 0), 0)
-
-
-def test_AMLowerLeftLinePrimtive_factory():
- l = AMLowerLeftLinePrimitive.from_gerber("22,1,6.8,1.2,3.4,0.6,0*")
- assert l.code == 22
- assert l.exposure == "on"
- assert l.width == 6.8
- assert l.height == 1.2
- assert l.lower_left == (3.4, 0.6)
- assert l.rotation == 0
-
-
-def test_AMLowerLeftLinePrimitive_dump():
- l = AMLowerLeftLinePrimitive.from_gerber("22,1,6.8,1.2,3.4,0.6,0*")
- assert l.to_gerber() == "22,1,6.8,1.2,3.4,0.6,0.0*"
-
-
-def test_AMLowerLeftLinePrimitive_conversion():
- l = AMLowerLeftLinePrimitive(22, "on", 25.4, 25.4, (25.4, 25.4), 0)
- l.to_inch()
- assert l.width == 1.0
- assert l.height == 1.0
- assert l.lower_left == (1.0, 1.0)
-
- l = AMLowerLeftLinePrimitive(22, "on", 1, 1, (1, 1), 0)
- l.to_metric()
- assert l.width == 25.4
- assert l.height == 25.4
- assert l.lower_left == (25.4, 25.4)
-
-
-def test_AMUnsupportPrimitive():
- u = AMUnsupportPrimitive.from_gerber("Test")
- assert u.primitive == "Test"
- u = AMUnsupportPrimitive("Test")
- assert u.to_gerber() == "Test"
-
-
-def test_AMUnsupportPrimitive_smoketest():
- u = AMUnsupportPrimitive.from_gerber("Test")
- u.to_inch()
- u.to_metric()
-
-
-def test_inch():
- assert inch(25.4) == 1
-
-
-def test_metric():
- assert metric(1) == 25.4
diff --git a/gerbonara/gerber/tests/test_cairo_backend.py b/gerbonara/gerber/tests/test_cairo_backend.py
deleted file mode 100644
index b8c9676..0000000
--- a/gerbonara/gerber/tests/test_cairo_backend.py
+++ /dev/null
@@ -1,382 +0,0 @@
-#! /usr/bin/env python
-# -*- coding: utf-8 -*-
-
-# Author: Garret Fick <garret@ficksworkshop.com>
-import os
-import shutil
-import io
-import tempfile
-import uuid
-import cv2
-from pathlib import Path
-
-import pytest
-from PIL import Image
-import numpy as np
-
-from ..render.cairo_backend import GerberCairoContext
-from ..rs274x import read
-
-class Tempdir:
- def __init__(self):
- self.path = tempfile.mkdtemp(prefix='gerbonara-test-')
- self.delete = True
-
- def cleanup(self):
- if self.delete:
- shutil.rmtree(self.path)
-
- def create(self, prefix='fail-', suffix=''):
- return Path(self.path) / f'{prefix}{uuid.uuid4()}{suffix}'
-
- def keep(self):
- self.delete = False
-
-output_dir = Tempdir()
-@pytest.fixture(scope='session', autouse=True)
-def cleanup(request):
- global output_dir
- request.addfinalizer(output_dir.cleanup)
-
-def test_render_two_boxes():
- """Umaco exapmle of two boxes"""
- _test_render("resources/example_two_square_boxes.gbr", "golden/example_two_square_boxes.png")
-
-
-def test_render_simple_contour():
- """Umaco exapmle of a simple arrow-shaped contour"""
- gerber = _test_render("resources/example_simple_contour.gbr", "golden/example_simple_contour.png")
-
- # Check the resulting dimensions
- assert ((2.0, 11.0), (1.0, 9.0)) == gerber.bounding_box
-
-
-def test_render_single_contour_1():
- """Umaco example of a single contour
-
- The resulting image for this test is used by other tests because they must generate the same output."""
- _test_render(
- "resources/example_single_contour_1.gbr", "golden/example_single_contour.png",
- 0.001 # TODO: It looks like we have some aliasing artifacts here. Make sure this is not caused by an actual error.
- )
-
-
-def test_render_single_contour_2():
- """Umaco exapmle of a single contour, alternate contour end order
-
- The resulting image for this test is used by other tests because they must generate the same output."""
- _test_render(
- "resources/example_single_contour_2.gbr", "golden/example_single_contour.png",
- 0.001 # TODO: It looks like we have some aliasing artifacts here. Make sure this is not caused by an actual error.
- )
-
-
-def test_render_single_contour_3():
- """Umaco exapmle of a single contour with extra line"""
- _test_render(
- "resources/example_single_contour_3.gbr", "golden/example_single_contour_3.png",
- 0.001 # TODO: It looks like we have some aliasing artifacts here. Make sure this is not caused by an actual error.
- )
-
-
-def test_render_not_overlapping_contour():
- """Umaco example of D02 staring a second contour"""
- _test_render(
- "resources/example_not_overlapping_contour.gbr",
- "golden/example_not_overlapping_contour.png",
- )
-
-
-def test_render_not_overlapping_touching():
- """Umaco example of D02 staring a second contour"""
- _test_render(
- "resources/example_not_overlapping_touching.gbr",
- "golden/example_not_overlapping_touching.png",
- )
-
-
-def test_render_overlapping_touching():
- """Umaco example of D02 staring a second contour"""
- _test_render(
- "resources/example_overlapping_touching.gbr",
- "golden/example_overlapping_touching.png",
- )
-
-
-def test_render_overlapping_contour():
- """Umaco example of D02 staring a second contour"""
- _test_render("resources/example_overlapping_contour.gbr", "golden/example_overlapping_contour.png")
-
-
-def test_render_level_holes():
- """Umaco example of using multiple levels to create multiple holes"""
- _test_render("resources/example_level_holes.gbr", "golden/example_level_holes.png",
- autocrop_golden=True, auto_contrast=True, scale=50, max_delta=0.005)
-
-
-def test_render_cutin():
- """Umaco example of using a cutin"""
- _test_render("resources/example_cutin.gbr", "golden/example_cutin.png",
- autocrop_golden=True, auto_contrast=True, max_delta=0.005)
-
-
-def test_render_fully_coincident():
- """Umaco example of coincident lines rendering two contours"""
-
- _test_render(
- "resources/example_fully_coincident.gbr", "golden/example_fully_coincident.png"
- )
-
-
-def test_render_coincident_hole():
- """Umaco example of coincident lines rendering a hole in the contour"""
-
- _test_render(
- "resources/example_coincident_hole.gbr", "golden/example_coincident_hole.png"
- )
-
-
-def test_render_cutin_multiple():
- """Umaco example of a region with multiple cutins"""
-
- _test_render(
- "resources/example_cutin_multiple.gbr", "golden/example_cutin_multiple.png"
- )
-
-
-def test_flash_circle():
- """Umaco example a simple circular flash with and without a hole"""
-
- _test_render(
- "resources/example_flash_circle.gbr",
- "golden/example_flash_circle.png",
- )
-
-
-def test_flash_rectangle():
- """Umaco example a simple rectangular flash with and without a hole"""
-
- _test_render(
- "resources/example_flash_rectangle.gbr", "golden/example_flash_rectangle.png"
- )
-
-
-def test_flash_obround():
- """Umaco example a simple obround flash with and without a hole"""
-
- _test_render(
- "resources/example_flash_obround.gbr", "golden/example_flash_obround.png"
- )
-
-
-def test_flash_polygon():
- """Umaco example a simple polygon flash with and without a hole"""
-
- _test_render(
- "resources/example_flash_polygon.gbr", "golden/example_flash_polygon.png"
- )
-
-
-def test_holes_dont_clear():
- """Umaco example that an aperture with a hole does not clear the area"""
-
- _test_render(
- "resources/example_holes_dont_clear.gbr", "golden/example_holes_dont_clear.png"
- )
-
-
-def test_render_am_exposure_modifier():
- """Umaco example that an aperture macro with a hole does not clear the area"""
-
- _test_render(
- "resources/example_am_exposure_modifier.gbr",
- "golden/example_am_exposure_modifier.png",
- scale = 50,
- autocrop_golden = True,
- auto_contrast = True,
- max_delta = 0.005 # Take artifacts due to differences in anti-aliasing and thresholding into account
- )
-
-
-def test_render_svg_simple_contour():
- """Example of rendering to an SVG file"""
- _test_simple_render_svg("resources/example_simple_contour.gbr")
-
-
-def test_fine_lines_x():
- """ Regression test for pcb-tools upstream PR #199 """
- for fn, axis in [
- ('resources/test_fine_lines_x.gbr', 1),
- ('resources/test_fine_lines_y.gbr', 0) ]:
- gerber_path = _resolve_path(fn)
-
- gerber = read(gerber_path)
-
- # Create PNG image to the memory stream
- ctx = GerberCairoContext(scale=51)
- gerber.render(ctx)
-
- global output_dir
- with output_dir.create(suffix='.png') as outfile:
- actual_bytes = ctx.dump(outfile)
-
- out = Image.open(outfile)
- out = np.array(out)
- out = out.astype(float).mean(axis=2) / 255
-
- means = out[10:-10,10:-10].mean(axis=axis)
-
- print(means.mean(), means.max(), means.min(), means.std())
- #print(means_y.mean(), means_y.max(), means_y.min(), means_y.std())
- # means_x broken: 0.3240598567858516 0.3443678513071895 0.2778033088235294 0.017457710324088545
- # means_x fixed: 0.33338519639882097 0.3443678513071895 0.3183159722222221 0.006792500045785638
- # means_y broken: 0.3240598567858518 0.3247468922209409 0.1660387030629246 0.009953477851147686
- # means_y fixed: 0.33338519639882114 0.3340766371908242 0.17388184031782652 0.010043400730860096
-
- assert means.std() < 0.01
-
-# TODO add scale tests: Export with different scale=... params and compare against bitmap-scaled reference image
-
-def _resolve_path(path):
- return os.path.join(os.path.dirname(__file__), path)
-
-def images_match(reference, output, max_delta, autocrop_golden=False, auto_contrast=False):
- global output_dir
-
- ref, out = Image.open(reference), Image.open(output)
- if ref.mode == 'P': # palette mode
- ref = ref.convert('RGB')
-
- ref, out = np.array(ref), np.array(out)
- # convert to grayscale
- ref, out = ref.astype(float).mean(axis=2), out.astype(float).mean(axis=2)
-
- if autocrop_golden:
- rows = ref.sum(axis=1)
- cols = ref.sum(axis=0)
-
- x0 = np.argmax(cols > 0)
- y0 = np.argmax(rows > 0)
- x1 = len(cols) - np.argmax(cols[::-1] > 0)
- y1 = len(rows) - np.argmax(rows[::-1] > 0)
- print(f'{x0=} {y0=} {x1=} {y1=}')
-
- ref = ref[y0:y1, x0:x1]
- ref = cv2.resize(ref, dsize=out.shape[::-1], interpolation=cv2.INTER_LINEAR)
-
- def print_stats(name, ref):
- print(name, 'stats:', ref.min(), ref.mean(), ref.max(), 'std:', ref.std())
-
- if auto_contrast:
- print_stats('ref pre proc', ref)
- print_stats('out pre proc', out)
-
- ref -= ref.min()
- ref /= ref.max() + 1e-9
- ref *= 255
-
- out -= out.min()
- out /= out.max() + 1e-9
- out *= 255
-
- def write_refout():
- nonlocal autocrop_golden, ref
- if autocrop_golden:
- global output_dir
- with output_dir.create(suffix='.png') as ref_out:
- cv2.imwrite(str(ref_out), ref)
- print('Processed reference image:', ref_out)
-
- if ref.shape != out.shape:
- print(f'Rendering image size mismatch: {ref.shape} != {out.shape}')
- print(f'Reference image: {Path(reference).absolute()}')
- print(f'Actual output: {output}')
-
- write_refout()
- output_dir.keep()
-
- return False
-
- delta = np.abs(out - ref).astype(float) / 255
-
- if delta.mean() > max_delta:
- print(f'Renderings mismatch: {delta.mean()=}, {max_delta=}')
- print(f'Reference image: {Path(reference).absolute()}')
- print(f'Actual output: {output}')
- with output_dir.create(suffix='.png') as proc_out:
- cv2.imwrite(str(proc_out), out)
- print('Processed output image:', proc_out)
- print_stats('reference', ref)
- print_stats('actual', out)
-
- write_refout()
- output_dir.keep()
-
- return False
-
- return True
-
-
-def _test_render(gerber_path, png_expected_path, max_delta=1e-6, scale=300, autocrop_golden=False, auto_contrast=False):
- """Render the gerber file and compare to the expected PNG output.
-
- Parameters
- ----------
- gerber_path : string
- Path to Gerber file to open
- png_expected_path : string
- Path to the PNG file to compare to
- create_output : string|None
- If not None, write the generated PNG to the specified path.
- This is primarily to help with
- """
-
- gerber_path = _resolve_path(gerber_path)
- png_expected_path = _resolve_path(png_expected_path)
-
- gerber = read(gerber_path)
-
- # Create PNG image to the memory stream
- ctx = GerberCairoContext(scale=scale)
- gerber.render(ctx)
-
- global output_dir
- with output_dir.create(suffix='.png') as outfile:
- actual_bytes = ctx.dump(outfile)
-
- assert images_match(png_expected_path, outfile, max_delta, autocrop_golden, auto_contrast)
-
- return gerber
-
-
-def _test_simple_render_svg(gerber_path):
- """Render the gerber file as SVG
-
- Note: verifies only the header, not the full content.
-
- Parameters
- ----------
- gerber_path : string
- Path to Gerber file to open
- """
-
- gerber_path = _resolve_path(gerber_path)
- gerber = read(gerber_path)
-
- # Create SVG image to the memory stream
- ctx = GerberCairoContext()
- gerber.render(ctx)
-
- temp_dir = tempfile.mkdtemp()
- svg_temp_path = os.path.join(temp_dir, "output.svg")
-
- assert not os.path.exists(svg_temp_path)
- ctx.dump(svg_temp_path)
- assert os.path.exists(svg_temp_path)
-
- with open(svg_temp_path, "r") as expected_file:
- expected_bytes = expected_file.read()
- assert expected_bytes[:38] == '<?xml version="1.0" encoding="UTF-8"?>'
-
- shutil.rmtree(temp_dir)
-
diff --git a/gerbonara/gerber/tests/test_gerber_statements.py b/gerbonara/gerber/tests/test_gerber_statements.py
index 140cbd1..17e2591 100644
--- a/gerbonara/gerber/tests/test_gerber_statements.py
+++ b/gerbonara/gerber/tests/test_gerber_statements.py
@@ -413,84 +413,29 @@ def test_AMParamStmt_factory():
1,1,1.5,0,0*
20,1,0.9,0,0.45,12,0.45,0*
21,1,6.8,1.2,3.4,0.6,0*
-22,1,6.8,1.2,0,0,0*
4,1,4,0.1,0.1,0.5,0.1,0.5,0.5,0.1,0.5,0.1,0.1,0*
5,1,8,0,0,8,0*
-6,0,0,5,0.5,0.5,2,0.1,6,0*
7,0,0,7,6,0.2,0*
-8,THIS IS AN UNSUPPORTED PRIMITIVE*
"""
- s = AMParamStmt.from_dict({"param": "AM", "name": name, "macro": macro})
- s.build()
+ s = AMParamStmt.from_dict({"param": "AM", "name": name, "macro": macro}, units='mm')
assert len(s.primitives) == 10
assert isinstance(s.primitives[0], AMCommentPrimitive)
assert isinstance(s.primitives[1], AMCirclePrimitive)
assert isinstance(s.primitives[2], AMVectorLinePrimitive)
assert isinstance(s.primitives[3], AMCenterLinePrimitive)
- assert isinstance(s.primitives[4], AMLowerLeftLinePrimitive)
assert isinstance(s.primitives[5], AMOutlinePrimitive)
assert isinstance(s.primitives[6], AMPolygonPrimitive)
- assert isinstance(s.primitives[7], AMMoirePrimitive)
assert isinstance(s.primitives[8], AMThermalPrimitive)
- assert isinstance(s.primitives[9], AMUnsupportPrimitive)
-
-
-def testAMParamStmt_conversion():
- name = "POLYGON"
- macro = "5,1,8,25.4,25.4,25.4,0*"
- s = AMParamStmt.from_dict({"param": "AM", "name": name, "macro": macro})
-
- s.build()
- s.units = "metric"
-
- # No effect
- s.to_metric()
- assert s.primitives[0].position == (25.4, 25.4)
- assert s.primitives[0].diameter == 25.4
-
- s.to_inch()
- assert s.units == "inch"
- assert s.primitives[0].position == (1.0, 1.0)
- assert s.primitives[0].diameter == 1.0
-
- # No effect
- s.to_inch()
- assert s.primitives[0].position == (1.0, 1.0)
- assert s.primitives[0].diameter == 1.0
-
- macro = "5,1,8,1,1,1,0*"
- s = AMParamStmt.from_dict({"param": "AM", "name": name, "macro": macro})
- s.build()
- s.units = "inch"
-
- # No effect
- s.to_inch()
- assert s.primitives[0].position == (1.0, 1.0)
- assert s.primitives[0].diameter == 1.0
-
- s.to_metric()
- assert s.units == "metric"
- assert s.primitives[0].position == (25.4, 25.4)
- assert s.primitives[0].diameter == 25.4
-
- # No effect
- s.to_metric()
- assert s.primitives[0].position == (25.4, 25.4)
- assert s.primitives[0].diameter == 25.4
def test_AMParamStmt_dump():
name = "POLYGON"
macro = "5,1,8,25.4,25.4,25.4,0.0"
- s = AMParamStmt.from_dict({"param": "AM", "name": name, "macro": macro})
- s.build()
+ s = AMParamStmt.from_dict({"param": "AM", "name": name, "macro": macro}, units='mm')
assert s.to_gerber() == "%AMPOLYGON*5,1,8,25.4,25.4,25.4,0.0*%"
# TODO - Store Equations and update on unit change...
- s = AMParamStmt.from_dict(
- {"param": "AM", "name": "OC8", "macro": "5,1,8,0,0,1.08239X$1,22.5"}
- )
- s.build()
+ s = AMParamStmt.from_dict({"param": "AM", "name": "OC8", "macro": "5,1,8,0,0,1.08239X$1,22.5"}, units='mm')
# assert_equal(s.to_gerber(), '%AMOC8*5,1,8,0,0,1.08239X$1,22.5*%')
assert s.to_gerber() == "%AMOC8*5,1,8,0,0,0,22.5*%"
@@ -498,8 +443,7 @@ def test_AMParamStmt_dump():
def test_AMParamStmt_string():
name = "POLYGON"
macro = "5,1,8,25.4,25.4,25.4,0*"
- s = AMParamStmt.from_dict({"param": "AM", "name": name, "macro": macro})
- s.build()
+ s = AMParamStmt.from_dict({"param": "AM", "name": name, "macro": macro}, units='mm')
assert str(s) == "<Aperture Macro POLYGON: 5,1,8,25.4,25.4,25.4,0*>"
diff --git a/gerbonara/gerber/utils.py b/gerbonara/gerber/utils.py
index 3d39df9..c2e81cd 100644
--- a/gerbonara/gerber/utils.py
+++ b/gerbonara/gerber/utils.py
@@ -29,7 +29,7 @@ from math import radians, sin, cos, sqrt, atan2, pi
MILLIMETERS_PER_INCH = 25.4
-def parse_gerber_value(value, format=(2, 5), zero_suppression='trailing'):
+def parse_gerber_value(value, settings):
""" Convert gerber/excellon formatted string to floating-point number
.. note::
@@ -60,12 +60,16 @@ def parse_gerber_value(value, format=(2, 5), zero_suppression='trailing'):
The specified value as a floating-point number.
"""
+
+ if value is None:
+ return None
+
# Handle excellon edge case with explicit decimal. "That was easy!"
if '.' in value:
return float(value)
# Format precision
- integer_digits, decimal_digits = format
+ integer_digits, decimal_digits = settings.format
MAX_DIGITS = integer_digits + decimal_digits
# Absolute maximum number of digits supported. This will handle up to
@@ -82,9 +86,9 @@ def parse_gerber_value(value, format=(2, 5), zero_suppression='trailing'):
missing_digits = MAX_DIGITS - len(value)
- if zero_suppression == 'trailing':
+ if settings.zero_suppression == 'trailing':
digits = list(value + ('0' * missing_digits))
- elif zero_suppression == 'leading':
+ elif settings.zero_suppression == 'leading':
digits = list(('0' * missing_digits) + value)
else:
digits = list(value)
@@ -94,7 +98,7 @@ def parse_gerber_value(value, format=(2, 5), zero_suppression='trailing'):
return -result if negative else result
-def write_gerber_value(value, format=(2, 5), zero_suppression='trailing'):
+def write_gerber_value(value, settings):
""" Convert a floating point number to a Gerber/Excellon-formatted string.
.. note::
@@ -128,7 +132,7 @@ def write_gerber_value(value, format=(2, 5), zero_suppression='trailing'):
return "%f" %value
# Format precision
- integer_digits, decimal_digits = format
+ integer_digits, decimal_digits = settings.format
MAX_DIGITS = integer_digits + decimal_digits
if MAX_DIGITS > 13 or integer_digits > 6 or decimal_digits > 7:
@@ -154,10 +158,10 @@ def write_gerber_value(value, format=(2, 5), zero_suppression='trailing'):
return '0'
# Suppression...
- if zero_suppression == 'trailing':
+ if settings.zero_suppression == 'trailing':
while digits and digits[-1] == '0':
digits.pop()
- elif zero_suppression == 'leading':
+ elif settings.zero_suppression == 'leading':
while digits and digits[0] == '0':
digits.pop(0)