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