diff options
Diffstat (limited to 'gerbonara')
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) |