From 68619d4d5a7beb38dc81d953b43bf4196ca1d3a6 Mon Sep 17 00:00:00 2001
From: Hamilton Kibbe <hamilton.kibbe@gmail.com>
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 '<IPC-D-356 Board Edge Definition>'
 
 
+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 '<IPC-D-356 Adjacency Record>'
+
 
 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 <ham@hamiltonkib.be>
 # 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(<float>, <float>)
+        Point to rotate about origin or center point
+
+    angle : float
+        Angle to rotate the point [degrees]
+
+    center : tuple(<float>, <float>)
+        Coordinates about which the point is rotated. Defaults to the origin.
+
+    Returns
+    -------
+    rotated_point : tuple(<float>, <float>)
+        `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