From 68619d4d5a7beb38dc81d953b43bf4196ca1d3a6 Mon Sep 17 00:00:00 2001 From: Hamilton Kibbe Date: Thu, 5 Mar 2015 22:42:42 -0500 Subject: Fix parsing for multiline ipc-d-356 records --- gerber/ipc356.py | 132 +++++++++++++++++++++++------------ gerber/primitives.py | 63 +++++++++++++++-- gerber/render/cairo_backend.py | 12 +++- gerber/render/render.py | 6 +- gerber/tests/resources/ipc-d-356.ipc | 1 + gerber/tests/test_ipc356.py | 2 +- gerber/tests/test_primitives.py | 4 +- gerber/utils.py | 53 ++++++++++++++ 8 files changed, 218 insertions(+), 55 deletions(-) diff --git a/gerber/ipc356.py b/gerber/ipc356.py index 2b6f1f6..1762480 100644 --- a/gerber/ipc356.py +++ b/gerber/ipc356.py @@ -18,7 +18,8 @@ import math import re -from .cam import FileSettings +from .cam import CamFile, FileSettings +from .primitives import TestRecord # Net Name Variables _NNAME = re.compile(r'^NNAME\d+$') @@ -44,7 +45,7 @@ def read(filename): return IPC_D_356.from_file(filename) -class IPC_D_356(object): +class IPC_D_356(CamFile): @classmethod def from_file(self, filename): @@ -52,10 +53,12 @@ class IPC_D_356(object): return p.parse(filename) - def __init__(self, statements, settings): + def __init__(self, statements, settings, primitives=None): self.statements = statements self.units = settings.units self.angle_units = settings.angle_units + self.primitives = [TestRecord((rec.x_coord, rec.y_coord), rec.net_name, + rec.access) for rec in self.test_records] @property def settings(self): @@ -98,6 +101,19 @@ class IPC_D_356(object): else: return None + + def render(self, ctx, layer='both', filename=None): + for p in self.primitives: + if layer == 'both' and p.layer in ('top', 'bottom', 'both'): + ctx.render(p) + elif layer == 'top' and p.layer in ('top', 'both'): + ctx.render(p) + elif layer == 'bottom' and p.layer in ('bottom', 'both'): + ctx.render(p) + if filename is not None: + ctx.dump(filename) + + class IPC_D_356_Parser(object): # TODO: Allow multi-line statements (e.g. Altium board edge) def __init__(self): @@ -112,51 +128,68 @@ class IPC_D_356_Parser(object): def parse(self, filename): with open(filename, 'r') as f: + oldline = '' for line in f: - - if line[0] == 'C': - # Comment - self.statements.append(IPC356_Comment.from_line(line)) - - elif line[0] == 'P': - # Parameter - p = IPC356_Parameter.from_line(line) - if p.parameter == 'UNITS': - if p.value in ('CUST', 'CUST 0'): - self.units = 'inch' - self.angle_units = 'degrees' - elif p.value == 'CUST 1': - self.units = 'metric' - self.angle_units = 'degrees' - elif p.value == 'CUST 2': - self.units = 'inch' - self.angle_units = 'radians' - self.statements.append(p) - if _NNAME.match(p.parameter): - # Add to list of net name variables - self.nnames[p.parameter] = p.value - - elif line[0] == '3' and line[2] == '7': - # Test Record - record = IPC356_TestRecord.from_line(line, self.settings) - - # Substitute net name variables - net = record.net_name - if (_NNAME.match(net) and net in self.nnames.keys()): - record.net_name = self.nnames[record.net_name] - self.statements.append(record) - - elif line[0:3] == '389': - # Altium Board Edge Info - self.statements.append(IPC356_BoardEdge.from_line(line, self.settings)) - - elif line[0] == '9': - self.multiline = False - self.statements.append(IPC356_EndOfFile()) + # Check for existing multiline data... + if oldline != '': + if len(line) and line[0] == '0': + oldline = oldline.rstrip('\r\n') + line[3:].rstrip() + else: + self._parse_line(oldline) + oldline = line + else: + oldline = line + self._parse_line(oldline) return IPC_D_356(self.statements, self.settings) + def _parse_line(self, line): + if not len(line): + return + if line[0] == 'C': + # Comment + self.statements.append(IPC356_Comment.from_line(line)) + + elif line[0] == 'P': + # Parameter + p = IPC356_Parameter.from_line(line) + if p.parameter == 'UNITS': + if p.value in ('CUST', 'CUST 0'): + self.units = 'inch' + self.angle_units = 'degrees' + elif p.value == 'CUST 1': + self.units = 'metric' + self.angle_units = 'degrees' + elif p.value == 'CUST 2': + self.units = 'inch' + self.angle_units = 'radians' + self.statements.append(p) + if _NNAME.match(p.parameter): + # Add to list of net name variables + self.nnames[p.parameter] = p.value + + elif line[0] == '9': + self.statements.append(IPC356_EndOfFile()) + + elif line[0:3] in ('317', '327', '367'): + # Test Record + record = IPC356_TestRecord.from_line(line, self.settings) + + # Substitute net name variables + net = record.net_name + if (_NNAME.match(net) and net in self.nnames.keys()): + record.net_name = self.nnames[record.net_name] + self.statements.append(record) + + elif line[0:3] == '379': + # Net Adjacency Info + pass + elif line[0:3] == '389': + # Altium Board Edge Info + self.statements.append(IPC356_BoardEdge.from_line(line, self.settings)) + + class IPC356_Comment(object): @classmethod def from_line(cls, line): @@ -302,6 +335,19 @@ class IPC356_BoardEdge(object): return '' +class IPC356_Adjacency(object): + + @classmethod + def from_line(cls, line): + nets = line.strip().split()[1:] + return cls(nets) + + def __init__(self, nets): + self.nets = nets + + def __repr__(self): + return '' + class IPC356_EndOfFile(object): def __init__(self): diff --git a/gerber/primitives.py b/gerber/primitives.py index 3469880..5d0b8cf 100644 --- a/gerber/primitives.py +++ b/gerber/primitives.py @@ -713,8 +713,7 @@ class Donut(Primitive): """ def __init__(self, position, shape, inner_diameter, outer_diameter, **kwargs): super(Donut, self).__init__(**kwargs) - if len(position) != 2: - raise TypeError('Position must be a tuple (n=2) of coordinates') + validate_coordinates(position) self.position = position if shape not in ('round', 'square', 'hexagon', 'octagon'): raise ValueError('Valid shapes are round, square, hexagon or octagon') @@ -731,7 +730,6 @@ class Donut(Primitive): self.width = 0.5 * math.sqrt(3.) * outer_diameter self.height = outer_diameter - @property def lower_left(self): return (self.position[0] - (self.width / 2.), @@ -755,14 +753,56 @@ class Donut(Primitive): self.width = inch(self.width) self.height = inch(self.height) self.inner_diameter = inch(self.inner_diameter) - self.outer_diaemter = inch(self.outer_diameter) + self.outer_diameter = inch(self.outer_diameter) def to_metric(self): self.position = tuple(map(metric, self.position)) self.width = metric(self.width) self.height = metric(self.height) self.inner_diameter = metric(self.inner_diameter) - self.outer_diaemter = metric(self.outer_diameter) + self.outer_diameter = metric(self.outer_diameter) + + def offset(self, x_offset=0, y_offset=0): + self.position = tuple(map(add, self.position, (x_offset, y_offset))) + + +class SquareRoundDonut(Primitive): + """ A Square with a circular cutout in the center + """ + def __init__(self, position, inner_diameter, outer_diameter, **kwargs): + super(SquareRoundDonut, self).__init__(**kwargs) + validate_coordinates(position) + self.position = position + if inner_diameter >= outer_diameter: + raise ValueError('Outer diameter must be larger than inner diameter.') + self.inner_diameter = inner_diameter + self.outer_diameter = outer_diameter + + @property + def lower_left(self): + return tuple([c - self.outer_diameter / 2. for c in self.position]) + + @property + def upper_right(self): + return tuple([c + self.outer_diameter / 2. for c in self.position]) + + @property + def bounding_box(self): + min_x = self.lower_left[0] + max_x = self.upper_right[0] + min_y = self.lower_left[1] + max_y = self.upper_right[1] + return ((min_x, max_x), (min_y, max_y)) + + def to_inch(self): + self.position = tuple(map(inch, self.position)) + self.inner_diameter = inch(self.inner_diameter) + self.outer_diameter = inch(self.outer_diameter) + + def to_metric(self): + self.position = tuple(map(metric, self.position)) + self.inner_diameter = metric(self.inner_diameter) + self.outer_diameter = metric(self.outer_diameter) def offset(self, x_offset=0, y_offset=0): self.position = tuple(map(add, self.position, (x_offset, y_offset))) @@ -773,8 +813,7 @@ class Drill(Primitive): """ def __init__(self, position, diameter): super(Drill, self).__init__('dark') - if len(position) != 2: - raise TypeError('Position must be a tuple (n=2) of coordinates') + validate_coordinates(position) self.position = position self.diameter = diameter @@ -801,3 +840,13 @@ class Drill(Primitive): def offset(self, x_offset=0, y_offset=0): self.position = tuple(map(add, self.position, (x_offset, y_offset))) +class TestRecord(Primitive): + """ Netlist Test record + """ + 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 + diff --git a/gerber/render/cairo_backend.py b/gerber/render/cairo_backend.py index 326f44e..fa1aecc 100644 --- a/gerber/render/cairo_backend.py +++ b/gerber/render/cairo_backend.py @@ -104,7 +104,7 @@ class GerberCairoContext(GerberContext): self.ctx.fill() def _render_circle(self, circle, color): - center = map(mul, circle.position, self.scale) + center = tuple(map(mul, circle.position, self.scale)) self.ctx.set_source_rgba(*color, alpha=self.alpha) self.ctx.set_line_width(0) self.ctx.arc(*center, radius=circle.radius * SCALE, angle1=0, angle2=2 * math.pi) @@ -126,5 +126,15 @@ class GerberCairoContext(GerberContext): def _render_drill(self, circle, color): self._render_circle(circle, color) + def _render_test_record(self, primitive, color): + self.ctx.select_font_face('monospace', cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL) + self.ctx.set_font_size(200) + self._render_circle(Circle(primitive.position, 0.01), color) + self.ctx.set_source_rgb(*color) + self.ctx.move_to(*[SCALE * (coord + 0.01) for coord in primitive.position]) + self.ctx.scale(1, -1) + self.ctx.show_text(primitive.net_name) + self.ctx.scale(1, -1) + def dump(self, filename): self.surface.write_to_png(filename) diff --git a/gerber/render/render.py b/gerber/render/render.py index 2e4abfa..68c2115 100644 --- a/gerber/render/render.py +++ b/gerber/render/render.py @@ -138,9 +138,11 @@ class GerberContext(object): elif isinstance(primitive, Obround): self._render_obround(primitive, color) elif isinstance(primitive, Polygon): - self._render_polygon(Polygon, color) + self._render_polygon(primitive, color) elif isinstance(primitive, Drill): self._render_drill(primitive, self.drill_color) + elif isinstance(primitive, TestRecord): + self._render_test_record(primitive, color) else: return @@ -168,3 +170,5 @@ class GerberContext(object): def _render_drill(self, primitive, color): pass + def _render_test_record(self, primitive, color): + pass diff --git a/gerber/tests/resources/ipc-d-356.ipc b/gerber/tests/resources/ipc-d-356.ipc index b0086c9..2ed3f49 100644 --- a/gerber/tests/resources/ipc-d-356.ipc +++ b/gerber/tests/resources/ipc-d-356.ipc @@ -111,4 +111,5 @@ P NNAME1 A_REALLY_LONG_NET_NAME 327VCC U4 -8 A01X 8396Y 3850X 394Y 500R 0 327NNAME1 NA -69 A01X 8396Y 3850X 394Y 500R 0 389BOARD_EDGE X0Y0 X22500 Y15000 X0 +089 X1300Y240 999 diff --git a/gerber/tests/test_ipc356.py b/gerber/tests/test_ipc356.py index 760608c..88726a5 100644 --- a/gerber/tests/test_ipc356.py +++ b/gerber/tests/test_ipc356.py @@ -25,7 +25,7 @@ def test_parser(): assert_equal(len(ipcfile.vias), 14) assert_equal(ipcfile.test_records[-1].net_name, 'A_REALLY_LONG_NET_NAME') assert_equal(set(ipcfile.board_outline), - {(0., 0.), (2.25, 0.), (2.25, 1.5), (0., 1.5)}) + {(0., 0.), (2.25, 0.), (2.25, 1.5), (0., 1.5), (0.13, 0.024)}) def test_comment(): c = IPC356_Comment('Layer Stackup:') diff --git a/gerber/tests/test_primitives.py b/gerber/tests/test_primitives.py index 2909d8f..f3b1189 100644 --- a/gerber/tests/test_primitives.py +++ b/gerber/tests/test_primitives.py @@ -681,13 +681,13 @@ def test_donut_conversion(): d.to_inch() assert_equal(d.position, (0.1, 1.0)) assert_equal(d.inner_diameter, 10.0) - assert_equal(d.outer_diaemter, 100.0) + assert_equal(d.outer_diameter, 100.0) d = Donut((0.1, 1.0), 'round', 10.0, 100.0) d.to_metric() assert_equal(d.position, (2.54, 25.4)) assert_equal(d.inner_diameter, 254.0) - assert_equal(d.outer_diaemter, 2540.0) + assert_equal(d.outer_diameter, 2540.0) def test_donut_offset(): d = Donut((0, 0), 'round', 1, 10) diff --git a/gerber/utils.py b/gerber/utils.py index 8cd4965..1c43550 100644 --- a/gerber/utils.py +++ b/gerber/utils.py @@ -26,6 +26,9 @@ files. # Author: Hamilton Kibbe # License: +from math import radians, sin, cos +from operator import sub + MILLIMETERS_PER_INCH = 25.4 def parse_gerber_value(value, format=(2, 5), zero_suppression='trailing'): @@ -238,7 +241,57 @@ def validate_coordinates(position): def metric(value): + """ Convert inch value to millimeters + + Parameters + ---------- + value : float + A value in inches. + + Returns + ------- + value : float + The equivalent value expressed in millimeters. + """ return value * MILLIMETERS_PER_INCH def inch(value): + """ Convert millimeter value to inches + + Parameters + ---------- + value : float + A value in millimeters. + + Returns + ------- + value : float + The equivalent value expressed in inches. + """ return value / MILLIMETERS_PER_INCH + + +def rotate_point(point, angle, center=(0.0, 0.0)): + """ Rotate a point about another point. + + Parameters + ----------- + point : tuple(, ) + Point to rotate about origin or center point + + angle : float + Angle to rotate the point [degrees] + + center : tuple(, ) + Coordinates about which the point is rotated. Defaults to the origin. + + Returns + ------- + rotated_point : tuple(, ) + `point` rotated about `center` by `angle` degrees. + """ + angle = radians(angle) + xdelta, ydelta = tuple(map(sub, point, center)) + x = center[0] + (cos(angle) * xdelta) - (sin(angle) * ydelta) + y = center[1] + (sin(angle) * xdelta) - (cos(angle) * ydelta) + return (x, y) -- cgit