diff options
-rw-r--r-- | gerber/ipc356.py | 132 | ||||
-rw-r--r-- | gerber/primitives.py | 63 | ||||
-rw-r--r-- | gerber/render/cairo_backend.py | 12 | ||||
-rw-r--r-- | gerber/render/render.py | 6 | ||||
-rw-r--r-- | gerber/tests/resources/ipc-d-356.ipc | 1 | ||||
-rw-r--r-- | gerber/tests/test_ipc356.py | 2 | ||||
-rw-r--r-- | gerber/tests/test_primitives.py | 4 | ||||
-rw-r--r-- | 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) |