From 95de179bb08157c3f6716b0645ec00794acc83e6 Mon Sep 17 00:00:00 2001 From: Hamilton Kibbe Date: Mon, 27 Oct 2014 08:29:43 -0400 Subject: [PATCH 001/186] Fix rendering of 0-width lines (e.g. board outlines) in SVG and Cairo renderer --- gerber/render/cairo_backend.py | 13 ++++--- gerber/render/svgwrite_backend.py | 63 +++---------------------------- 2 files changed, 13 insertions(+), 63 deletions(-) diff --git a/gerber/render/cairo_backend.py b/gerber/render/cairo_backend.py index df513bb..1c69725 100644 --- a/gerber/render/cairo_backend.py +++ b/gerber/render/cairo_backend.py @@ -20,7 +20,7 @@ from operator import mul import cairocffi as cairo import math -SCALE = 300. +SCALE = 400. class GerberCairoContext(GerberContext): @@ -48,8 +48,9 @@ class GerberCairoContext(GerberContext): def _render_line(self, line, color): start = map(mul, line.start, self.scale) end = map(mul, line.end, self.scale) - self.ctx.set_source_rgb (*color) - self.ctx.set_line_width(line.width * SCALE) + width = line.width if line.width != 0 else 0.001 + self.ctx.set_source_rgba(*color, alpha=self.alpha) + self.ctx.set_line_width(width * SCALE) self.ctx.set_line_cap(cairo.LINE_CAP_ROUND) self.ctx.move_to(*start) self.ctx.line_to(*end) @@ -57,7 +58,7 @@ class GerberCairoContext(GerberContext): def _render_region(self, region, color): points = [tuple(map(mul, point, self.scale)) for point in region.points] - self.ctx.set_source_rgb (*color) + self.ctx.set_source_rgba(*color, alpha=self.alpha) self.ctx.set_line_width(0) self.ctx.move_to(*points[0]) for point in points[1:]: @@ -66,7 +67,7 @@ class GerberCairoContext(GerberContext): def _render_circle(self, circle, color): center = map(mul, circle.position, self.scale) - self.ctx.set_source_rgb (*color) + 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) self.ctx.fill() @@ -74,7 +75,7 @@ class GerberCairoContext(GerberContext): def _render_rectangle(self, rectangle, color): ll = map(mul, rectangle.lower_left, self.scale) width, height = tuple(map(mul, (rectangle.width, rectangle.height), map(abs, self.scale))) - self.ctx.set_source_rgb (*color) + self.ctx.set_source_rgba(*color, alpha=self.alpha) self.ctx.set_line_width(0) self.ctx.rectangle(*ll,width=width, height=height) self.ctx.fill() diff --git a/gerber/render/svgwrite_backend.py b/gerber/render/svgwrite_backend.py index 2df87b3..aeb680c 100644 --- a/gerber/render/svgwrite_backend.py +++ b/gerber/render/svgwrite_backend.py @@ -20,7 +20,7 @@ from .render import GerberContext from operator import mul import svgwrite -SCALE = 300 +SCALE = 400. def svg_color(color): @@ -56,9 +56,10 @@ class GerberSvgContext(GerberContext): def _render_line(self, line, color): start = map(mul, line.start, self.scale) end = map(mul, line.end, self.scale) + width = line.width if line.width != 0 else 0.001 aline = self.dwg.line(start=start, end=end, stroke=svg_color(color), - stroke_width=SCALE * line.width, + stroke_width=SCALE * width, stroke_linecap='round') aline.stroke(opacity=self.alpha) self.dwg.add(aline) @@ -91,62 +92,10 @@ class GerberSvgContext(GerberContext): self.dwg.add(arect) def _render_obround(self, obround, color): - x, y = tuple(map(mul, obround.position, self.scale)) - xsize, ysize = tuple(map(mul, (obround.width, obround.height), - self.scale)) - xscale, yscale = self.scale + self._render_circle(obround.subshapes['circle1'], color) + self._render_circle(obround.subshapes['circle2'], color) + self._render_rectangle(obround.subshapes['rectangle'], color) - # Corner case... - if xsize == ysize: - circle = self.dwg.circle(center=(x, y), - r = (xsize / 2.0), - fill=svg_color(color)) - circle.fill(opacity=self.alpha) - self.dwg.add(circle) - - # Horizontal obround - elif xsize > ysize: - rectx = xsize - ysize - recty = ysize - c1 = self.dwg.circle(center=(x - (rectx / 2.0), y), - r = (ysize / 2.0), - fill=svg_color(color)) - - c2 = self.dwg.circle(center=(x + (rectx / 2.0), y), - r = (ysize / 2.0), - fill=svg_color(color)) - - rect = self.dwg.rect(insert=(x, y), - size=(xsize, ysize), - fill=svg_color(color)) - c1.fill(opacity=self.alpha) - c2.fill(opacity=self.alpha) - rect.fill(opacity=self.alpha) - self.dwg.add(c1) - self.dwg.add(c2) - self.dwg.add(rect) - - # Vertical obround - else: - rectx = xsize - recty = ysize - xsize - c1 = self.dwg.circle(center=(x, y - (recty / 2.)), - r = (xsize / 2.), - fill=svg_color(color)) - - c2 = self.dwg.circle(center=(x, y + (recty / 2.)), - r = (xsize / 2.), - fill=svg_color(color)) - - rect = self.dwg.rect(insert=(x, y), - size=(xsize, ysize), - fill=svg_color(color)) - c1.fill(opacity=self.alpha) - c2.fill(opacity=self.alpha) - rect.fill(opacity=self.alpha) - self.dwg.add(c1) - self.dwg.add(c2) - self.dwg.add(rect) def _render_drill(self, circle, color): center = map(mul, circle.position, self.scale) From fdbdbf24a6b706b04b6cbe97c0d74df6f0b57022 Mon Sep 17 00:00:00 2001 From: Paulo Henrique Silva Date: Tue, 28 Oct 2014 11:55:02 -0200 Subject: [PATCH 002/186] Change coveralls and travis badges --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2695cca..07d19d4 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ gerber-tools ============ -![Travis CI Build Status](https://travis-ci.org/hamiltonkibbe/gerber-tools.svg?branch=master) -[![Coverage Status](https://coveralls.io/repos/hamiltonkibbe/gerber-tools/badge.png?branch=master)](https://coveralls.io/r/hamiltonkibbe/gerber-tools?branch=master) +![Travis CI Build Status](https://travis-ci.org/curtacircuitos/pcb-tools.svg?branch=master) +[![Coverage Status](https://coveralls.io/repos/curtacircuitos/pcb-tools/badge.png?branch=master)](https://coveralls.io/r/curtacircuitos/pcb-tools?branch=master) Tools to handle Gerber and Excellon files in Python. @@ -28,4 +28,4 @@ Rendering Examples: ![Composite Top Image](examples/composite_top.png) ###Bottom Composite rendering -![Composite Bottom Image](examples/composite_bottom.png) \ No newline at end of file +![Composite Bottom Image](examples/composite_bottom.png) From af1e6b11d1092e459e7beaea5796736e91c2124a Mon Sep 17 00:00:00 2001 From: Paulo Henrique Silva Date: Tue, 28 Oct 2014 11:55:34 -0200 Subject: [PATCH 003/186] Renamed project --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 07d19d4..a2e3118 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -gerber-tools +pcb-tools ============ ![Travis CI Build Status](https://travis-ci.org/curtacircuitos/pcb-tools.svg?branch=master) [![Coverage Status](https://coveralls.io/repos/curtacircuitos/pcb-tools/badge.png?branch=master)](https://coveralls.io/r/curtacircuitos/pcb-tools?branch=master) From b488ab6af9d7925263b2d0712abfd2ba55dc96d2 Mon Sep 17 00:00:00 2001 From: Hamilton Kibbe Date: Tue, 28 Oct 2014 11:57:36 -0400 Subject: [PATCH 004/186] Doc update Update project name in docs --- doc/source/conf.py | 14 +++++++------- doc/source/documentation/index.rst | 2 +- doc/source/documentation/rs274x.rst | 2 +- doc/source/index.rst | 4 ++-- doc/source/intro.rst | 4 ++-- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/doc/source/conf.py b/doc/source/conf.py index a118546..7a8134e 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -47,8 +47,8 @@ source_suffix = '.rst' master_doc = 'index' # General information about the project. -project = u'Gerber Tools' -copyright = u'2014, Hamilton Kibbe' +project = u'PCB Tools' +copyright = u'2014 Paulo Henrique Silva , Hamilton Kibbe ' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -180,7 +180,7 @@ html_static_path = ['_static'] #html_file_suffix = None # Output file base name for HTML help builder. -htmlhelp_basename = 'GerberToolsdoc' +htmlhelp_basename = 'PCBToolsdoc' # -- Options for LaTeX output --------------------------------------------- @@ -200,7 +200,7 @@ latex_elements = { # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - ('index', 'GerberTools.tex', u'Gerber Tools Documentation', + ('index', 'PCBTools.tex', u'PCB Tools Documentation', u'Hamilton Kibbe', 'manual'), ] @@ -230,7 +230,7 @@ latex_documents = [ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ - ('index', 'gerbertools', u'Gerber Tools Documentation', + ('index', 'pcbtools', u'PCB Tools Documentation', [u'Hamilton Kibbe'], 1) ] @@ -244,8 +244,8 @@ man_pages = [ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - ('index', 'GerberTools', u'Gerber Tools Documentation', - u'Hamilton Kibbe', 'GerberTools', 'One line description of project.', + ('index', 'PCBTools', u'PCB Tools Documentation', + u'Hamilton Kibbe', 'PCBTools', 'Tools for working with PCB CAM files.', 'Miscellaneous'), ] diff --git a/doc/source/documentation/index.rst b/doc/source/documentation/index.rst index 3d8241a..6fbfa94 100644 --- a/doc/source/documentation/index.rst +++ b/doc/source/documentation/index.rst @@ -1,4 +1,4 @@ -Gerber Tools Reference +PCB Tools Reference ====================== .. toctree:: diff --git a/doc/source/documentation/rs274x.rst b/doc/source/documentation/rs274x.rst index bc99519..00094d4 100644 --- a/doc/source/documentation/rs274x.rst +++ b/doc/source/documentation/rs274x.rst @@ -33,5 +33,5 @@ The :mod:`rs274x` module defines the following classes: .. autoclass:: gerber.rs274x.GerberFile :members: -.. autoclass:: gerber.rs274x.GerberParser +.. autoclass:: gerber.rs274x.GerberParser :members: \ No newline at end of file diff --git a/doc/source/index.rst b/doc/source/index.rst index aec8b48..309cf88 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -1,9 +1,9 @@ -.. Gerber Tools documentation master file, created by +.. PCB-tools documentation master file, created by sphinx-quickstart on Sun Sep 28 18:16:46 2014. You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. -Gerber-Tools! +PCB-Tools ======================================== Contents: diff --git a/doc/source/intro.rst b/doc/source/intro.rst index 1982fc8..4db80ad 100644 --- a/doc/source/intro.rst +++ b/doc/source/intro.rst @@ -1,4 +1,4 @@ -Gerber Tools Intro +PCB Tools Intro ================== PCB CAM (Gerber) Files @@ -10,7 +10,7 @@ a generic term that may refer to `RS-274X (Gerber) Date: Tue, 28 Oct 2014 22:11:43 -0400 Subject: [PATCH 005/186] Add arc rendering and tests --- gerber/primitives.py | 63 +++++++++++++++++++++-- gerber/render/cairo_backend.py | 20 +++++++- gerber/render/svgwrite_backend.py | 14 +++++ gerber/tests/test_primitives.py | 85 +++++++++++++++++++++++++++++++ gerber/tests/tests.py | 5 +- 5 files changed, 179 insertions(+), 8 deletions(-) create mode 100644 gerber/tests/test_primitives.py diff --git a/gerber/primitives.py b/gerber/primitives.py index b3869e1..f934f74 100644 --- a/gerber/primitives.py +++ b/gerber/primitives.py @@ -44,8 +44,8 @@ class Line(Primitive): @property def angle(self): - delta_x, delta_y = tuple(map(sub, end, start)) - angle = degrees(math.tan(delta_y/delta_x)) + delta_x, delta_y = tuple(map(sub, self.end, self.start)) + angle = math.atan2(delta_y, delta_x) return angle @property @@ -69,19 +69,72 @@ class Arc(Primitive): self.direction = direction self.width = width + @property + def radius(self): + dy, dx = map(sub, self.start, self.center) + return math.sqrt(dy**2 + dx**2) + @property def start_angle(self): dy, dx = map(sub, self.start, self.center) - return math.atan2(dy, dx) + return math.atan2(dx, dy) @property def end_angle(self): dy, dx = map(sub, self.end, self.center) - return math.atan2(dy, dx) + return math.atan2(dx, dy) + + @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): - pass + 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] + #Shit's about to get ugly... + if self.direction == 'counterclockwise': + # Passes through 0 degrees + if theta0 > theta1: + points.append((self.center[0] + self.radius, self.center[1])) + # Passes through 90 degrees + if theta0 <= math.pi / 2. and (theta1 >= math.pi / 2. or theta1 < theta0): + points.append((self.center[0], self.center[1] + self.radius)) + # Passes through 180 degrees + if theta0 <= math.pi and (theta1 >= math.pi or theta1 < theta0): + points.append((self.center[0] - self.radius, self.center[1])) + # Passes through 270 degrees + if theta0 <= math.pi * 1.5 and (theta1 >= math.pi * 1.5 or theta1 < theta0): + points.append((self.center[0], self.center[1] - self.radius )) + else: + # Passes through 0 degrees + if theta1 > theta0: + points.append((self.center[0] + self.radius, self.center[1])) + # Passes through 90 degrees + if theta1 <= math.pi / 2. and (theta0 >= math.pi / 2. or theta0 < theta1): + points.append((self.center[0], self.center[1] + self.radius)) + # Passes through 180 degrees + if theta1 <= math.pi and (theta0 >= math.pi or theta0 < theta1): + points.append((self.center[0] - self.radius, self.center[1])) + # Passes through 270 degrees + if theta1 <= math.pi * 1.5 and (theta0 >= math.pi * 1.5 or theta0 < theta1): + points.append((self.center[0], self.center[1] - self.radius )) + x, y = zip(*points) + min_x = min(x) + max_x = max(x) + min_y = min(y) + max_y = max(y) + return ((min_x, max_x), (min_y, max_y)) + class Circle(Primitive): """ diff --git a/gerber/render/cairo_backend.py b/gerber/render/cairo_backend.py index 1c69725..125a125 100644 --- a/gerber/render/cairo_backend.py +++ b/gerber/render/cairo_backend.py @@ -56,13 +56,31 @@ class GerberCairoContext(GerberContext): self.ctx.line_to(*end) self.ctx.stroke() + def _render_arc(self, arc, color): + center = map(mul, arc.center, self.scale) + start = map(mul, arc.start, self.scale) + end = map(mul, arc.end, self.scale) + radius = SCALE * arc.radius + angle1 = arc.start_angle + angle2 = arc.end_angle + width = arc.width if arc.width != 0 else 0.001 + self.ctx.set_source_rgba(*color, alpha=self.alpha) + self.ctx.set_line_width(width * SCALE) + self.ctx.set_line_cap(cairo.LINE_CAP_ROUND) + self.ctx.move_to(*start) # You actually have to do this... + if arc.direction == 'counterclockwise': + self.ctx.arc(*center, radius=radius, angle1=angle1, angle2=angle2) + else: + self.ctx.arc_negative(*center, radius=radius, angle1=angle1, angle2=angle2) + self.ctx.move_to(*end) # ...lame + def _render_region(self, region, color): points = [tuple(map(mul, point, self.scale)) for point in region.points] self.ctx.set_source_rgba(*color, alpha=self.alpha) self.ctx.set_line_width(0) self.ctx.move_to(*points[0]) for point in points[1:]: - self.ctx.move_to(*point) + self.ctx.line_to(*point) self.ctx.fill() def _render_circle(self, circle, color): diff --git a/gerber/render/svgwrite_backend.py b/gerber/render/svgwrite_backend.py index aeb680c..27783d6 100644 --- a/gerber/render/svgwrite_backend.py +++ b/gerber/render/svgwrite_backend.py @@ -18,6 +18,7 @@ from .render import GerberContext from operator import mul +import math import svgwrite SCALE = 400. @@ -64,6 +65,19 @@ class GerberSvgContext(GerberContext): aline.stroke(opacity=self.alpha) self.dwg.add(aline) + def _render_arc(self, arc, color): + start = tuple(map(mul, arc.start, self.scale)) + end = tuple(map(mul, arc.end, self.scale)) + radius = SCALE * arc.radius + width = arc.width if arc.width != 0 else 0.001 + arc_path = self.dwg.path(d='M %f, %f' % start, + stroke=svg_color(color), + stroke_width=SCALE * width) + large_arc = arc.sweep_angle >= 2 * math.pi + direction = '-' if arc.direction == 'clockwise' else '+' + arc_path.push_arc(end, 0, radius, large_arc, direction, True) + self.dwg.add(arc_path) + def _render_region(self, region, color): points = [tuple(map(mul, point, self.scale)) for point in region.points] region_path = self.dwg.path(d='M %f, %f' % points[0], diff --git a/gerber/tests/test_primitives.py b/gerber/tests/test_primitives.py new file mode 100644 index 0000000..29036b4 --- /dev/null +++ b/gerber/tests/test_primitives.py @@ -0,0 +1,85 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- + +# Author: Hamilton Kibbe +from ..primitives import * +from tests import * + + + +def test_line_angle(): + """ Test Line primitive angle calculation + """ + cases = [((0, 0), (1, 0), math.radians(0)), + ((0, 0), (1, 1), math.radians(45)), + ((0, 0), (0, 1), math.radians(90)), + ((0, 0), (-1, 1), math.radians(135)), + ((0, 0), (-1, 0), math.radians(180)), + ((0, 0), (-1, -1), math.radians(225)), + ((0, 0), (0, -1), math.radians(270)), + ((0, 0), (1, -1), math.radians(315)),] + for start, end, expected in cases: + l = Line(start, end, 0) + line_angle = (l.angle + 2 * math.pi) % (2 * math.pi) + assert_almost_equal(line_angle, expected) + +def test_line_bounds(): + """ Test Line primitive bounding box calculation + """ + cases = [((0, 0), (1, 1), ((0, 1), (0, 1))), + ((-1, -1), (1, 1), ((-1, 1), (-1, 1))), + ((1, 1), (-1, -1), ((-1, 1), (-1, 1))), + ((-1, 1), (1, -1), ((-1, 1), (-1, 1))),] + for start, end, expected in cases: + l = Line(start, end, 0) + assert_equal(l.bounding_box, expected) + +def test_arc_radius(): + """ Test Arc primitive radius calculation + """ + cases = [((-3, 4), (5, 0), (0, 0), 5), + ((0, 1), (1, 0), (0, 0), 1),] + + for start, end, center, radius in cases: + a = Arc(start, end, center, 'clockwise', 0) + assert_equal(a.radius, radius) + + +def test_arc_sweep_angle(): + """ Test Arc primitive sweep angle calculation + """ + cases = [((1, 0), (0, 1), (0, 0), 'counterclockwise', math.radians(90)), + ((1, 0), (0, 1), (0, 0), 'clockwise', math.radians(270)), + ((1, 0), (-1, 0), (0, 0), 'clockwise', math.radians(180)), + ((1, 0), (-1, 0), (0, 0), 'counterclockwise', math.radians(180)),] + + for start, end, center, direction, sweep in cases: + a = Arc(start, end, center, direction, 0) + assert_equal(a.sweep_angle, sweep) + + +def test_arc_bounds(): + """ Test Arc primitive bounding box calculation + """ + cases = [((1, 0), (0, 1), (0, 0), 'clockwise', ((-1, 1), (-1, 1))), + ((1, 0), (0, 1), (0, 0), 'counterclockwise', ((0, 1), (0, 1))), + #TODO: ADD MORE TEST CASES HERE + ] + + for start, end, center, direction, bounds in cases: + a = Arc(start, end, center, direction, 0) + assert_equal(a.bounding_box, bounds) + +def test_circle_radius(): + """ Test Circle primitive radius calculation + """ + c = Circle((1, 1), 2) + assert_equal(c.radius, 1) + +def test_circle_bounds(): + """ Test Circle bounding box calculation + """ + c = Circle((1, 1), 2) + assert_equal(c.bounding_box, ((0, 2), (0, 2))) + + diff --git a/gerber/tests/tests.py b/gerber/tests/tests.py index 29b7899..222eea3 100644 --- a/gerber/tests/tests.py +++ b/gerber/tests/tests.py @@ -7,6 +7,7 @@ from nose.tools import assert_in from nose.tools import assert_not_in from nose.tools import assert_equal from nose.tools import assert_not_equal +from nose.tools import assert_almost_equal from nose.tools import assert_true from nose.tools import assert_false from nose.tools import assert_raises @@ -14,5 +15,5 @@ from nose.tools import raises from nose import with_setup __all__ = ['assert_in', 'assert_not_in', 'assert_equal', 'assert_not_equal', - 'assert_true', 'assert_false', 'assert_raises', 'raises', - 'with_setup' ] + 'assert_almost_equal', 'assert_true', 'assert_false', + 'assert_raises', 'raises', 'with_setup' ] From ab69ee0172353e64fbe5099a974341e88feaf24b Mon Sep 17 00:00:00 2001 From: Paulo Henrique Silva Date: Mon, 10 Nov 2014 12:24:09 -0200 Subject: [PATCH 006/186] Bunch of small fixes to improve Gerber read/write. --- gerber/gerber_statements.py | 54 ++++++++++++------------ gerber/rs274x.py | 4 +- gerber/tests/test_excellon_statements.py | 6 +-- gerber/tests/test_gerber_statements.py | 8 ++-- gerber/tests/test_utils.py | 15 +++++-- gerber/utils.py | 8 +++- 6 files changed, 53 insertions(+), 42 deletions(-) diff --git a/gerber/gerber_statements.py b/gerber/gerber_statements.py index 32d3784..4aaa1d0 100644 --- a/gerber/gerber_statements.py +++ b/gerber/gerber_statements.py @@ -215,8 +215,8 @@ class OFParamStmt(ParamStmt): @classmethod def from_dict(cls, stmt_dict): param = stmt_dict.get('param') - a = float(stmt_dict.get('a')) - b = float(stmt_dict.get('b')) + a = float(stmt_dict.get('a', 0)) + b = float(stmt_dict.get('b', 0)) return cls(param, a, b) def __init__(self, param, a, b): @@ -245,17 +245,17 @@ class OFParamStmt(ParamStmt): def to_gerber(self): ret = '%OF' - if self.a: - ret += 'A' + decimal_string(self.a, precision=6) - if self.b: - ret += 'B' + decimal_string(self.b, precision=6) + if self.a is not None: + ret += 'A' + decimal_string(self.a, precision=5) + if self.b is not None: + ret += 'B' + decimal_string(self.b, precision=5) return ret + '*%' def __str__(self): offset_str = '' - if self.a: + if self.a is not None: offset_str += ('X: %f' % self.a) - if self.b: + if self.b is not None: offset_str += ('Y: %f' % self.b) return ('' % offset_str) @@ -341,7 +341,7 @@ class ADParamStmt(ParamStmt): else: self.modifiers = [] - def to_gerber(self, settings): + def to_gerber(self): return '%ADD{0}{1},{2}*%'.format(self.d, self.shape, ','.join(['X'.join(e) for e in self.modifiers])) @@ -540,18 +540,14 @@ class CoordStmt(Statement): ret = '' if self.function: ret += self.function - if self.x: - ret += 'X{0}'.format(write_gerber_value(self.x, self.zeros, - self.format)) - if self.y: - ret += 'Y{0}'.format(write_gerber_value(self.y, self. zeros, - self.format)) - if self.i: - ret += 'I{0}'.format(write_gerber_value(self.i, self.zeros, - self.format)) - if self.j: - ret += 'J{0}'.format(write_gerber_value(self.j, self.zeros, - self.format)) + if self.x is not None: + ret += 'X{0}'.format(write_gerber_value(self.x, self.format, self.zero_suppression)) + if self.y is not None: + ret += 'Y{0}'.format(write_gerber_value(self.y, self.format, self.zero_suppression)) + if self.i is not None: + ret += 'I{0}'.format(write_gerber_value(self.i, self.format, self.zero_suppression)) + if self.j is not None: + ret += 'J{0}'.format(write_gerber_value(self.j, self.format, self.zero_suppression)) if self.op: ret += self.op return ret + '*' @@ -560,13 +556,13 @@ class CoordStmt(Statement): coord_str = '' if self.function: coord_str += 'Fn: %s ' % self.function - if self.x: + if self.x is not None: coord_str += 'X: %f ' % self.x - if self.y: + if self.y is not None: coord_str += 'Y: %f ' % self.y - if self.i: + if self.i is not None: coord_str += 'I: %f ' % self.i - if self.j: + if self.j is not None: coord_str += 'J: %f ' % self.j if self.op: if self.op == 'D01': @@ -585,12 +581,16 @@ class CoordStmt(Statement): class ApertureStmt(Statement): """ Aperture Statement """ - def __init__(self, d): + def __init__(self, d, deprecated=None): Statement.__init__(self, "APERTURE") self.d = int(d) + self.deprecated = True if deprecated is not None else False def to_gerber(self): - return 'G54D{0}*'.format(self.d) + if self.deprecated: + return 'G54D{0}*'.format(self.d) + else: + return 'D{0}*'.format(self.d) def __str__(self): return '' % self.d diff --git a/gerber/rs274x.py b/gerber/rs274x.py index f7be44d..a41760e 100644 --- a/gerber/rs274x.py +++ b/gerber/rs274x.py @@ -109,7 +109,7 @@ class GerberFile(CamFile): """ with open(filename, 'w') as f: for statement in self.statements: - f.write(statement.to_gerber()) + f.write(statement.to_gerber() + "\n") class GerberParser(object): @@ -149,7 +149,7 @@ class GerberParser(object): r"(I(?P{number}))?(J(?P{number}))?" r"(?P{op})?\*".format(number=NUMBER, function=FUNCTION, op=COORD_OP))) - APERTURE_STMT = re.compile(r"(G54)?D(?P\d+)\*") + APERTURE_STMT = re.compile(r"(?PG54)?D(?P\d+)\*") COMMENT_STMT = re.compile(r"G04(?P[^*]*)(\*)?") diff --git a/gerber/tests/test_excellon_statements.py b/gerber/tests/test_excellon_statements.py index f2e17ee..0e1efa6 100644 --- a/gerber/tests/test_excellon_statements.py +++ b/gerber/tests/test_excellon_statements.py @@ -23,9 +23,9 @@ def test_excellontool_factory(): def test_excellontool_dump(): """ Test ExcellonTool to_excellon() """ - exc_lines = ['T1F00S00C0.01200', 'T2F00S00C0.01500', 'T3F00S00C0.01968', - 'T4F00S00C0.02800', 'T5F00S00C0.03300', 'T6F00S00C0.03800', - 'T7F00S00C0.04300', 'T8F00S00C0.12500', 'T9F00S00C0.13000', ] + exc_lines = ['T1F0S0C0.01200', 'T2F0S0C0.01500', 'T3F0S0C0.01968', + 'T4F0S0C0.02800', 'T5F0S0C0.03300', 'T6F0S0C0.03800', + 'T7F0S0C0.04300', 'T8F0S0C0.12500', 'T9F0S0C0.13000', ] settings = FileSettings(format=(2, 5), zero_suppression='trailing', units='inch', notation='absolute') for line in exc_lines: diff --git a/gerber/tests/test_gerber_statements.py b/gerber/tests/test_gerber_statements.py index a463c9d..62b99b4 100644 --- a/gerber/tests/test_gerber_statements.py +++ b/gerber/tests/test_gerber_statements.py @@ -123,7 +123,7 @@ def test_IPParamStmt_dump(): def test_OFParamStmt_factory(): - """ Test OFParamStmt factory + """ Test OFParamStmt factory """ stmt = {'param': 'OF', 'a': '0.1234567', 'b': '0.1234567'} of = OFParamStmt.from_dict(stmt) @@ -139,13 +139,13 @@ def test_OFParamStmt(): assert_equal(stmt.param, param) assert_equal(stmt.a, val) assert_equal(stmt.b, val) - + def test_OFParamStmt_dump(): """ Test OFParamStmt to_gerber() """ - stmt = {'param': 'OF', 'a': '0.1234567', 'b': '0.1234567'} + stmt = {'param': 'OF', 'a': '0.123456', 'b': '0.123456'} of = OFParamStmt.from_dict(stmt) - assert_equal(of.to_gerber(), '%OFA0.123456B0.123456*%') + assert_equal(of.to_gerber(), '%OFA0.12345B0.12345*%') def test_LPParamStmt_factory(): diff --git a/gerber/tests/test_utils.py b/gerber/tests/test_utils.py index 001a32f..706fa65 100644 --- a/gerber/tests/test_utils.py +++ b/gerber/tests/test_utils.py @@ -19,7 +19,8 @@ def test_zero_suppression(): ('1000', 0.01), ('10000', 0.1), ('100000', 1.0), ('1000000', 10.0), ('-1', -0.00001), ('-10', -0.0001), ('-100', -0.001), ('-1000', -0.01), ('-10000', -0.1), - ('-100000', -1.0), ('-1000000', -10.0), ] + ('-100000', -1.0), ('-1000000', -10.0), + ('0', 0.0)] for string, value in test_cases: assert(value == parse_gerber_value(string, fmt, zero_suppression)) assert(string == write_gerber_value(value, fmt, zero_suppression)) @@ -30,7 +31,8 @@ def test_zero_suppression(): ('00001', 0.001), ('000001', 0.0001), ('0000001', 0.00001), ('-1', -10.0), ('-01', -1.0), ('-001', -0.1), ('-0001', -0.01), ('-00001', -0.001), - ('-000001', -0.0001), ('-0000001', -0.00001)] + ('-000001', -0.0001), ('-0000001', -0.00001), + ('0', 0.0)] for string, value in test_cases: assert(value == parse_gerber_value(string, fmt, zero_suppression)) assert(string == write_gerber_value(value, fmt, zero_suppression)) @@ -46,7 +48,8 @@ def test_format(): ((2, 1), '1', 0.1), ((2, 7), '-1', -0.0000001), ((2, 6), '-1', -0.000001), ((2, 5), '-1', -0.00001), ((2, 4), '-1', -0.0001), ((2, 3), '-1', -0.001), - ((2, 2), '-1', -0.01), ((2, 1), '-1', -0.1), ] + ((2, 2), '-1', -0.01), ((2, 1), '-1', -0.1), + ((2, 6), '0', 0) ] for fmt, string, value in test_cases: assert(value == parse_gerber_value(string, fmt, zero_suppression)) assert(string == write_gerber_value(value, fmt, zero_suppression)) @@ -57,7 +60,8 @@ def test_format(): ((2, 5), '1', 10.0), ((1, 5), '1', 1.0), ((6, 5), '-1', -100000.0), ((5, 5), '-1', -10000.0), ((4, 5), '-1', -1000.0), ((3, 5), '-1', -100.0), - ((2, 5), '-1', -10.0), ((1, 5), '-1', -1.0), ] + ((2, 5), '-1', -10.0), ((1, 5), '-1', -1.0), + ((2, 5), '0', 0)] for fmt, string, value in test_cases: assert(value == parse_gerber_value(string, fmt, zero_suppression)) assert(string == write_gerber_value(value, fmt, zero_suppression)) @@ -81,3 +85,6 @@ def test_decimal_padding(): assert_equal(decimal_string(value, precision=4, padding=True), '1.1230') assert_equal(decimal_string(value, precision=5, padding=True), '1.12300') assert_equal(decimal_string(value, precision=6, padding=True), '1.123000') + + assert_equal(decimal_string(0, precision=6, padding=True), '0.000000') + diff --git a/gerber/utils.py b/gerber/utils.py index 7749e22..56b675f 100644 --- a/gerber/utils.py +++ b/gerber/utils.py @@ -125,9 +125,9 @@ def write_gerber_value(value, format=(2, 5), zero_suppression='trailing'): if MAX_DIGITS > 13 or integer_digits > 6 or decimal_digits > 7: raise ValueError('Parser only supports precision up to 6:7 format') - # Edge case... + # Edge case... (per Gerber spec we should return 0 in all cases, see page 77) if value == 0: - return '00' + return '0' # negative sign affects padding, so deal with it at the end... negative = value < 0.0 @@ -173,10 +173,14 @@ def decimal_string(value, precision=6, padding=False): integer, decimal = floatstr.split('.') elif ',' in floatstr: integer, decimal = floatstr.split(',') + else: + integer, decimal = floatstr, "0" + if len(decimal) > precision: decimal = decimal[:precision] elif padding: decimal = decimal + (precision - len(decimal)) * '0' + if integer or decimal: return ''.join([integer, '.', decimal]) else: From 29deffcf77e963ae81aec9f8cbc61b029f3052d5 Mon Sep 17 00:00:00 2001 From: Hamilton Kibbe Date: Fri, 5 Dec 2014 23:59:28 -0500 Subject: [PATCH 007/186] add ipc2581 primitives --- doc/source/about.rst | 38 +++++++++++++++ doc/source/index.rst | 2 +- doc/source/intro.rst | 19 -------- gerber/gerber_statements.py | 16 ++++++- gerber/primitives.py | 94 ++++++++++++++++++++++++++++++------- gerber/rs274x.py | 4 +- 6 files changed, 133 insertions(+), 40 deletions(-) create mode 100644 doc/source/about.rst delete mode 100644 doc/source/intro.rst diff --git a/doc/source/about.rst b/doc/source/about.rst new file mode 100644 index 0000000..1bb354c --- /dev/null +++ b/doc/source/about.rst @@ -0,0 +1,38 @@ +About PCB Tools +=============== + +PCB CAM Files +~~~~~~~~~~~~~ + +PCB design (artwork) files are most often stored in `Gerber` files. This is +a generic term that may refer to `RS-274X (Gerber) `_, +`ODB++ `_ , or `Excellon `_ +files. These file formats are used by the CNC equipment used to manufacutre PCBs. + +PCB Tools provides a set of utilities for visualizing and working with PCB design files +in a variety of formats. PCB Tools currently supports the following file formats: + +- Gerber (RS-274X) +- Excellon + +with planned support for IPC-2581, IPC-D-356 Netlists, ODB++ and more. + +Visualization +~~~~~~~~~~~~~~ +.. image:: ../../examples/composite_top.png + :alt: Rendering Example + +The PCB Tools module provides tools to visualize PCBs and export images in a variety of formats, +including SVG and PNG. + + +Future Plans +~~~~~~~~~~~~ +We are working on adding the following features to PCB Tools: + +- Design Rules Checking +- Editing +- Panelization + + + diff --git a/doc/source/index.rst b/doc/source/index.rst index 309cf88..ab29738 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -11,7 +11,7 @@ Contents: .. toctree:: :maxdepth: 1 - intro + about documentation/index Indices and tables diff --git a/doc/source/intro.rst b/doc/source/intro.rst deleted file mode 100644 index 4db80ad..0000000 --- a/doc/source/intro.rst +++ /dev/null @@ -1,19 +0,0 @@ -PCB Tools Intro -================== - -PCB CAM (Gerber) Files ------------- - -PCB design files (artwork) are most often stored in `Gerber` files. This is -a generic term that may refer to `RS-274X (Gerber) `_, -`ODB++ `_, or `Excellon `_ -files. - - -PCB-Tools ------------- - -The gerber-tools module provides tools for working with and rendering Gerber -and Excellon files. - - diff --git a/gerber/gerber_statements.py b/gerber/gerber_statements.py index 4aaa1d0..05c84b8 100644 --- a/gerber/gerber_statements.py +++ b/gerber/gerber_statements.py @@ -1,5 +1,19 @@ -#! /usr/bin/env python +#!/usr/bin/env python # -*- coding: utf-8 -*- + +# copyright 2014 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. """ Gerber (RS-274X) Statements =========================== diff --git a/gerber/primitives.py b/gerber/primitives.py index f934f74..e13e37f 100644 --- a/gerber/primitives.py +++ b/gerber/primitives.py @@ -19,10 +19,11 @@ from operator import sub class Primitive(object): - - def __init__(self, level_polarity='dark'): + + def __init__(self, level_polarity='dark', rotation=0): self.level_polarity = level_polarity - + self.rotation = rotation + def bounding_box(self): """ Calculate bounding box @@ -36,8 +37,8 @@ class Primitive(object): class Line(Primitive): """ """ - def __init__(self, start, end, width, level_polarity='dark'): - super(Line, self).__init__(level_polarity) + def __init__(self, start, end, width, **kwargs): + super(Line, self).__init__(**kwargs) self.start = start self.end = end self.width = width @@ -61,8 +62,8 @@ class Line(Primitive): class Arc(Primitive): """ """ - def __init__(self, start, end, center, direction, width, level_polarity='dark'): - super(Arc, self).__init__(level_polarity) + def __init__(self, start, end, center, direction, width, **kwargs): + super(Arc, self).__init__(**kwargs) self.start = start self.end = end self.center = center @@ -139,8 +140,8 @@ class Arc(Primitive): class Circle(Primitive): """ """ - def __init__(self, position, diameter, level_polarity='dark'): - super(Circle, self).__init__(level_polarity) + def __init__(self, position, diameter, **kwargs): + super(Circle, self).__init__(**kwargs) self.position = position self.diameter = diameter @@ -161,11 +162,29 @@ class Circle(Primitive): return self.diameter +class Ellipse(Primitive): + """ + """ + def __init__(self, position, width, height, **kwargs): + super(Ellipse, self).__init__(**kwargs) + self.position = position + self.width = width + self.height = height + + @property + def bounding_box(self): + min_x = self.position[0] - (self.width / 2.0) + max_x = self.position[0] + (self.width / 2.0) + min_y = self.position[1] - (self.height / 2.0) + max_y = self.position[1] + (self.height / 2.0) + return ((min_x, max_x), (min_y, max_y)) + + class Rectangle(Primitive): """ """ - def __init__(self, position, width, height, level_polarity='dark'): - super(Rectangle, self).__init__(level_polarity) + def __init__(self, position, width, height, **kwargs): + super(Rectangle, self).__init__(**kwargs) self.position = position self.width = width self.height = height @@ -193,11 +212,23 @@ class Rectangle(Primitive): return max((self.width, self.height)) +class Diamond(Primitive): + pass + + +class ChamferRectangle(Primitive): + pass + + +class RoundRectangle(Primitive): + pass + + class Obround(Primitive): """ """ - def __init__(self, position, width, height, level_polarity='dark'): - super(Obround, self).__init__(level_polarity) + def __init__(self, position, width, height, **kwargs): + super(Obround, self).__init__(**kwargs) self.position = position self.width = width self.height = height @@ -242,11 +273,12 @@ class Obround(Primitive): self.height) return {'circle1': circle1, 'circle2': circle2, 'rectangle': rect} + class Polygon(Primitive): """ """ - def __init__(self, position, sides, radius, level_polarity='dark'): - super(Polygon, self).__init__(level_polarity) + def __init__(self, position, sides, radius, **kwargs): + super(Polygon, self).__init__(**kwargs) self.position = position self.sides = sides self.radius = radius @@ -263,8 +295,8 @@ class Polygon(Primitive): class Region(Primitive): """ """ - def __init__(self, points, level_polarity='dark'): - super(Region, self).__init__(level_polarity) + def __init__(self, points, **kwargs): + super(Region, self).__init__(**kwargs) self.points = points @property @@ -277,6 +309,34 @@ class Region(Primitive): return ((min_x, max_x), (min_y, max_y)) +class RoundButterfly(Primitive): + """ + """ + def __init__(self, position, diameter, **kwargs): + super(RoundButterfly, self).__init__(**kwargs) + self.position = position + self.diameter = diameter + + @property + def radius(self): + return self.diameter / 2. + + @property + def bounding_box(self): + 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 + return ((min_x, max_x), (min_y, max_y)) + +class SquareButterfly(Primitive): + pass + + +class Donut(Primitive): + pass + + class Drill(Primitive): """ """ diff --git a/gerber/rs274x.py b/gerber/rs274x.py index a41760e..6dbcc63 100644 --- a/gerber/rs274x.py +++ b/gerber/rs274x.py @@ -403,10 +403,10 @@ class GerberParser(object): end = (x, y) width = self.apertures[self.aperture].stroke_width if self.interpolation == 'linear': - self.primitives.append(Line(start, end, width, self.level_polarity)) + self.primitives.append(Line(start, end, width, level_polarity=self.level_polarity)) else: center = (start[0] + stmt.i, start[1] + stmt.j) - self.primitives.append(Arc(start, end, center, self.direction, width, self.level_polarity)) + self.primitives.append(Arc(start, end, center, self.direction, width, level_polarity=self.level_polarity)) elif stmt.op == "D02": pass From 4bb2e5f8a047d10dafcbc9f841571ac753a439da Mon Sep 17 00:00:00 2001 From: Paulo Henrique Silva Date: Mon, 15 Dec 2014 23:35:01 -0200 Subject: [PATCH 008/186] Fix parsing for very short (less 20 lines) files. --- gerber/utils.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/gerber/utils.py b/gerber/utils.py index 56b675f..0f0c07c 100644 --- a/gerber/utils.py +++ b/gerber/utils.py @@ -201,9 +201,14 @@ def detect_file_format(filename): File format. either 'excellon' or 'rs274x' """ - # Read the first 20 lines + # Read the first 20 lines (if possible) + lines = [] with open(filename, 'r') as f: - lines = [next(f) for x in xrange(20)] + try: + for i in range(20): + lines.append(f.readline()) + except StopIteration: + pass # Look for for line in lines: From be5b94b8c09f647e5e19f795927060f75461c283 Mon Sep 17 00:00:00 2001 From: Paulo Henrique Silva Date: Mon, 15 Dec 2014 23:38:27 -0200 Subject: [PATCH 009/186] Fix parsing for OrCAD. * Modify the way we parse parameters to allow more than one parameter in a single line as in the following example: %FSLAX55Y55*MOIN*% %IR0*IPPOS*OFA0.00000B0.00000*MIA0B0*SFA1.00000B1.00000*% (this is from OrCAD 16 default output) * Add missing deprecated parameters. * Change API to use given FileSettings on output. This allows us to use pcb-tools to convert between FS formats. --- gerber/gerber_statements.py | 434 ++++++++++++++++++++++++------------ gerber/rs274x.py | 47 ++-- 2 files changed, 327 insertions(+), 154 deletions(-) diff --git a/gerber/gerber_statements.py b/gerber/gerber_statements.py index 05c84b8..1a6f646 100644 --- a/gerber/gerber_statements.py +++ b/gerber/gerber_statements.py @@ -23,13 +23,6 @@ Gerber (RS-274X) Statements from .utils import parse_gerber_value, write_gerber_value, decimal_string -__all__ = ['FSParamStmt', 'MOParamStmt', 'IPParamStmt', 'OFParamStmt', - 'LPParamStmt', 'ADParamStmt', 'AMParamStmt', 'INParamStmt', - 'LNParamStmt', 'CoordStmt', 'ApertureStmt', 'CommentStmt', - 'EofStmt', 'QuadrantModeStmt', 'RegionModeStmt', 'UnknownStmt', - 'ParamStmt'] - - class Statement(object): """ Gerber statement Base class @@ -128,17 +121,21 @@ class FSParamStmt(ParamStmt): self.notation = notation self.format = format - def to_gerber(self): - zero_suppression = 'L' if self.zero_suppression == 'leading' else 'T' - notation = 'A' if self.notation == 'absolute' else 'I' - fmt = ''.join(map(str, self.format)) - return '%FS{0}{1}X{2}Y{3}*%'.format(zero_suppression, notation, - fmt, fmt) + def to_gerber(self, settings=None): + if settings: + zero_suppression = 'L' if settings.zero_suppression == 'leading' else 'T' + notation = 'A' if settings.notation == 'absolute' else 'I' + fmt = ''.join(map(str, settings.format)) + else: + zero_suppression = 'L' if self.zero_suppression == 'leading' else 'T' + notation = 'A' if self.notation == 'absolute' else 'I' + fmt = ''.join(map(str, self.format)) + + return '%FS{0}{1}X{2}Y{3}*%'.format(zero_suppression, notation, fmt, fmt) def __str__(self): return ('' % - (self.format[0], self.format[1], self.zero_suppression, - self.notation)) + (self.format[0], self.format[1], self.zero_suppression, self.notation)) class MOParamStmt(ParamStmt): @@ -176,7 +173,7 @@ class MOParamStmt(ParamStmt): ParamStmt.__init__(self, param) self.mode = mo - def to_gerber(self): + def to_gerber(self, settings=None): mode = 'MM' if self.mode == 'metric' else 'IN' return '%MO{0}*%'.format(mode) @@ -185,95 +182,6 @@ class MOParamStmt(ParamStmt): return ('' % mode_str) -class IPParamStmt(ParamStmt): - """ IP - Gerber Image Polarity Statement. (Deprecated) - """ - @classmethod - def from_dict(cls, stmt_dict): - param = stmt_dict.get('param') - ip = 'positive' if stmt_dict.get('ip') == 'POS' else 'negative' - return cls(param, ip) - - def __init__(self, param, ip): - """ Initialize IPParamStmt class - - Parameters - ---------- - param : string - Parameter string. - - ip : string - Image polarity. May be either'positive' or 'negative' - - Returns - ------- - ParamStmt : IPParamStmt - Initialized IPParamStmt class. - - """ - ParamStmt.__init__(self, param) - self.ip = ip - - def to_gerber(self): - ip = 'POS' if self.ip == 'positive' else 'NEG' - return '%IP{0}*%'.format(ip) - - def __str__(self): - return ('' % self.ip) - - -class OFParamStmt(ParamStmt): - """ OF - Gerber Offset statement (Deprecated) - """ - - @classmethod - def from_dict(cls, stmt_dict): - param = stmt_dict.get('param') - a = float(stmt_dict.get('a', 0)) - b = float(stmt_dict.get('b', 0)) - return cls(param, a, b) - - def __init__(self, param, a, b): - """ Initialize OFParamStmt class - - Parameters - ---------- - param : string - Parameter - - a : float - Offset along the output device A axis - - b : float - Offset along the output device B axis - - Returns - ------- - ParamStmt : OFParamStmt - Initialized OFParamStmt class. - - """ - ParamStmt.__init__(self, param) - self.a = a - self.b = b - - def to_gerber(self): - ret = '%OF' - if self.a is not None: - ret += 'A' + decimal_string(self.a, precision=5) - if self.b is not None: - ret += 'B' + decimal_string(self.b, precision=5) - return ret + '*%' - - def __str__(self): - offset_str = '' - if self.a is not None: - offset_str += ('X: %f' % self.a) - if self.b is not None: - offset_str += ('Y: %f' % self.b) - return ('' % offset_str) - - class LPParamStmt(ParamStmt): """ LP - Gerber Level Polarity statement """ @@ -304,7 +212,7 @@ class LPParamStmt(ParamStmt): ParamStmt.__init__(self, param) self.lp = lp - def to_gerber(self): + def to_gerber(self, settings=None): lp = 'C' if self.lp == 'clear' else 'D' return '%LP{0}*%'.format(lp) @@ -351,13 +259,15 @@ class ADParamStmt(ParamStmt): self.d = d self.shape = shape if modifiers is not None: - self.modifiers = [[x for x in m.split("X")] for m in modifiers.split(",")] + self.modifiers = [[x for x in m.split("X")] for m in modifiers.split(",") if len(m)] else: self.modifiers = [] - def to_gerber(self): - return '%ADD{0}{1},{2}*%'.format(self.d, self.shape, - ','.join(['X'.join(e) for e in self.modifiers])) + def to_gerber(self, settings=None): + if len(self.modifiers): + return '%ADD{0}{1},{2}*%'.format(self.d, self.shape, ','.join(['X'.join(e) for e in self.modifiers])) + else: + return '%ADD{0}{1}*%'.format(self.d, self.shape) def __str__(self): if self.shape == 'C': @@ -404,15 +314,51 @@ class AMParamStmt(ParamStmt): self.name = name self.macro = macro - def to_gerber(self): + def to_gerber(self, settings=None): return '%AM{0}*{1}*%'.format(self.name, self.macro) def __str__(self): return '' % (self.name, self.macro) +class ASParamStmt(ParamStmt): + """ AS - Axis Select. (Deprecated) + """ + @classmethod + def from_dict(cls, stmt_dict): + param = stmt_dict.get('param') + mode = stmt_dict.get('mode') + return cls(param, mode) + + def __init__(self, param, ip): + """ Initialize ASParamStmt class + + Parameters + ---------- + param : string + Parameter string. + + mode : string + Axis select. May be either 'AXBY' or 'AYBX' + + Returns + ------- + ParamStmt : ASParamStmt + Initialized ASParamStmt class. + + """ + ParamStmt.__init__(self, param) + self.mode = mode + + def to_gerber(self, settings=None): + return '%AS{0}*%'.format(self.mode) + + def __str__(self): + return ('' % self.mode) + + class INParamStmt(ParamStmt): - """ IN - Image Name Statement + """ IN - Image Name Statement (Deprecated) """ @classmethod def from_dict(cls, stmt_dict): @@ -438,13 +384,235 @@ class INParamStmt(ParamStmt): ParamStmt.__init__(self, param) self.name = name - def to_gerber(self): + def to_gerber(self, settings=None): return '%IN{0}*%'.format(self.name) def __str__(self): return '' % self.name +class IPParamStmt(ParamStmt): + """ IP - Gerber Image Polarity Statement. (Deprecated) + """ + @classmethod + def from_dict(cls, stmt_dict): + param = stmt_dict.get('param') + ip = 'positive' if stmt_dict.get('ip') == 'POS' else 'negative' + return cls(param, ip) + + def __init__(self, param, ip): + """ Initialize IPParamStmt class + + Parameters + ---------- + param : string + Parameter string. + + ip : string + Image polarity. May be either'positive' or 'negative' + + Returns + ------- + ParamStmt : IPParamStmt + Initialized IPParamStmt class. + + """ + ParamStmt.__init__(self, param) + self.ip = ip + + def to_gerber(self, settings=None): + ip = 'POS' if self.ip == 'positive' else 'NEG' + return '%IP{0}*%'.format(ip) + + def __str__(self): + return ('' % self.ip) + + +class IRParamStmt(ParamStmt): + """ IR - Image Rotation Param (Deprecated) + """ + @classmethod + def from_dict(cls, stmt_dict): + return cls(**stmt_dict) + + def __init__(self, param, angle): + """ Initialize IRParamStmt class + + Parameters + ---------- + param : string + Parameter code + + angle : int + Image angle + + Returns + ------- + ParamStmt : IRParamStmt + Initialized IRParamStmt class. + + """ + ParamStmt.__init__(self, param) + self.angle = angle + + def to_gerber(self, settings=None): + return '%IR{0}*%'.format(self.angle) + + def __str__(self): + return '' % self.angle + + +class MIParamStmt(ParamStmt): + """ MI - Image Mirror Param (Deprecated) + """ + @classmethod + def from_dict(cls, stmt_dict): + param = stmt_dict.get('param') + a = int(stmt_dict.get('a', 0)) + b = int(stmt_dict.get('b', 0)) + return cls(param, a, b) + + def __init__(self, param, a, b): + """ Initialize MIParamStmt class + + Parameters + ---------- + param : string + Parameter code + + a : int + Mirror for A output devices axis (0=disabled, 1=mirrored) + + b : int + Mirror for B output devices axis (0=disabled, 1=mirrored) + + Returns + ------- + ParamStmt : MIParamStmt + Initialized MIParamStmt class. + + """ + ParamStmt.__init__(self, param) + self.a = a + self.b = b + + def to_gerber(self, settings=None): + ret = "%MI" + if self.a is not None: + ret += "A{0}".format(self.a) + if self.b is not None: + ret += "B{0}".format(self.b) + + return ret + + def __str__(self): + return '' % (self.a, self.b) + + +class OFParamStmt(ParamStmt): + """ OF - Gerber Offset statement (Deprecated) + """ + + @classmethod + def from_dict(cls, stmt_dict): + param = stmt_dict.get('param') + a = float(stmt_dict.get('a', 0)) + b = float(stmt_dict.get('b', 0)) + return cls(param, a, b) + + def __init__(self, param, a, b): + """ Initialize OFParamStmt class + + Parameters + ---------- + param : string + Parameter + + a : float + Offset along the output device A axis + + b : float + Offset along the output device B axis + + Returns + ------- + ParamStmt : OFParamStmt + Initialized OFParamStmt class. + + """ + ParamStmt.__init__(self, param) + self.a = a + self.b = b + + def to_gerber(self, settings=None): + ret = '%OF' + if self.a is not None: + ret += 'A' + decimal_string(self.a, precision=5) + if self.b is not None: + ret += 'B' + decimal_string(self.b, precision=5) + return ret + '*%' + + def __str__(self): + offset_str = '' + if self.a is not None: + offset_str += ('X: %f' % self.a) + if self.b is not None: + offset_str += ('Y: %f' % self.b) + return ('' % offset_str) + + +class SFParamStmt(ParamStmt): + """ SF - Scale Factor Param (Deprecated) + """ + + @classmethod + def from_dict(cls, stmt_dict): + param = stmt_dict.get('param') + a = float(stmt_dict.get('a', 1)) + b = float(stmt_dict.get('b', 1)) + return cls(param, a, b) + + def __init__(self, param, a, b): + """ Initialize OFParamStmt class + + Parameters + ---------- + param : string + Parameter + + a : float + Scale factor for the output device A axis + + b : float + Scale factor for the output device B axis + + Returns + ------- + ParamStmt : SFParamStmt + Initialized SFParamStmt class. + + """ + ParamStmt.__init__(self, param) + self.a = a + self.b = b + + def to_gerber(self, settings=None): + ret = '%SF' + if self.a is not None: + ret += 'A' + decimal_string(self.a, precision=5) + if self.b is not None: + ret += 'B' + decimal_string(self.b, precision=5) + return ret + '*%' + + def __str__(self): + scale_factor = '' + if self.a is not None: + scale_factor += ('X: %f' % self.a) + if self.b is not None: + scale_factor += ('Y: %f' % self.b) + return ('' % scale_factor) + + class LNParamStmt(ParamStmt): """ LN - Level Name Statement (Deprecated) """ @@ -472,7 +640,7 @@ class LNParamStmt(ParamStmt): ParamStmt.__init__(self, param) self.name = name - def to_gerber(self): + def to_gerber(self, settings=None): return '%LN{0}*%'.format(self.name) def __str__(self): @@ -485,8 +653,6 @@ class CoordStmt(Statement): @classmethod def from_dict(cls, stmt_dict, settings): - zeros = settings.zero_suppression - format = settings.format function = stmt_dict['function'] x = stmt_dict.get('x') y = stmt_dict.get('y') @@ -495,17 +661,13 @@ class CoordStmt(Statement): op = stmt_dict.get('op') if x is not None: - x = parse_gerber_value(stmt_dict.get('x'), - format, zeros) + x = parse_gerber_value(stmt_dict.get('x'), settings.format, settings.zero_suppression) if y is not None: - y = parse_gerber_value(stmt_dict.get('y'), - format, zeros) + y = parse_gerber_value(stmt_dict.get('y'), settings.format, settings.zero_suppression) if i is not None: - i = parse_gerber_value(stmt_dict.get('i'), - format, zeros) + i = parse_gerber_value(stmt_dict.get('i'), settings.format, settings.zero_suppression) if j is not None: - j = parse_gerber_value(stmt_dict.get('j'), - format, zeros) + j = parse_gerber_value(stmt_dict.get('j'), settings.format, settings.zero_suppression) return cls(function, x, y, i, j, op, settings) def __init__(self, function, x, y, i, j, op, settings): @@ -541,8 +703,6 @@ class CoordStmt(Statement): """ Statement.__init__(self, "COORD") - self.zero_suppression = settings.zero_suppression - self.format = settings.format self.function = function self.x = x self.y = y @@ -550,18 +710,18 @@ class CoordStmt(Statement): self.j = j self.op = op - def to_gerber(self): + def to_gerber(self, settings=None): ret = '' if self.function: ret += self.function if self.x is not None: - ret += 'X{0}'.format(write_gerber_value(self.x, self.format, self.zero_suppression)) + ret += 'X{0}'.format(write_gerber_value(self.x, settings.format, settings.zero_suppression)) if self.y is not None: - ret += 'Y{0}'.format(write_gerber_value(self.y, self.format, self.zero_suppression)) + ret += 'Y{0}'.format(write_gerber_value(self.y, settings.format, settings.zero_suppression)) if self.i is not None: - ret += 'I{0}'.format(write_gerber_value(self.i, self.format, self.zero_suppression)) + ret += 'I{0}'.format(write_gerber_value(self.i, settings.format, settings.zero_suppression)) if self.j is not None: - ret += 'J{0}'.format(write_gerber_value(self.j, self.format, self.zero_suppression)) + ret += 'J{0}'.format(write_gerber_value(self.j, settings.format, settings.zero_suppression)) if self.op: ret += self.op return ret + '*' @@ -600,7 +760,7 @@ class ApertureStmt(Statement): self.d = int(d) self.deprecated = True if deprecated is not None else False - def to_gerber(self): + def to_gerber(self, settings=None): if self.deprecated: return 'G54D{0}*'.format(self.d) else: @@ -618,7 +778,7 @@ class CommentStmt(Statement): Statement.__init__(self, "COMMENT") self.comment = comment - def to_gerber(self): + def to_gerber(self, settings=None): return 'G04{0}*'.format(self.comment) def __str__(self): @@ -631,7 +791,7 @@ class EofStmt(Statement): def __init__(self): Statement.__init__(self, "EOF") - def to_gerber(self): + def to_gerber(self, settings=None): return 'M02*' def __str__(self): @@ -656,7 +816,7 @@ class QuadrantModeStmt(Statement): or "multi-quadrant"') self.mode = mode - def to_gerber(self): + def to_gerber(self, settings=None): return 'G74*' if self.mode == 'single-quadrant' else 'G75*' @@ -675,7 +835,7 @@ class RegionModeStmt(Statement): raise ValueError('Valid modes are "on" or "off"') self.mode = mode - def to_gerber(self): + def to_gerber(self, settings=None): return 'G36*' if self.mode == 'on' else 'G37*' @@ -686,5 +846,5 @@ class UnknownStmt(Statement): Statement.__init__(self, "UNKNOWN") self.line = line - def to_gerber(self): + def to_gerber(self, settings=None): return self.line diff --git a/gerber/rs274x.py b/gerber/rs274x.py index 6dbcc63..8f4a171 100644 --- a/gerber/rs274x.py +++ b/gerber/rs274x.py @@ -104,12 +104,13 @@ class GerberFile(CamFile): return (xbounds, ybounds) - def write(self, filename): + def write(self, filename, settings=None): """ Write data out to a gerber file """ with open(filename, 'w') as f: for statement in self.statements: - f.write(statement.to_gerber() + "\n") + f.write(statement.to_gerber(settings or self.settings)) + f.write("\n") class GerberParser(object): @@ -125,7 +126,6 @@ class GerberParser(object): FS = r"(?PFS)(?P(L|T))?(?P(A|I))X(?P[0-7][0-7])Y(?P[0-7][0-7])" MO = r"(?PMO)(?P(MM|IN))" - IP = r"(?PIP)(?P(POS|NEG))" LP = r"(?PLP)(?P(D|C))" AD_CIRCLE = r"(?PAD)D(?P\d+)(?PC)[,]?(?P[^,]*)?" AD_RECT = r"(?PAD)D(?P\d+)(?PR)[,](?P[^,]*)" @@ -135,13 +135,18 @@ class GerberParser(object): AM = r"(?PAM)(?P{name})\*(?P.*)".format(name=NAME) # begin deprecated - OF = r"(?POF)(A(?P{decimal}))?(B(?P{decimal}))?".format(decimal=DECIMAL) + AS = r"(?PAS)(?P(AXBY)|(AYBX))" IN = r"(?PIN)(?P.*)" + IP = r"(?PIP)(?P(POS|NEG))" + IR = r"(?PIR)(?P{number})".format(number=NUMBER) + MI = r"(?PMI)(A(?P0|1))?(B(?P0|1))?" + OF = r"(?POF)(A(?P{decimal}))?(B(?P{decimal}))?".format(decimal=DECIMAL) + SF = r"(?PSF)(?P.*)" LN = r"(?PLN)(?P.*)" # end deprecated - PARAMS = (FS, MO, IP, LP, AD_CIRCLE, AD_RECT, AD_OBROUND, AD_POLY, AD_MACRO, AM, OF, IN, LN) - PARAM_STMT = [re.compile(r"%{0}\*%".format(p)) for p in PARAMS] + PARAMS = (FS, MO, LP, AD_CIRCLE, AD_RECT, AD_OBROUND, AD_POLY, AD_MACRO, AM, AS, IN, IP, IR, MI, OF, SF, LN) + PARAM_STMT = [re.compile(r"%?{0}\*%?".format(p)) for p in PARAMS] COORD_STMT = re.compile(( r"(?P{function})?" @@ -149,7 +154,7 @@ class GerberParser(object): r"(I(?P{number}))?(J(?P{number}))?" r"(?P{op})?\*".format(number=NUMBER, function=FUNCTION, op=COORD_OP))) - APERTURE_STMT = re.compile(r"(?PG54)?D(?P\d+)\*") + APERTURE_STMT = re.compile(r"(?P(G54)|G55)?D(?P\d+)\*") COMMENT_STMT = re.compile(r"G04(?P[^*]*)(\*)?") @@ -270,8 +275,6 @@ class GerberParser(object): stmt = MOParamStmt.from_dict(param) self.settings.units = stmt.mode yield stmt - elif param["param"] == "IP": - yield IPParamStmt.from_dict(param) elif param["param"] == "LP": yield LPParamStmt.from_dict(param) elif param["param"] == "AD": @@ -284,8 +287,26 @@ class GerberParser(object): yield INParamStmt.from_dict(param) elif param["param"] == "LN": yield LNParamStmt.from_dict(param) + # deprecated commands AS, IN, IP, IR, MI, OF, SF, LN + elif param["param"] == "AS": + yield ASParamStmt.from_dict(param) + elif param["param"] == "IN": + yield INParamStmt.from_dict(param) + elif param["param"] == "IP": + yield IPParamStmt.from_dict(param) + elif param["param"] == "IR": + yield IRParamStmt.from_dict(param) + elif param["param"] == "MI": + yield MIParamStmt.from_dict(param) + elif param["param"] == "OF": + yield OFParamStmt.from_dict(param) + elif param["param"] == "SF": + yield SFParamStmt.from_dict(param) + elif param["param"] == "LN": + yield LNParamStmt.from_dict(param) else: yield UnknownStmt(line) + did_something = True line = r continue @@ -298,14 +319,6 @@ class GerberParser(object): line = r continue - if False: - print self.COORD_STMT.pattern - print self.APERTURE_STMT.pattern - print self.COMMENT_STMT.pattern - print self.EOF_STMT.pattern - for i in self.PARAM_STMT: - print i.pattern - if line.find('*') > 0: yield UnknownStmt(line) did_something = True From 53ee7566097b5a26cd3a1dab1d730f9606d767e6 Mon Sep 17 00:00:00 2001 From: Paulo Henrique Silva Date: Tue, 13 Jan 2015 23:12:27 -0200 Subject: [PATCH 010/186] Fix region primitive creation --- gerber/rs274x.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gerber/rs274x.py b/gerber/rs274x.py index 8f4a171..3ec7429 100644 --- a/gerber/rs274x.py +++ b/gerber/rs274x.py @@ -376,7 +376,7 @@ class GerberParser(object): def _evaluate_mode(self, stmt): if stmt.type == 'RegionMode': if self.region_mode == 'on' and stmt.mode == 'off': - self.primitives.append(Region(self.current_region, self.level_polarity)) + self.primitives.append(Region(self.current_region, level_polarity=self.level_polarity)) self.current_region = None self.region_mode = stmt.mode elif stmt.type == 'QuadrantMode': From cbb662491c273e97cfceed94b29a77ce865244dd Mon Sep 17 00:00:00 2001 From: Paulo Henrique Silva Date: Wed, 14 Jan 2015 03:15:52 -0200 Subject: [PATCH 011/186] Refactor AM aperture handling and add unit conversion support * Add support to convert between metric/impertial * AM primitives are now properly created and can be converted between metric/imperial. (only Outline primitive is supported, no rendering yet) --- gerber/gerber_statements.py | 128 ++++++++++++++++++++++++++++++++++-- gerber/rs274x.py | 10 +-- 2 files changed, 129 insertions(+), 9 deletions(-) diff --git a/gerber/gerber_statements.py b/gerber/gerber_statements.py index 1a6f646..f799f5a 100644 --- a/gerber/gerber_statements.py +++ b/gerber/gerber_statements.py @@ -259,13 +259,19 @@ class ADParamStmt(ParamStmt): self.d = d self.shape = shape if modifiers is not None: - self.modifiers = [[x for x in m.split("X")] for m in modifiers.split(",") if len(m)] + self.modifiers = [[float(x) for x in m.split("X")] for m in modifiers.split(",") if len(m)] else: self.modifiers = [] + def to_inch(self): + self.modifiers = [[x / 25.4 for x in modifier] for modifier in self.modifiers] + + def to_metric(self): + self.modifiers = [[x * 25.4 for x in modifier] for modifier in self.modifiers] + def to_gerber(self, settings=None): if len(self.modifiers): - return '%ADD{0}{1},{2}*%'.format(self.d, self.shape, ','.join(['X'.join(e) for e in self.modifiers])) + return '%ADD{0}{1},{2}*%'.format(self.d, self.shape, ','.join(['X'.join(["%.4f" % x for x in modifier]) for modifier in self.modifiers])) else: return '%ADD{0}{1}*%'.format(self.d, self.shape) @@ -282,6 +288,77 @@ class ADParamStmt(ParamStmt): return '' % (self.d, shape) +class AMPrimitive(object): + + def __init__(self, code, exposure): + self.code = code + self.exposure = exposure + + def to_inch(self): + pass + + def to_metric(self): + pass + + +class AMOutlinePrimitive(AMPrimitive): + + @classmethod + def from_gerber(cls, primitive): + modifiers = primitive.split(",") + + code = int(modifiers[0]) + exposure = "on" if modifiers[1] == "1" else "off" + n = int(modifiers[2]) + start_point = (float(modifiers[3]), float(modifiers[4])) + points = [] + + for i in range(n): + points.append((float(modifiers[5 + i*2]), float(modifiers[5 + i*2 + 1]))) + + rotation = float(modifiers[-1]) + + return cls(code, exposure, start_point, points, rotation) + + def __init__(self, code, exposure, start_point, points, rotation): + super(AMOutlinePrimitive, self).__init__(code, exposure) + + self.start_point = start_point + self.points = points + self.rotation = rotation + + def to_inch(self): + self.start_point = tuple([x / 25.4 for x in self.start_point]) + self.points = tuple([(x / 25.4, y / 25.4) for x, y in self.points]) + + def to_metric(self): + self.start_point = tuple([x * 25.4 for x in self.start_point]) + self.points = tuple([(x * 25.4, y * 25.4) for x, y in self.points]) + + def to_gerber(self, settings=None): + data = dict( + code=self.code, + exposure="1" if self.exposure == "on" else "0", + n_points=len(self.points), + start_point="%.4f,%.4f" % self.start_point, + points=",".join(["%.4f,%.4f" % point for point in self.points]), + rotation=str(self.rotation) + ) + return "{code},{exposure},{n_points},{start_point},{points},{rotation}".format(**data) + + +class AMUnsupportPrimitive: + @classmethod + def from_gerber(cls, primitive): + return cls(primitive) + + def __init__(self, primitive): + self.primitive = primitive + + def to_gerber(self, settings=None): + return self.primitive + + class AMParamStmt(ParamStmt): """ AM - Aperture Macro Statement """ @@ -312,10 +389,29 @@ class AMParamStmt(ParamStmt): """ ParamStmt.__init__(self, param) self.name = name - self.macro = macro + self.primitives = self._parsePrimitives(macro) + + def _parsePrimitives(self, macro): + primitives = [] + + for primitive in macro.split("*"): + if primitive[0] == "4": + primitives.append(AMOutlinePrimitive.from_gerber(primitive)) + else: + primitives.append(AMUnsupportPrimitive.from_gerber(primitive)) + + return primitives + + def to_inch(self): + for primitive in self.primitives: + primitive.to_inch() + + def to_metric(self): + for primitive in self.primitives: + primitive.to_metric() def to_gerber(self, settings=None): - return '%AM{0}*{1}*%'.format(self.name, self.macro) + return '%AM{0}*{1}*%'.format(self.name, "".join([primitive.to_gerber(settings) for primitive in self.primitives])) def __str__(self): return '' % (self.name, self.macro) @@ -726,6 +822,30 @@ class CoordStmt(Statement): ret += self.op return ret + '*' + def to_inch(self): + if self.x is not None: + self.x = self.x / 25.4 + if self.y is not None: + self.y = self.y / 25.4 + if self.i is not None: + self.i = self.i / 25.4 + if self.j is not None: + self.j = self.j / 25.4 + if self.function == "G71": + self.function = "G70" + + def to_metric(self): + if self.x is not None: + self.x = self.x * 25.4 + if self.y is not None: + self.y = self.y * 25.4 + if self.i is not None: + self.i = self.i * 25.4 + if self.j is not None: + self.j = self.j * 25.4 + if self.function == "G70": + self.function = "G71" + def __str__(self): coord_str = '' if self.function: diff --git a/gerber/rs274x.py b/gerber/rs274x.py index 3ec7429..2e5a3ec 100644 --- a/gerber/rs274x.py +++ b/gerber/rs274x.py @@ -361,15 +361,15 @@ class GerberParser(object): def _define_aperture(self, d, shape, modifiers): aperture = None if shape == 'C': - diameter = float(modifiers[0][0]) + diameter = modifiers[0][0] aperture = Circle(position=None, diameter=diameter) elif shape == 'R': - width = float(modifiers[0][0]) - height = float(modifiers[0][1]) + width = modifiers[0][0] + height = modifiers[0][1] aperture = Rectangle(position=None, width=width, height=height) elif shape == 'O': - width = float(modifiers[0][0]) - height = float(modifiers[0][1]) + width = modifiers[0][0] + height = modifiers[0][1] aperture = Obround(position=None, width=width, height=height) self.apertures[d] = aperture From a9b5a17c534247fe5c82c82b945305f3855b8bef Mon Sep 17 00:00:00 2001 From: Paulo Henrique Silva Date: Wed, 14 Jan 2015 14:30:53 -0200 Subject: [PATCH 012/186] Fix Mirror (deprecated) param generation --- gerber/gerber_statements.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gerber/gerber_statements.py b/gerber/gerber_statements.py index f799f5a..83ae143 100644 --- a/gerber/gerber_statements.py +++ b/gerber/gerber_statements.py @@ -598,7 +598,7 @@ class MIParamStmt(ParamStmt): ret += "A{0}".format(self.a) if self.b is not None: ret += "B{0}".format(self.b) - + ret += "*%" return ret def __str__(self): From ac89a3c36505bebff68305eb8e315482cba860fd Mon Sep 17 00:00:00 2001 From: Paulo Henrique Silva Date: Wed, 14 Jan 2015 14:31:03 -0200 Subject: [PATCH 013/186] Fix for cases whee the coordinate precision is decreased. If we parse a file with 5.5 INCH format and ask to write it back as 2.4 INCH we are going to loose precision and write_gerber_value was not handling these cases write. --- gerber/utils.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/gerber/utils.py b/gerber/utils.py index 0f0c07c..64cd6ed 100644 --- a/gerber/utils.py +++ b/gerber/utils.py @@ -140,12 +140,15 @@ def write_gerber_value(value, format=(2, 5), zero_suppression='trailing'): # Suppression... if zero_suppression == 'trailing': - while digits[-1] == '0': + while digits and digits[-1] == '0': digits.pop() else: - while digits[0] == '0': + while digits and digits[0] == '0': digits.pop(0) + if not digits: + return '0' + return ''.join(digits) if not negative else ''.join(['-'] + digits) From 137c73f3e42281de67bde8f1c0b16938f5b8aeeb Mon Sep 17 00:00:00 2001 From: Paulo Henrique Silva Date: Wed, 14 Jan 2015 14:33:00 -0200 Subject: [PATCH 014/186] Many additions to Excellon parsing/creation. CAUTION: the original code used zero_suppression flags in the opposite sense as Gerber functions. This patch changes it to behave just like Gerber code. * Add metric/inch conversion support * Add settings context variable to to_gerber just like Gerber code. * Add some missing Excellon values. Tests are not entirely updated. --- gerber/excellon.py | 51 ++++++---- gerber/excellon_statements.py | 114 +++++++++++++++++------ gerber/tests/test_excellon_statements.py | 35 +++++-- 3 files changed, 144 insertions(+), 56 deletions(-) diff --git a/gerber/excellon.py b/gerber/excellon.py index 9d09576..ee38367 100755 --- a/gerber/excellon.py +++ b/gerber/excellon.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- # Copyright 2014 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 @@ -13,8 +13,8 @@ # 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. - +# limitations under the License. + """ Excellon File module ==================== @@ -28,6 +28,7 @@ from .excellon_statements import * from .cam import CamFile, FileSettings from .primitives import Drill import math +import re def read(filename): """ Read data from filename and return an ExcellonFile @@ -42,10 +43,7 @@ def read(filename): An ExcellonFile created from the specified file. """ - detected_settings = detect_excellon_format(filename) - settings = FileSettings(**detected_settings) - zeros = '' - return ExcellonParser(settings).parse(filename) + return ExcellonParser(None).parse(filename) class ExcellonFile(CamFile): @@ -104,7 +102,7 @@ class ExcellonFile(CamFile): def write(self, filename): with open(filename, 'w') as f: for statement in self.statements: - f.write(statement.to_excellon() + '\n') + f.write(statement.to_excellon(self.settings) + '\n') class ExcellonParser(object): @@ -118,14 +116,14 @@ class ExcellonParser(object): def __init__(self, settings=None): self.notation = 'absolute' self.units = 'inch' - self.zero_suppression = 'trailing' - self.format = (2, 5) + self.zero_suppression = 'leading' + self.format = (2, 4) self.state = 'INIT' self.statements = [] self.tools = {} self.hits = [] self.active_tool = None - self.pos = [0., 0.] + self.pos = [0., 0.] if settings is not None: self.units = settings.units self.zero_suppression = settings.zero_suppression @@ -166,11 +164,19 @@ class ExcellonParser(object): self._settings(), filename) def _parse(self, line): - #line = line.strip() - zs = self._settings().zero_suppression - fmt = self._settings().format + # skip empty lines + if not line.strip(): + return + if line[0] == ';': - self.statements.append(CommentStmt.from_excellon(line)) + comment_stmt = CommentStmt.from_excellon(line) + self.statements.append(comment_stmt) + + # get format from altium comment + if "FILE_FORMAT" in comment_stmt.comment: + detected_format = tuple([int(x) for x in comment_stmt.comment.split('=')[1].split(":")]) + if detected_format: + self.format = detected_format elif line[:3] == 'M48': self.statements.append(HeaderBeginStmt()) @@ -191,9 +197,11 @@ class ExcellonParser(object): self.statements.append(stmt) elif line[:3] == 'G00': + self.statements.append(RouteModeStmt()) self.state = 'ROUT' elif line[:3] == 'G05': + self.statements.append(DrillModeStmt()) self.state = 'DRILL' elif (('INCH' in line or 'METRIC' in line) and @@ -221,6 +229,9 @@ class ExcellonParser(object): stmt = FormatStmt.from_excellon(line) self.statements.append(stmt) + elif line[:4] == 'G90': + self.statements.append(AbsoluteModeStmt()) + elif line[0] == 'T' and self.state == 'HEADER': tool = ExcellonTool.from_excellon(line, self._settings()) self.tools[tool.number] = tool @@ -228,14 +239,16 @@ class ExcellonParser(object): elif line[0] == 'T' and self.state != 'HEADER': stmt = ToolSelectionStmt.from_excellon(line) - self.active_tool = self.tools[stmt.tool] + # T0 is used as END marker, just ignore + if stmt.tool != 0: + self.active_tool = self.tools[stmt.tool] self.statements.append(stmt) elif line[0] in ['X', 'Y']: - stmt = CoordinateStmt.from_excellon(line, fmt, zs) + stmt = CoordinateStmt.from_excellon(line, self._settings()) x = stmt.x y = stmt.y - self.statements.append(stmt) + self.statements.append(stmt) if self.notation == 'absolute': if x is not None: self.pos[0] = x @@ -246,7 +259,7 @@ class ExcellonParser(object): self.pos[0] += x if y is not None: self.pos[1] += y - if self.state == 'DRILL': + if self.state == 'DRILL': self.hits.append((self.active_tool, tuple(self.pos))) self.active_tool._hit() else: diff --git a/gerber/excellon_statements.py b/gerber/excellon_statements.py index feeda44..c4f4015 100644 --- a/gerber/excellon_statements.py +++ b/gerber/excellon_statements.py @@ -28,7 +28,8 @@ __all__ = ['ExcellonTool', 'ToolSelectionStmt', 'CoordinateStmt', 'CommentStmt', 'HeaderBeginStmt', 'HeaderEndStmt', 'RewindStopStmt', 'EndOfProgramStmt', 'UnitStmt', 'IncrementalModeStmt', 'VersionStmt', 'FormatStmt', 'LinkToolStmt', - 'MeasuringModeStmt', 'UnknownStmt', + 'MeasuringModeStmt', 'RouteModeStmt', 'DrillModeStmt', 'AbsoluteModeStmt', + 'UnknownStmt', ] @@ -39,7 +40,7 @@ class ExcellonStatement(object): def from_excellon(cls, line): pass - def to_excellon(self): + def to_excellon(self, settings=None): pass @@ -156,10 +157,10 @@ class ExcellonTool(ExcellonStatement): self.depth_offset = kwargs.get('depth_offset') self.hit_count = 0 - def to_excellon(self): + def to_excellon(self, settings=None): fmt = self.settings.format zs = self.settings.format - stmt = 'T%d' % self.number + stmt = 'T%02d' % self.number if self.retract_rate is not None: stmt += 'B%s' % write_gerber_value(self.retract_rate, fmt, zs) if self.feed_rate is not None: @@ -177,12 +178,20 @@ class ExcellonTool(ExcellonStatement): stmt += 'Z%s' % write_gerber_value(self.depth_offset, fmt, zs) return stmt + def to_inch(self): + if self.diameter is not None: + self.diameter = self.diameter / 25.4 + + def to_metric(self): + if self.diameter is not None: + self.diameter = self.diameter * 25.4 + def _hit(self): self.hit_count += 1 def __repr__(self): unit = 'in.' if self.settings.units == 'inch' else 'mm' - return '' % (self.number, self.diameter, unit) + return '' % (self.number, self.diameter, unit) class ToolSelectionStmt(ExcellonStatement): @@ -215,7 +224,7 @@ class ToolSelectionStmt(ExcellonStatement): self.tool = tool self.compensation_index = compensation_index - def to_excellon(self): + def to_excellon(self, settings=None): stmt = 'T%02d' % self.tool if self.compensation_index is not None: stmt += '%02d' % self.compensation_index @@ -225,33 +234,51 @@ class ToolSelectionStmt(ExcellonStatement): class CoordinateStmt(ExcellonStatement): @classmethod - def from_excellon(cls, line, nformat=(2, 5), zero_suppression='trailing'): + def from_excellon(cls, line, settings): x_coord = None y_coord = None if line[0] == 'X': splitline = line.strip('X').split('Y') - x_coord = parse_gerber_value(splitline[0], nformat, - zero_suppression) + x_coord = parse_gerber_value(splitline[0], settings.format, settings.zero_suppression) if len(splitline) == 2: - y_coord = parse_gerber_value(splitline[1], nformat, - zero_suppression) + y_coord = parse_gerber_value(splitline[1], settings.format, settings.zero_suppression) else: - y_coord = parse_gerber_value(line.strip(' Y'), nformat, - zero_suppression) + y_coord = parse_gerber_value(line.strip(' Y'), settings.format, settings.zero_suppression) return cls(x_coord, y_coord) def __init__(self, x=None, y=None): self.x = x self.y = y - def to_excellon(self): + def to_excellon(self, settings): stmt = '' if self.x is not None: - stmt += 'X%s' % write_gerber_value(self.x) + stmt += 'X%s' % write_gerber_value(self.x, settings.format, settings.zero_suppression) if self.y is not None: - stmt += 'Y%s' % write_gerber_value(self.y) + stmt += 'Y%s' % write_gerber_value(self.y, settings.format, settings.zero_suppression) return stmt + def to_inch(self): + if self.x is not None: + self.x = self.x / 25.4 + if self.y is not None: + self.y = self.y / 25.4 + + def to_metric(self): + if self.x is not None: + self.x = self.x * 25.4 + if self.y is not None: + self.y = self.y * 25.4 + + def __str__(self): + coord_str = '' + if self.x is not None: + coord_str += 'X: %f ' % self.x + if self.y is not None: + coord_str += 'Y: %f ' % self.y + + return '' % coord_str + class CommentStmt(ExcellonStatement): @@ -262,7 +289,7 @@ class CommentStmt(ExcellonStatement): def __init__(self, comment): self.comment = comment - def to_excellon(self): + def to_excellon(self, settings=None): return ';%s' % self.comment @@ -271,7 +298,7 @@ class HeaderBeginStmt(ExcellonStatement): def __init__(self): pass - def to_excellon(self): + def to_excellon(self, settings=None): return 'M48' @@ -280,7 +307,7 @@ class HeaderEndStmt(ExcellonStatement): def __init__(self): pass - def to_excellon(self): + def to_excellon(self, settings=None): return 'M95' @@ -289,17 +316,21 @@ class RewindStopStmt(ExcellonStatement): def __init__(self): pass - def to_excellon(self): + def to_excellon(self, settings=None): return '%' class EndOfProgramStmt(ExcellonStatement): + @classmethod + def from_excellon(cls, line): + return cls() + def __init__(self, x=None, y=None): self.x = x self.y = y - def to_excellon(self): + def to_excellon(self, settings=None): stmt = 'M30' if self.x is not None: stmt += 'X%s' % write_gerber_value(self.x) @@ -320,7 +351,7 @@ class UnitStmt(ExcellonStatement): self.units = units.lower() self.zero_suppression = zero_suppression - def to_excellon(self): + def to_excellon(self, settings=None): stmt = '%s,%s' % ('INCH' if self.units == 'inch' else 'METRIC', 'LZ' if self.zero_suppression == 'trailing' else 'TZ') @@ -338,7 +369,7 @@ class IncrementalModeStmt(ExcellonStatement): raise ValueError('Mode may be "on" or "off"') self.mode = mode - def to_excellon(self): + def to_excellon(self, settings=None): return 'ICI,%s' % ('OFF' if self.mode == 'off' else 'ON') @@ -355,7 +386,7 @@ class VersionStmt(ExcellonStatement): raise ValueError('Valid versions are 1 or 2') self.version = version - def to_excellon(self): + def to_excellon(self, settings=None): return 'VER,%d' % self.version @@ -372,7 +403,7 @@ class FormatStmt(ExcellonStatement): raise ValueError('Valid formats are 1 or 2') self.format = format - def to_excellon(self): + def to_excellon(self, settings=None): return 'FMAT,%d' % self.format @@ -386,7 +417,7 @@ class LinkToolStmt(ExcellonStatement): def __init__(self, linked_tools): self.linked_tools = [int(x) for x in linked_tools] - def to_excellon(self): + def to_excellon(self, settings=None): return '/'.join([str(x) for x in self.linked_tools]) @@ -404,10 +435,37 @@ class MeasuringModeStmt(ExcellonStatement): raise ValueError('units must be "inch" or "metric"') self.units = units - def to_excellon(self): + def to_excellon(self, settings=None): return 'M72' if self.units == 'inch' else 'M71' +class RouteModeStmt(ExcellonStatement): + + def __init__(self): + pass + + def to_excellon(self, settings=None): + return 'G00' + + +class DrillModeStmt(ExcellonStatement): + + def __init__(self): + pass + + def to_excellon(self, settings=None): + return 'G05' + + +class AbsoluteModeStmt(ExcellonStatement): + + def __init__(self): + pass + + def to_excellon(self, settings=None): + return 'G90' + + class UnknownStmt(ExcellonStatement): @classmethod @@ -417,7 +475,7 @@ class UnknownStmt(ExcellonStatement): def __init__(self, stmt): self.stmt = stmt - def to_excellon(self): + def to_excellon(self, settings=None): return self.stmt diff --git a/gerber/tests/test_excellon_statements.py b/gerber/tests/test_excellon_statements.py index 0e1efa6..13733f8 100644 --- a/gerber/tests/test_excellon_statements.py +++ b/gerber/tests/test_excellon_statements.py @@ -68,18 +68,31 @@ def test_toolselection_dump(): def test_coordinatestmt_factory(): """ Test CoordinateStmt factory method """ + settings = FileSettings(format=(2, 5), zero_suppression='trailing', + units='inch', notation='absolute') + line = 'X0278207Y0065293' - stmt = CoordinateStmt.from_excellon(line) + stmt = CoordinateStmt.from_excellon(line, settings) assert_equal(stmt.x, 2.78207) assert_equal(stmt.y, 0.65293) - line = 'X02945' - stmt = CoordinateStmt.from_excellon(line) - assert_equal(stmt.x, 2.945) + # line = 'X02945' + # stmt = CoordinateStmt.from_excellon(line) + # assert_equal(stmt.x, 2.945) + + # line = 'Y00575' + # stmt = CoordinateStmt.from_excellon(line) + # assert_equal(stmt.y, 0.575) + + settings = FileSettings(format=(2, 4), zero_suppression='leading', + units='inch', notation='absolute') + + line = 'X9660Y4639' + stmt = CoordinateStmt.from_excellon(line, settings) + assert_equal(stmt.x, 0.9660) + assert_equal(stmt.y, 0.4639) + assert_equal(stmt.to_excellon(settings), "X9660Y4639") - line = 'Y00575' - stmt = CoordinateStmt.from_excellon(line) - assert_equal(stmt.y, 0.575) def test_coordinatestmt_dump(): @@ -88,9 +101,13 @@ def test_coordinatestmt_dump(): lines = ['X0278207Y0065293', 'X0243795', 'Y0082528', 'Y0086028', 'X0251295Y0081528', 'X02525Y0078', 'X0255Y00575', 'Y0052', 'X02675', 'Y00575', 'X02425', 'Y0052', 'X023', ] + + settings = FileSettings(format=(2, 4), zero_suppression='leading', + units='inch', notation='absolute') + for line in lines: - stmt = CoordinateStmt.from_excellon(line) - assert_equal(stmt.to_excellon(), line) + stmt = CoordinateStmt.from_excellon(line, settings) + assert_equal(stmt.to_excellon(settings), line) def test_commentstmt_factory(): From 0f36084aadc85670b96ca63a8258d18db4b18cf8 Mon Sep 17 00:00:00 2001 From: Paulo Henrique Silva Date: Thu, 15 Jan 2015 05:01:40 -0200 Subject: [PATCH 015/186] Add Repeat Hole Stmt and fix UnitStmt parsing * Repeat hole support (with no real parsing, just a copy) * Fix UnitStmt to works even when a ,TZ or ,LZ information is not provided. --- gerber/excellon.py | 7 +++++-- gerber/excellon_statements.py | 21 ++++++++++++++++++++- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/gerber/excellon.py b/gerber/excellon.py index ee38367..17b870a 100755 --- a/gerber/excellon.py +++ b/gerber/excellon.py @@ -204,8 +204,7 @@ class ExcellonParser(object): self.statements.append(DrillModeStmt()) self.state = 'DRILL' - elif (('INCH' in line or 'METRIC' in line) and - ('LZ' in line or 'TZ' in line)): + elif 'INCH' in line or 'METRIC' in line: stmt = UnitStmt.from_excellon(line) self.units = stmt.units self.zero_suppression = stmt.zero_suppression @@ -244,6 +243,10 @@ class ExcellonParser(object): self.active_tool = self.tools[stmt.tool] self.statements.append(stmt) + elif line[0] == 'R' and self.state != 'HEADER': + stmt = RepeatHoleStmt.from_excellon(line, self._settings()) + self.statements.append(stmt) + elif line[0] in ['X', 'Y']: stmt = CoordinateStmt.from_excellon(line, self._settings()) x = stmt.x diff --git a/gerber/excellon_statements.py b/gerber/excellon_statements.py index c4f4015..02bb923 100644 --- a/gerber/excellon_statements.py +++ b/gerber/excellon_statements.py @@ -29,7 +29,7 @@ __all__ = ['ExcellonTool', 'ToolSelectionStmt', 'CoordinateStmt', 'RewindStopStmt', 'EndOfProgramStmt', 'UnitStmt', 'IncrementalModeStmt', 'VersionStmt', 'FormatStmt', 'LinkToolStmt', 'MeasuringModeStmt', 'RouteModeStmt', 'DrillModeStmt', 'AbsoluteModeStmt', - 'UnknownStmt', + 'RepeatHoleStmt', 'UnknownStmt', ] @@ -280,6 +280,22 @@ class CoordinateStmt(ExcellonStatement): return '' % coord_str +class RepeatHoleStmt(ExcellonStatement): + + @classmethod + def from_excellon(cls, line, settings): + return cls(line) + + def __init__(self, line): + self.line = line + + def to_excellon(self, settings): + return self.line + + def __str__(self): + return '' % self.line + + class CommentStmt(ExcellonStatement): @classmethod @@ -478,6 +494,9 @@ class UnknownStmt(ExcellonStatement): def to_excellon(self, settings=None): return self.stmt + def __str__(self): + return "" % self.stmt + def pairwise(iterator): """ Iterate over list taking two elements at a time. From d5157c1d076360e3702a910f119b9fc44ff76df5 Mon Sep 17 00:00:00 2001 From: Hamilton Kibbe Date: Fri, 23 Jan 2015 13:05:25 -0500 Subject: [PATCH 016/186] Fix tests for leading zero suppression --- gerber/tests/test_excellon_statements.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/gerber/tests/test_excellon_statements.py b/gerber/tests/test_excellon_statements.py index 13733f8..4c3201b 100644 --- a/gerber/tests/test_excellon_statements.py +++ b/gerber/tests/test_excellon_statements.py @@ -23,9 +23,9 @@ def test_excellontool_factory(): def test_excellontool_dump(): """ Test ExcellonTool to_excellon() """ - exc_lines = ['T1F0S0C0.01200', 'T2F0S0C0.01500', 'T3F0S0C0.01968', - 'T4F0S0C0.02800', 'T5F0S0C0.03300', 'T6F0S0C0.03800', - 'T7F0S0C0.04300', 'T8F0S0C0.12500', 'T9F0S0C0.13000', ] + exc_lines = ['T01F0S0C0.01200', 'T02F0S0C0.01500', 'T03F0S0C0.01968', + 'T04F0S0C0.02800', 'T05F0S0C0.03300', 'T06F0S0C0.03800', + 'T07F0S0C0.04300', 'T08F0S0C0.12500', 'T09F0S0C0.13000', ] settings = FileSettings(format=(2, 5), zero_suppression='trailing', units='inch', notation='absolute') for line in exc_lines: @@ -98,9 +98,9 @@ def test_coordinatestmt_factory(): def test_coordinatestmt_dump(): """ Test CoordinateStmt to_excellon() """ - lines = ['X0278207Y0065293', 'X0243795', 'Y0082528', 'Y0086028', - 'X0251295Y0081528', 'X02525Y0078', 'X0255Y00575', 'Y0052', - 'X02675', 'Y00575', 'X02425', 'Y0052', 'X023', ] + lines = ['X278207Y65293', 'X243795', 'Y82528', 'Y86028', + 'X251295Y81528', 'X2525Y78', 'X255Y575', 'Y52', + 'X2675', 'Y575', 'X2425', 'Y52', 'X23', ] settings = FileSettings(format=(2, 4), zero_suppression='leading', units='inch', notation='absolute') From b495d51354eff7b858dbbd41740865eba7f39100 Mon Sep 17 00:00:00 2001 From: Hamilton Kibbe Date: Sun, 25 Jan 2015 14:19:48 -0500 Subject: [PATCH 017/186] Changed zeros/zero suppression conventions to match file format specs --- gerber/cam.py | 84 ++++++++++++++++++++++-- gerber/excellon.py | 32 ++++----- gerber/excellon_statements.py | 10 +-- gerber/tests/test_cam.py | 27 +++++++- gerber/tests/test_excellon.py | 13 ++-- gerber/tests/test_excellon_statements.py | 14 +++- 6 files changed, 141 insertions(+), 39 deletions(-) diff --git a/gerber/cam.py b/gerber/cam.py index 051c3b5..a4057bc 100644 --- a/gerber/cam.py +++ b/gerber/cam.py @@ -27,9 +27,34 @@ class FileSettings(object): """ CAM File Settings Provides a common representation of gerber/excellon file settings + + Parameters + ---------- + notation: string + notation format. either 'absolute' or 'incremental' + + units : string + Measurement units. 'inch' or 'metric' + + zero_suppression: string + 'leading' to suppress leading zeros, 'trailing' to suppress trailing zeros. + This is the convention used in Gerber files. + + format : tuple (int, int) + Decimal format + + zeros : string + 'leading' to include leading zeros, 'trailing to include trailing zeros. + This is the convention used in Excellon files + + Notes + ----- + Either `zeros` or `zero_suppression` should be specified, there is no need to + specify both. `zero_suppression` will take on the opposite value of `zeros` + and vice versa """ def __init__(self, notation='absolute', units='inch', - zero_suppression='trailing', format=(2, 5)): + zero_suppression=None, format=(2, 5), zeros=None): if notation not in ['absolute', 'incremental']: raise ValueError('Notation must be either absolute or incremental') self.notation = notation @@ -38,15 +63,52 @@ class FileSettings(object): raise ValueError('Units must be either inch or metric') self.units = units - if zero_suppression not in ['leading', 'trailing']: - raise ValueError('Zero suppression must be either leading or \ - trailling') - self.zero_suppression = zero_suppression + + if zero_suppression is None and zeros is None: + self.zero_suppression = 'trailing' + + elif zero_suppression == zeros: + raise ValueError('Zeros and Zero Suppression must be different. \ + Best practice is to specify only one.') + + elif zero_suppression is not None: + if zero_suppression not in ['leading', 'trailing']: + raise ValueError('Zero suppression must be either leading or \ + trailling') + self.zero_suppression = zero_suppression + + + elif zeros is not None: + if zeros not in ['leading', 'trailing']: + raise ValueError('Zeros must be either leading or trailling') + self.zeros = zeros + else: + self.zeros = 'leading' + if len(format) != 2: raise ValueError('Format must be a tuple(n=2) of integers') self.format = format + @property + def zero_suppression(self): + return self._zero_suppression + + @zero_suppression.setter + def zero_suppression(self, value): + self._zero_suppression = value + self._zeros = 'leading' if value == 'trailing' else 'trailing' + + @property + def zeros(self): + return self._zeros + + @zeros.setter + def zeros(self, value): + + self._zeros = value + self._zero_suppression = 'leading' if value == 'trailing' else 'trailing' + def __getitem__(self, key): if key == 'notation': return self.notation @@ -54,6 +116,8 @@ class FileSettings(object): return self.units elif key == 'zero_suppression': return self.zero_suppression + elif key == 'zeros': + return self.zeros elif key == 'format': return self.format else: @@ -69,11 +133,18 @@ class FileSettings(object): if value not in ['inch', 'metric']: raise ValueError('Units must be either inch or metric') self.units = value + elif key == 'zero_suppression': if value not in ['leading', 'trailing']: raise ValueError('Zero suppression must be either leading or \ trailling') self.zero_suppression = value + + elif key == 'zeros': + if value not in ['leading', 'trailing']: + raise ValueError('Zeros must be either leading or trailling') + self.zeros = value + elif key == 'format': if len(value) != 2: raise ValueError('Format must be a tuple(n=2) of integers') @@ -86,7 +157,6 @@ class FileSettings(object): self.format == other.format) - class CamFile(object): """ Base class for Gerber/Excellon files. @@ -131,11 +201,13 @@ class CamFile(object): self.notation = settings['notation'] self.units = settings['units'] self.zero_suppression = settings['zero_suppression'] + self.zeros = settings['zeros'] self.format = settings['format'] else: self.notation = 'absolute' self.units = 'inch' self.zero_suppression = 'trailing' + self.zeros = 'leading' self.format = (2, 5) self.statements = statements if statements is not None else [] self.primitives = primitives diff --git a/gerber/excellon.py b/gerber/excellon.py index 17b870a..235f966 100755 --- a/gerber/excellon.py +++ b/gerber/excellon.py @@ -43,7 +43,9 @@ def read(filename): An ExcellonFile created from the specified file. """ - return ExcellonParser(None).parse(filename) + # File object should use settings from source file by default. + settings = FileSettings(**detect_excellon_format(filename)) + return ExcellonParser(settings).parse(filename) class ExcellonFile(CamFile): @@ -116,7 +118,7 @@ class ExcellonParser(object): def __init__(self, settings=None): self.notation = 'absolute' self.units = 'inch' - self.zero_suppression = 'leading' + self.zeros = 'leading' self.format = (2, 4) self.state = 'INIT' self.statements = [] @@ -125,8 +127,9 @@ class ExcellonParser(object): self.active_tool = None self.pos = [0., 0.] if settings is not None: + print('Setting shit from settings. zeros: %s' %settings.zeros) self.units = settings.units - self.zero_suppression = settings.zero_suppression + self.zeros = settings.zeros self.notation = settings.notation self.format = settings.format @@ -207,7 +210,7 @@ class ExcellonParser(object): elif 'INCH' in line or 'METRIC' in line: stmt = UnitStmt.from_excellon(line) self.units = stmt.units - self.zero_suppression = stmt.zero_suppression + self.zeros = stmt.zeros self.statements.append(stmt) elif line[:3] == 'M71' or line [:3] == 'M72': @@ -270,8 +273,7 @@ class ExcellonParser(object): def _settings(self): return FileSettings(units=self.units, format=self.format, - zero_suppression=self.zero_suppression, - notation=self.notation) + zeros=self.zeros, notation=self.notation) def detect_excellon_format(filename): @@ -293,7 +295,7 @@ def detect_excellon_format(filename): results = {} detected_zeros = None detected_format = None - zs_options = ('leading', 'trailing', ) + zeros_options = ('leading', 'trailing', ) format_options = ((2, 4), (2, 5), (3, 3),) # Check for obvious clues: @@ -301,7 +303,7 @@ def detect_excellon_format(filename): p.parse(filename) # Get zero_suppression from a unit statement - zero_statements = [stmt.zero_suppression for stmt in p.statements + zero_statements = [stmt.zeros for stmt in p.statements if isinstance(stmt, UnitStmt)] # get format from altium comment @@ -316,19 +318,19 @@ def detect_excellon_format(filename): # Bail out here if possible if detected_format is not None and detected_zeros is not None: - return {'format': detected_format, 'zero_suppression': detected_zeros} + return {'format': detected_format, 'zeros': detected_zeros} # Only look at remaining options if detected_format is not None: format_options = (detected_format,) if detected_zeros is not None: - zs_options = (detected_zeros,) + zeros_options = (detected_zeros,) # Brute force all remaining options, and pick the best looking one... - for zs in zs_options: + for zeros in zeros_options: for fmt in format_options: - key = (fmt, zs) - settings = FileSettings(zero_suppression=zs, format=fmt) + key = (fmt, zeros) + settings = FileSettings(zeros=zeros, format=fmt) try: p = ExcellonParser(settings) p.parse(filename) @@ -351,7 +353,7 @@ def detect_excellon_format(filename): # Bail out here if we got everything.... if detected_format is not None and detected_zeros is not None: - return {'format': detected_format, 'zero_suppression': detected_zeros} + return {'format': detected_format, 'zeros': detected_zeros} # Otherwise score each option and pick the best candidate else: @@ -362,7 +364,7 @@ def detect_excellon_format(filename): minscore = min(scores.values()) for key in scores.iterkeys(): if scores[key] == minscore: - return {'format': key[0], 'zero_suppression': key[1]} + return {'format': key[0], 'zeros': key[1]} def _layer_size_score(size, hole_count, hole_area): diff --git a/gerber/excellon_statements.py b/gerber/excellon_statements.py index 02bb923..71009d8 100644 --- a/gerber/excellon_statements.py +++ b/gerber/excellon_statements.py @@ -360,16 +360,16 @@ class UnitStmt(ExcellonStatement): @classmethod def from_excellon(cls, line): units = 'inch' if 'INCH' in line else 'metric' - zero_suppression = 'trailing' if 'LZ' in line else 'leading' - return cls(units, zero_suppression) + zeros = 'leading' if 'LZ' in line else 'trailing' + return cls(units, zeros) - def __init__(self, units='inch', zero_suppression='trailing'): + def __init__(self, units='inch', zeros='leading'): self.units = units.lower() - self.zero_suppression = zero_suppression + self.zeros = zeros def to_excellon(self, settings=None): stmt = '%s,%s' % ('INCH' if self.units == 'inch' else 'METRIC', - 'LZ' if self.zero_suppression == 'trailing' + 'LZ' if self.zeros == 'leading' else 'TZ') return stmt diff --git a/gerber/tests/test_cam.py b/gerber/tests/test_cam.py index ce4ec44..1aeb18c 100644 --- a/gerber/tests/test_cam.py +++ b/gerber/tests/test_cam.py @@ -64,5 +64,28 @@ def test_camfile_settings(): """ cf = CamFile() assert_equal(cf.settings, FileSettings()) - - \ No newline at end of file + +def test_zeros(): + + fs = FileSettings() + assert_equal(fs.zero_suppression, 'trailing') + assert_equal(fs.zeros, 'leading') + + + fs['zero_suppression'] = 'leading' + assert_equal(fs.zero_suppression, 'leading') + assert_equal(fs.zeros, 'trailing') + + fs.zero_suppression = 'trailing' + assert_equal(fs.zero_suppression, 'trailing') + assert_equal(fs.zeros, 'leading') + + fs['zeros'] = 'trailing' + assert_equal(fs.zeros, 'trailing') + assert_equal(fs.zero_suppression, 'leading') + + + fs.zeros= 'leading' + assert_equal(fs.zeros, 'leading') + assert_equal(fs.zero_suppression, 'trailing') + diff --git a/gerber/tests/test_excellon.py b/gerber/tests/test_excellon.py index 72e3d7d..70e4560 100644 --- a/gerber/tests/test_excellon.py +++ b/gerber/tests/test_excellon.py @@ -15,18 +15,13 @@ def test_format_detection(): """ settings = detect_excellon_format(NCDRILL_FILE) assert_equal(settings['format'], (2, 4)) - assert_equal(settings['zero_suppression'], 'leading') + assert_equal(settings['zeros'], 'trailing') def test_read(): ncdrill = read(NCDRILL_FILE) assert(isinstance(ncdrill, ExcellonFile)) - + def test_read_settings(): ncdrill = read(NCDRILL_FILE) - assert_equal(ncdrill.settings.format, (2, 4)) - assert_equal(ncdrill.settings.zero_suppression, 'leading') - - - - - + assert_equal(ncdrill.settings['format'], (2, 4)) + assert_equal(ncdrill.settings['zeros'], 'trailing') diff --git a/gerber/tests/test_excellon_statements.py b/gerber/tests/test_excellon_statements.py index 4c3201b..2e508ff 100644 --- a/gerber/tests/test_excellon_statements.py +++ b/gerber/tests/test_excellon_statements.py @@ -141,12 +141,22 @@ def test_unitstmt_factory(): line = 'INCH,LZ' stmt = UnitStmt.from_excellon(line) assert_equal(stmt.units, 'inch') - assert_equal(stmt.zero_suppression, 'trailing') + assert_equal(stmt.zeros, 'leading') + + line = 'INCH,TZ' + stmt = UnitStmt.from_excellon(line) + assert_equal(stmt.units, 'inch') + assert_equal(stmt.zeros, 'trailing') + + line = 'METRIC,LZ' + stmt = UnitStmt.from_excellon(line) + assert_equal(stmt.units, 'metric') + assert_equal(stmt.zeros, 'leading') line = 'METRIC,TZ' stmt = UnitStmt.from_excellon(line) assert_equal(stmt.units, 'metric') - assert_equal(stmt.zero_suppression, 'leading') + assert_equal(stmt.zeros, 'trailing') def test_unitstmt_dump(): From 939f782728a1b16f85ad2697b03ef026a88ad354 Mon Sep 17 00:00:00 2001 From: Hamilton Kibbe Date: Sun, 25 Jan 2015 14:22:27 -0500 Subject: [PATCH 018/186] ...oops --- gerber/excellon.py | 1 - 1 file changed, 1 deletion(-) diff --git a/gerber/excellon.py b/gerber/excellon.py index 235f966..79a6e1f 100755 --- a/gerber/excellon.py +++ b/gerber/excellon.py @@ -127,7 +127,6 @@ class ExcellonParser(object): self.active_tool = None self.pos = [0., 0.] if settings is not None: - print('Setting shit from settings. zeros: %s' %settings.zeros) self.units = settings.units self.zeros = settings.zeros self.notation = settings.notation From c054136a6531404e3b20aadbc7fba2ec25b50a4a Mon Sep 17 00:00:00 2001 From: Hamilton Kibbe Date: Mon, 26 Jan 2015 22:16:00 -0500 Subject: [PATCH 019/186] Added some tests --- gerber/gerber_statements.py | 5 +- gerber/tests/resources/top_copper.GTL | 1 + gerber/tests/test_gerber_statements.py | 111 +++++++++++++++++++++++++ gerber/tests/test_rs274x.py | 11 +++ 4 files changed, 126 insertions(+), 2 deletions(-) diff --git a/gerber/gerber_statements.py b/gerber/gerber_statements.py index 83ae143..3419948 100644 --- a/gerber/gerber_statements.py +++ b/gerber/gerber_statements.py @@ -251,8 +251,8 @@ class ADParamStmt(ParamStmt): Returns ------- - ParamStmt : LPParamStmt - Initialized LPParamStmt class. + ParamStmt : ADParamStmt + Initialized ADParamStmt class. """ ParamStmt.__init__(self, param) @@ -389,6 +389,7 @@ class AMParamStmt(ParamStmt): """ ParamStmt.__init__(self, param) self.name = name + self.macro = macro self.primitives = self._parsePrimitives(macro) def _parsePrimitives(self, macro): diff --git a/gerber/tests/resources/top_copper.GTL b/gerber/tests/resources/top_copper.GTL index cedd2fd..6d382c0 100644 --- a/gerber/tests/resources/top_copper.GTL +++ b/gerber/tests/resources/top_copper.GTL @@ -4,6 +4,7 @@ G75* %FSLAX24Y24*% %IPPOS*% %LPD*% +G04This is a comment,:* %AMOC8* 5,1,8,0,0,1.08239X$1,22.5* % diff --git a/gerber/tests/test_gerber_statements.py b/gerber/tests/test_gerber_statements.py index 62b99b4..c346ace 100644 --- a/gerber/tests/test_gerber_statements.py +++ b/gerber/tests/test_gerber_statements.py @@ -5,6 +5,7 @@ from .tests import * from ..gerber_statements import * +from ..cam import FileSettings def test_FSParamStmt_factory(): @@ -24,6 +25,7 @@ def test_FSParamStmt_factory(): assert_equal(fs.notation, 'incremental') assert_equal(fs.format, (2, 7)) + def test_FSParamStmt(): """ Test FSParamStmt initialization """ @@ -37,6 +39,7 @@ def test_FSParamStmt(): assert_equal(stmt.notation, notation) assert_equal(stmt.format, fmt) + def test_FSParamStmt_dump(): """ Test FSParamStmt to_gerber() """ @@ -48,6 +51,21 @@ def test_FSParamStmt_dump(): fs = FSParamStmt.from_dict(stmt) assert_equal(fs.to_gerber(), '%FSTIX25Y25*%') + settings = FileSettings(zero_suppression='leading', notation='absolute') + assert_equal(fs.to_gerber(settings), '%FSLAX25Y25*%') + + +def test_FSParamStmt_string(): + """ Test FSParamStmt.__str__() + """ + stmt = {'param': 'FS', 'zero': 'L', 'notation': 'A', 'x': '27'} + fs = FSParamStmt.from_dict(stmt) + assert_equal(str(fs), '') + + stmt = {'param': 'FS', 'zero': 'T', 'notation': 'I', 'x': '25'} + fs = FSParamStmt.from_dict(stmt) + assert_equal(str(fs), '') + def test_MOParamStmt_factory(): """ Test MOParamStruct factory @@ -64,6 +82,7 @@ def test_MOParamStmt_factory(): assert_equal(mo.param, 'MO') assert_equal(mo.mode, 'metric') + def test_MOParamStmt(): """ Test MOParamStmt initialization """ @@ -89,6 +108,18 @@ def test_MOParamStmt_dump(): assert_equal(mo.to_gerber(), '%MOMM*%') +def test_MOParamStmt_string(): + """ Test MOParamStmt.__str__() + """ + stmt = {'param': 'MO', 'mo': 'IN'} + mo = MOParamStmt.from_dict(stmt) + assert_equal(str(mo), '') + + stmt = {'param': 'MO', 'mo': 'MM'} + mo = MOParamStmt.from_dict(stmt) + assert_equal(str(mo), '') + + def test_IPParamStmt_factory(): """ Test IPParamStruct factory """ @@ -100,6 +131,7 @@ def test_IPParamStmt_factory(): ip = IPParamStmt.from_dict(stmt) assert_equal(ip.ip, 'negative') + def test_IPParamStmt(): """ Test IPParamStmt initialization """ @@ -130,6 +162,7 @@ def test_OFParamStmt_factory(): assert_equal(of.a, 0.1234567) assert_equal(of.b, 0.1234567) + def test_OFParamStmt(): """ Test IPParamStmt initialization """ @@ -140,6 +173,7 @@ def test_OFParamStmt(): assert_equal(stmt.a, val) assert_equal(stmt.b, val) + def test_OFParamStmt_dump(): """ Test OFParamStmt to_gerber() """ @@ -159,6 +193,7 @@ def test_LPParamStmt_factory(): lp = LPParamStmt.from_dict(stmt) assert_equal(lp.lp, 'dark') + def test_LPParamStmt_dump(): """ Test LPParamStmt to_gerber() """ @@ -171,6 +206,18 @@ def test_LPParamStmt_dump(): assert_equal(lp.to_gerber(), '%LPD*%') +def test_LPParamStmt_string(): + """ Test LPParamStmt.__str__() + """ + stmt = {'param': 'LP', 'lp': 'D'} + lp = LPParamStmt.from_dict(stmt) + assert_equal(str(lp), '') + + stmt = {'param': 'LP', 'lp': 'C'} + lp = LPParamStmt.from_dict(stmt) + assert_equal(str(lp), '') + + def test_INParamStmt_factory(): """ Test INParamStmt factory """ @@ -178,6 +225,7 @@ def test_INParamStmt_factory(): inp = INParamStmt.from_dict(stmt) assert_equal(inp.name, 'test') + def test_INParamStmt_dump(): """ Test INParamStmt to_gerber() """ @@ -193,6 +241,7 @@ def test_LNParamStmt_factory(): lnp = LNParamStmt.from_dict(stmt) assert_equal(lnp.name, 'test') + def test_LNParamStmt_dump(): """ Test LNParamStmt to_gerber() """ @@ -200,6 +249,7 @@ def test_LNParamStmt_dump(): lnp = LNParamStmt.from_dict(stmt) assert_equal(lnp.to_gerber(), '%LNtest*%') + def test_comment_stmt(): """ Test comment statement """ @@ -207,6 +257,7 @@ def test_comment_stmt(): assert_equal(stmt.type, 'COMMENT') assert_equal(stmt.comment, 'A comment') + def test_comment_stmt_dump(): """ Test CommentStmt to_gerber() """ @@ -220,6 +271,7 @@ def test_eofstmt(): stmt = EofStmt() assert_equal(stmt.type, 'EOF') + def test_eofstmt_dump(): """ Test EofStmt to_gerber() """ @@ -239,6 +291,7 @@ def test_quadmodestmt_factory(): stmt = QuadrantModeStmt.from_gerber(line) assert_equal(stmt.mode, 'multi-quadrant') + def test_quadmodestmt_validation(): """ Test QuadrantModeStmt input validation """ @@ -301,3 +354,61 @@ def test_unknownstmt_dump(): stmt = UnknownStmt(line) assert_equal(stmt.to_gerber(), line) + +def test_statement_string(): + """ Test Statement.__str__() + """ + stmt = Statement('PARAM') + assert_equal(str(stmt), '') + stmt.test='PASS' + assert_equal(str(stmt), '') + + +def test_ADParamStmt_factory(): + """ Test ADParamStmt factory + """ + stmt = {'param': 'AD', 'd': 0, 'shape': 'C'} + ad = ADParamStmt.from_dict(stmt) + assert_equal(ad.d, 0) + assert_equal(ad.shape, 'C') + + stmt = {'param': 'AD', 'd': 1, 'shape': 'R'} + ad = ADParamStmt.from_dict(stmt) + assert_equal(ad.d, 1) + assert_equal(ad.shape, 'R') + + stmt = {'param': 'AD', 'd': 2, 'shape': 'O'} + ad = ADParamStmt.from_dict(stmt) + assert_equal(ad.d, 2) + assert_equal(ad.shape, 'O') + + +def test_ADParamStmt_dump(): + """ Test ADParamStmt.to_gerber() + """ + stmt = {'param': 'AD', 'd': 0, 'shape': 'C'} + ad = ADParamStmt.from_dict(stmt) + assert_equal(ad.to_gerber(),'%ADD0C*%') + + stmt = {'param': 'AD', 'd': 1, 'shape': 'R'} + ad = ADParamStmt.from_dict(stmt) + assert_equal(ad.to_gerber(),'%ADD1R*%') + + stmt = {'param': 'AD', 'd': 2, 'shape': 'O'} + ad = ADParamStmt.from_dict(stmt) + assert_equal(ad.to_gerber(),'%ADD2O*%') + +def test_ADParamStmt_string(): + """ Test ADParamStmt.__str__() + """ + stmt = {'param': 'AD', 'd': 0, 'shape': 'C'} + ad = ADParamStmt.from_dict(stmt) + assert_equal(str(ad), '') + + stmt = {'param': 'AD', 'd': 1, 'shape': 'R'} + ad = ADParamStmt.from_dict(stmt) + assert_equal(str(ad), '') + + stmt = {'param': 'AD', 'd': 2, 'shape': 'O'} + ad = ADParamStmt.from_dict(stmt) + assert_equal(str(ad), ' Date: Mon, 26 Jan 2015 22:24:45 -0500 Subject: [PATCH 020/186] merge upstream changes --- gerber/gerber_statements.py | 2 +- gerber/rs274x.py | 2 ++ gerber/tests/test_gerber_statements.py | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/gerber/gerber_statements.py b/gerber/gerber_statements.py index 3419948..d84b5e0 100644 --- a/gerber/gerber_statements.py +++ b/gerber/gerber_statements.py @@ -281,7 +281,7 @@ class ADParamStmt(ParamStmt): elif self.shape == 'R': shape = 'rectangle' elif self.shape == 'O': - shape = 'oblong' + shape = 'obround' else: shape = self.shape diff --git a/gerber/rs274x.py b/gerber/rs274x.py index 2e5a3ec..abd7366 100644 --- a/gerber/rs274x.py +++ b/gerber/rs274x.py @@ -113,6 +113,7 @@ class GerberFile(CamFile): f.write("\n") + class GerberParser(object): """ GerberParser """ @@ -156,6 +157,7 @@ class GerberParser(object): APERTURE_STMT = re.compile(r"(?P(G54)|G55)?D(?P\d+)\*") + COMMENT_STMT = re.compile(r"G04(?P[^*]*)(\*)?") EOF_STMT = re.compile(r"(?PM02)\*") diff --git a/gerber/tests/test_gerber_statements.py b/gerber/tests/test_gerber_statements.py index c346ace..ff967f9 100644 --- a/gerber/tests/test_gerber_statements.py +++ b/gerber/tests/test_gerber_statements.py @@ -411,4 +411,4 @@ def test_ADParamStmt_string(): stmt = {'param': 'AD', 'd': 2, 'shape': 'O'} ad = ADParamStmt.from_dict(stmt) - assert_equal(str(ad), '') From 360eddc3c421cc193716b17d33cc94d8444d64ce Mon Sep 17 00:00:00 2001 From: Hamilton Kibbe Date: Sun, 1 Feb 2015 13:40:08 -0500 Subject: [PATCH 021/186] Added primitives and tests --- gerber/primitives.py | 201 ++++++++++++++++++++++++++++---- gerber/tests/test_primitives.py | 134 ++++++++++++++++++++- gerber/tests/tests.py | 9 +- 3 files changed, 317 insertions(+), 27 deletions(-) diff --git a/gerber/primitives.py b/gerber/primitives.py index e13e37f..61df7c1 100644 --- a/gerber/primitives.py +++ b/gerber/primitives.py @@ -19,7 +19,21 @@ from operator import sub class Primitive(object): + """ Base class for all Cam file primitives + Parameters + --------- + level_polarity : string + Polarity of the parameter. May be 'dark' or 'clear'. Dark indicates + a "positive" primitive, i.e. indicating where coppper should remain, + and clear indicates a negative primitive, such as where copper should + be removed. clear primitives are often used to create cutouts in region + pours. + + rotation : float + Rotation of a primitive about its origin in degrees. Positive rotation + is counter-clockwise as viewed from the board top. + """ def __init__(self, level_polarity='dark', rotation=0): self.level_polarity = level_polarity self.rotation = rotation @@ -102,7 +116,6 @@ class Arc(Primitive): theta0 = (self.start_angle + two_pi) % two_pi theta1 = (self.end_angle + two_pi) % two_pi points = [self.start, self.end] - #Shit's about to get ugly... if self.direction == 'counterclockwise': # Passes through 0 degrees if theta0 > theta1: @@ -170,13 +183,20 @@ class Ellipse(Primitive): self.position = position self.width = width self.height = height + # Axis-aligned width and height + ux = (self.width / 2.) * math.cos(math.radians(self.rotation)) + uy = (self.width / 2.) * math.sin(math.radians(self.rotation)) + vx = (self.height / 2.) * math.cos(math.radians(self.rotation) + (math.pi / 2.)) + vy = (self.height / 2.) * math.sin(math.radians(self.rotation) + (math.pi / 2.)) + self._abs_width = 2 * math.sqrt((ux * ux) + (vx * vx)) + self._abs_height = 2 * math.sqrt((uy * uy) + (vy * vy)) @property def bounding_box(self): - min_x = self.position[0] - (self.width / 2.0) - max_x = self.position[0] + (self.width / 2.0) - min_y = self.position[1] - (self.height / 2.0) - max_y = self.position[1] + (self.height / 2.0) + min_x = self.position[0] - (self._abs_width / 2.0) + max_x = self.position[0] + (self._abs_width / 2.0) + min_y = self.position[1] - (self._abs_height / 2.0) + max_y = self.position[1] + (self._abs_height / 2.0) return ((min_x, max_x), (min_y, max_y)) @@ -188,16 +208,21 @@ class Rectangle(Primitive): self.position = position self.width = width self.height = height + # Axis-aligned width and height + self._abs_width = (math.cos(math.radians(self.rotation)) * self.width + + math.sin(math.radians(self.rotation)) * self.height) + self._abs_height = (math.cos(math.radians(self.rotation)) * self.height + + math.sin(math.radians(self.rotation)) * self.width) @property def lower_left(self): - return (self.position[0] - (self.width / 2.), - self.position[1] - (self.height / 2.)) + return (self.position[0] - (self._abs_width / 2.), + self.position[1] - (self._abs_height / 2.)) @property def upper_right(self): - return (self.position[0] + (self.width / 2.), - self.position[1] + (self.height / 2.)) + return (self.position[0] + (self._abs_width / 2.), + self.position[1] + (self._abs_height / 2.)) @property def bounding_box(self): @@ -207,21 +232,109 @@ class Rectangle(Primitive): max_y = self.upper_right[1] return ((min_x, max_x), (min_y, max_y)) - @property - def stroke_width(self): - return max((self.width, self.height)) class Diamond(Primitive): - pass + """ + """ + def __init__(self, position, width, height, **kwargs): + super(Diamond, self).__init__(**kwargs) + self.position = position + self.width = width + self.height = height + # Axis-aligned width and height + self._abs_width = (math.cos(math.radians(self.rotation)) * self.width + + math.sin(math.radians(self.rotation)) * self.height) + self._abs_height = (math.cos(math.radians(self.rotation)) * self.height + + math.sin(math.radians(self.rotation)) * self.width) + + @property + def lower_left(self): + return (self.position[0] - (self._abs_width / 2.), + self.position[1] - (self._abs_height / 2.)) + + @property + def upper_right(self): + return (self.position[0] + (self._abs_width / 2.), + self.position[1] + (self._abs_height / 2.)) + + @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)) class ChamferRectangle(Primitive): - pass + """ + """ + def __init__(self, position, width, height, chamfer, corners, **kwargs): + super(ChamferRectangle, self).__init__(**kwargs) + self.position = position + self.width = width + self.height = height + self.chamfer = chamfer + self.corners = corners + # Axis-aligned width and height + self._abs_width = (math.cos(math.radians(self.rotation)) * self.width + + math.sin(math.radians(self.rotation)) * self.height) + self._abs_height = (math.cos(math.radians(self.rotation)) * self.height + + math.sin(math.radians(self.rotation)) * self.width) + + @property + def lower_left(self): + return (self.position[0] - (self._abs_width / 2.), + self.position[1] - (self._abs_height / 2.)) + + @property + def upper_right(self): + return (self.position[0] + (self._abs_width / 2.), + self.position[1] + (self._abs_height / 2.)) + + @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)) class RoundRectangle(Primitive): - pass + """ + """ + def __init__(self, position, width, height, radius, corners, **kwargs): + super(RoundRectangle, self).__init__(**kwargs) + self.position = position + self.width = width + self.height = height + self.radius = radius + self.corners = corners + # Axis-aligned width and height + self._abs_width = (math.cos(math.radians(self.rotation)) * self.width + + math.sin(math.radians(self.rotation)) * self.height) + self._abs_height = (math.cos(math.radians(self.rotation)) * self.height + + math.sin(math.radians(self.rotation)) * self.width) + + @property + def lower_left(self): + return (self.position[0] - (self._abs_width / 2.), + self.position[1] - (self._abs_height / 2.)) + + @property + def upper_right(self): + return (self.position[0] + (self._abs_width / 2.), + self.position[1] + (self._abs_height / 2.)) + + @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)) class Obround(Primitive): @@ -310,7 +423,7 @@ class Region(Primitive): class RoundButterfly(Primitive): - """ + """ A circle with two diagonally-opposite quadrants removed """ def __init__(self, position, diameter, **kwargs): super(RoundButterfly, self).__init__(**kwargs) @@ -328,17 +441,64 @@ class RoundButterfly(Primitive): min_y = self.position[1] - self.radius max_y = self.position[1] + self.radius return ((min_x, max_x), (min_y, max_y)) - + class SquareButterfly(Primitive): - pass + """ A square with two diagonally-opposite quadrants removed + """ + def __init__(self, position, side, **kwargs): + super(SquareButterfly, self).__init__(**kwargs) + self.position = position + self.side = side + + + @property + def bounding_box(self): + min_x = self.position[0] - (self.side / 2.) + max_x = self.position[0] + (self.side / 2.) + min_y = self.position[1] - (self.side / 2.) + max_y = self.position[1] + (self.side / 2.) + return ((min_x, max_x), (min_y, max_y)) class Donut(Primitive): - pass + """ A Shape with an identical concentric shape removed from its center + """ + def __init__(self, position, shape, inner_diameter, outer_diameter, **kwargs): + super(Donut, self).__init__(**kwargs) + self.position = position + self.shape = shape + self.inner_diameter = inner_diameter + self.outer_diameter = outer_diameter + if self.shape in ('round', 'square', 'octagon'): + self.width = outer_diameter + self.height = outer_diameter + else: + # Hexagon + 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.), + self.position[1] - (self.height / 2.)) + + @property + def upper_right(self): + return (self.position[0] + (self.width / 2.), + self.position[1] + (self.height / 2.)) + + @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)) class Drill(Primitive): - """ + """ A drill hole """ def __init__(self, position, diameter): super(Drill, self).__init__('dark') @@ -356,3 +516,4 @@ class Drill(Primitive): min_y = self.position[1] - self.radius max_y = self.position[1] + self.radius return ((min_x, max_x), (min_y, max_y)) + diff --git a/gerber/tests/test_primitives.py b/gerber/tests/test_primitives.py index 29036b4..4534484 100644 --- a/gerber/tests/test_primitives.py +++ b/gerber/tests/test_primitives.py @@ -6,7 +6,6 @@ from ..primitives import * from tests import * - def test_line_angle(): """ Test Line primitive angle calculation """ @@ -22,7 +21,8 @@ def test_line_angle(): l = Line(start, end, 0) line_angle = (l.angle + 2 * math.pi) % (2 * math.pi) assert_almost_equal(line_angle, expected) - + + def test_line_bounds(): """ Test Line primitive bounding box calculation """ @@ -34,6 +34,7 @@ def test_line_bounds(): l = Line(start, end, 0) assert_equal(l.bounding_box, expected) + def test_arc_radius(): """ Test Arc primitive radius calculation """ @@ -65,21 +66,144 @@ def test_arc_bounds(): ((1, 0), (0, 1), (0, 0), 'counterclockwise', ((0, 1), (0, 1))), #TODO: ADD MORE TEST CASES HERE ] - for start, end, center, direction, bounds in cases: a = Arc(start, end, center, direction, 0) assert_equal(a.bounding_box, bounds) - + + def test_circle_radius(): """ Test Circle primitive radius calculation """ c = Circle((1, 1), 2) assert_equal(c.radius, 1) - + + def test_circle_bounds(): """ Test Circle bounding box calculation """ c = Circle((1, 1), 2) assert_equal(c.bounding_box, ((0, 2), (0, 2))) + + +def test_ellipse_ctor(): + """ Test ellipse creation + """ + e = Ellipse((2, 2), 3, 2) + assert_equal(e.position, (2, 2)) + assert_equal(e.width, 3) + assert_equal(e.height, 2) + + +def test_ellipse_bounds(): + """ Test ellipse bounding box calculation + """ + e = Ellipse((2, 2), 4, 2) + assert_equal(e.bounding_box, ((0, 4), (1, 3))) + e = Ellipse((2, 2), 4, 2, rotation=90) + assert_equal(e.bounding_box, ((1, 3), (0, 4))) + e = Ellipse((2, 2), 4, 2, rotation=180) + assert_equal(e.bounding_box, ((0, 4), (1, 3))) + e = Ellipse((2, 2), 4, 2, rotation=270) + assert_equal(e.bounding_box, ((1, 3), (0, 4))) + +def test_rectangle_ctor(): + """ Test rectangle creation + """ + test_cases = (((0,0), 1, 1), ((0, 0), 1, 2), ((1,1), 1, 2)) + for pos, width, height in test_cases: + r = Rectangle(pos, width, height) + assert_equal(r.position, pos) + assert_equal(r.width, width) + assert_equal(r.height, height) + +def test_rectangle_bounds(): + """ Test rectangle bounding box calculation + """ + r = Rectangle((0,0), 2, 2) + xbounds, ybounds = r.bounding_box + assert_array_almost_equal(xbounds, (-1, 1)) + assert_array_almost_equal(ybounds, (-1, 1)) + r = Rectangle((0,0), 2, 2, rotation=45) + xbounds, ybounds = r.bounding_box + assert_array_almost_equal(xbounds, (-math.sqrt(2), math.sqrt(2))) + assert_array_almost_equal(ybounds, (-math.sqrt(2), math.sqrt(2))) + +def test_diamond_ctor(): + """ Test diamond creation + """ + test_cases = (((0,0), 1, 1), ((0, 0), 1, 2), ((1,1), 1, 2)) + for pos, width, height in test_cases: + d = Diamond(pos, width, height) + assert_equal(d.position, pos) + assert_equal(d.width, width) + assert_equal(d.height, height) + +def test_diamond_bounds(): + """ Test diamond bounding box calculation + """ + d = Diamond((0,0), 2, 2) + xbounds, ybounds = d.bounding_box + assert_array_almost_equal(xbounds, (-1, 1)) + assert_array_almost_equal(ybounds, (-1, 1)) + d = Diamond((0,0), math.sqrt(2), math.sqrt(2), rotation=45) + xbounds, ybounds = d.bounding_box + assert_array_almost_equal(xbounds, (-1, 1)) + assert_array_almost_equal(ybounds, (-1, 1)) + + +def test_chamfer_rectangle_ctor(): + """ Test chamfer rectangle creation + """ + test_cases = (((0,0), 1, 1, 0.2, (True, True, False, False)), + ((0, 0), 1, 2, 0.3, (True, True, True, True)), + ((1,1), 1, 2, 0.4, (False, False, False, False))) + for pos, width, height, chamfer, corners in test_cases: + r = ChamferRectangle(pos, width, height, chamfer, corners) + assert_equal(r.position, pos) + assert_equal(r.width, width) + assert_equal(r.height, height) + assert_equal(r.chamfer, chamfer) + assert_array_almost_equal(r.corners, corners) + +def test_chamfer_rectangle_bounds(): + """ Test chamfer rectangle bounding box calculation + """ + r = ChamferRectangle((0,0), 2, 2, 0.2, (True, True, False, False)) + xbounds, ybounds = r.bounding_box + assert_array_almost_equal(xbounds, (-1, 1)) + assert_array_almost_equal(ybounds, (-1, 1)) + r = ChamferRectangle((0,0), 2, 2, 0.2, (True, True, False, False), rotation=45) + xbounds, ybounds = r.bounding_box + assert_array_almost_equal(xbounds, (-math.sqrt(2), math.sqrt(2))) + assert_array_almost_equal(ybounds, (-math.sqrt(2), math.sqrt(2))) + + +def test_round_rectangle_ctor(): + """ Test round rectangle creation + """ + test_cases = (((0,0), 1, 1, 0.2, (True, True, False, False)), + ((0, 0), 1, 2, 0.3, (True, True, True, True)), + ((1,1), 1, 2, 0.4, (False, False, False, False))) + for pos, width, height, radius, corners in test_cases: + r = RoundRectangle(pos, width, height, radius, corners) + assert_equal(r.position, pos) + assert_equal(r.width, width) + assert_equal(r.height, height) + assert_equal(r.radius, radius) + assert_array_almost_equal(r.corners, corners) + +def test_round_rectangle_bounds(): + """ Test round rectangle bounding box calculation + """ + r = RoundRectangle((0,0), 2, 2, 0.2, (True, True, False, False)) + xbounds, ybounds = r.bounding_box + assert_array_almost_equal(xbounds, (-1, 1)) + assert_array_almost_equal(ybounds, (-1, 1)) + r = RoundRectangle((0,0), 2, 2, 0.2, (True, True, False, False), rotation=45) + xbounds, ybounds = r.bounding_box + assert_array_almost_equal(xbounds, (-math.sqrt(2), math.sqrt(2))) + assert_array_almost_equal(ybounds, (-math.sqrt(2), math.sqrt(2))) + + \ No newline at end of file diff --git a/gerber/tests/tests.py b/gerber/tests/tests.py index 222eea3..e7029e4 100644 --- a/gerber/tests/tests.py +++ b/gerber/tests/tests.py @@ -15,5 +15,10 @@ from nose.tools import raises from nose import with_setup __all__ = ['assert_in', 'assert_not_in', 'assert_equal', 'assert_not_equal', - 'assert_almost_equal', 'assert_true', 'assert_false', - 'assert_raises', 'raises', 'with_setup' ] + 'assert_almost_equal', 'assert_array_almost_equal', 'assert_true', + 'assert_false', 'assert_raises', 'raises', 'with_setup' ] + +def assert_array_almost_equal(arr1, arr2, decimal=6): + assert_equal(len(arr1), len(arr2)) + for i in xrange(len(arr1)): + assert_almost_equal(arr1[i], arr2[i], decimal) \ No newline at end of file From d98d23f8b5d61bb9d20e743a3c44bf04b6b2330a Mon Sep 17 00:00:00 2001 From: Hamilton Kibbe Date: Mon, 2 Feb 2015 00:43:08 -0500 Subject: [PATCH 022/186] More tests and bugfixes --- gerber/__main__.py | 10 +-- gerber/cam.py | 16 ++--- gerber/common.py | 3 +- gerber/primitives.py | 27 ++++---- gerber/render/render.py | 110 +++++++++++++++----------------- gerber/tests/test_cam.py | 30 ++++++++- gerber/tests/test_common.py | 11 +++- gerber/tests/test_primitives.py | 79 +++++++++++++++++++++-- gerber/tests/test_utils.py | 43 +++++++++---- 9 files changed, 227 insertions(+), 102 deletions(-) diff --git a/gerber/__main__.py b/gerber/__main__.py index 71e3bfc..8f20212 100644 --- a/gerber/__main__.py +++ b/gerber/__main__.py @@ -25,15 +25,15 @@ if __name__ == '__main__': sys.exit(1) ctx = GerberSvgContext() - ctx.set_alpha(0.95) + ctx.alpha = 0.95 for filename in sys.argv[1:]: print "parsing %s" % filename if 'GTO' in filename or 'GBO' in filename: - ctx.set_color((1, 1, 1)) - ctx.set_alpha(0.8) + ctx.color = (1, 1, 1) + ctx.alpha = 0.8 elif 'GTS' in filename or 'GBS' in filename: - ctx.set_color((0.2, 0.2, 0.75)) - ctx.set_alpha(0.8) + ctx.color = (0.2, 0.2, 0.75) + ctx.alpha = 0.8 gerberfile = read(filename) gerberfile.render(ctx) diff --git a/gerber/cam.py b/gerber/cam.py index a4057bc..9c731aa 100644 --- a/gerber/cam.py +++ b/gerber/cam.py @@ -62,29 +62,24 @@ class FileSettings(object): if units not in ['inch', 'metric']: raise ValueError('Units must be either inch or metric') self.units = units - - + if zero_suppression is None and zeros is None: self.zero_suppression = 'trailing' - + elif zero_suppression == zeros: raise ValueError('Zeros and Zero Suppression must be different. \ Best practice is to specify only one.') - + elif zero_suppression is not None: if zero_suppression not in ['leading', 'trailing']: raise ValueError('Zero suppression must be either leading or \ trailling') self.zero_suppression = zero_suppression - elif zeros is not None: if zeros not in ['leading', 'trailing']: raise ValueError('Zeros must be either leading or trailling') self.zeros = zeros - else: - self.zeros = 'leading' - if len(format) != 2: raise ValueError('Format must be a tuple(n=2) of integers') @@ -150,6 +145,9 @@ class FileSettings(object): raise ValueError('Format must be a tuple(n=2) of integers') self.format = value + else: + raise KeyError('%s is not a valid key' % key) + def __eq__(self, other): return (self.notation == other.notation and self.units == other.units and @@ -230,7 +228,7 @@ class CamFile(object): def bounds(self): """ File baundaries """ - pass + raise NotImplementedError('bounds must be implemented in a subclass') def render(self, ctx, filename=None): """ Generate image of layer. diff --git a/gerber/common.py b/gerber/common.py index 6e8c862..83e3cb0 100644 --- a/gerber/common.py +++ b/gerber/common.py @@ -39,4 +39,5 @@ def read(filename): elif fmt == 'excellon': return excellon.read(filename) else: - return None + raise TypeError('Unable to detect file format') + diff --git a/gerber/primitives.py b/gerber/primitives.py index 61df7c1..da05127 100644 --- a/gerber/primitives.py +++ b/gerber/primitives.py @@ -345,21 +345,26 @@ class Obround(Primitive): self.position = position self.width = width self.height = height + # Axis-aligned width and height + self._abs_width = (math.cos(math.radians(self.rotation)) * self.width + + math.sin(math.radians(self.rotation)) * self.height) + self._abs_height = (math.cos(math.radians(self.rotation)) * self.height + + math.sin(math.radians(self.rotation)) * self.width) + + @property + def lower_left(self): + return (self.position[0] - (self._abs_width / 2.), + self.position[1] - (self._abs_height / 2.)) + + @property + def upper_right(self): + return (self.position[0] + (self._abs_width / 2.), + self.position[1] + (self._abs_height / 2.)) @property def orientation(self): return 'vertical' if self.height > self.width else 'horizontal' - @property - def lower_left(self): - return (self.position[0] - (self.width / 2.), - self.position[1] - (self.height / 2.)) - - @property - def upper_right(self): - return (self.position[0] + (self.width / 2.), - self.position[1] + (self.height / 2.)) - @property def bounding_box(self): min_x = self.lower_left[0] @@ -380,7 +385,7 @@ class Obround(Primitive): 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., + 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) diff --git a/gerber/render/render.py b/gerber/render/render.py index f5c58d8..2e4abfa 100644 --- a/gerber/render/render.py +++ b/gerber/render/render.py @@ -41,7 +41,7 @@ class GerberContext(object): Attributes ---------- units : string - Measurement units + Measurement units. 'inch' or 'metric' color : tuple (, , ) Color used for rendering as a tuple of normalized (red, green, blue) values. @@ -57,74 +57,70 @@ class GerberContext(object): Rendering opacity. Between 0.0 (transparent) and 1.0 (opaque.) """ def __init__(self, units='inch'): - self.units = units - self.color = (0.7215, 0.451, 0.200) - self.drill_color = (0.25, 0.25, 0.25) - self.background_color = (0.0, 0.0, 0.0) - self.alpha = 1.0 + self._units = units + self._color = (0.7215, 0.451, 0.200) + self._drill_color = (0.25, 0.25, 0.25) + self._background_color = (0.0, 0.0, 0.0) + self._alpha = 1.0 - def set_units(self, units): - """ Set context measurement units + @property + def units(self): + return self._units - Parameters - ---------- - unit : string - Measurement units. may be 'inch' or 'metric' - - Raises - ------ - ValueError - If `unit` is not 'inch' or 'metric' - """ + @units.setter + def units(self, units): if units not in ('inch', 'metric'): raise ValueError('Units may be "inch" or "metric"') - self.units = units + self._units = units - def set_color(self, color): - """ Set rendering color. + @property + def color(self): + return self._color - Parameters - ---------- - color : tuple (, , ) - Color as a tuple of (red, green, blue) values. Each channel is - represented as a float value in (0, 1) - """ - self.color = color + @color.setter + def color(self, color): + if len(color) != 3: + raise TypeError('Color must be a tuple of R, G, and B values') + for c in color: + if c < 0 or c > 1: + raise ValueError('Channel values must be between 0.0 and 1.0') + self._color = color - def set_drill_color(self, color): - """ Set color used for rendering drill hits. + @property + def drill_color(self): + return self._drill_color - Parameters - ---------- - color : tuple (, , ) - Color as a tuple of (red, green, blue) values. Each channel is - represented as a float value in (0, 1) - """ - self.drill_color = color + @drill_color.setter + def drill_color(self, color): + if len(color) != 3: + raise TypeError('Drill color must be a tuple of R, G, and B values') + for c in color: + if c < 0 or c > 1: + raise ValueError('Channel values must be between 0.0 and 1.0') + self._drill_color = color - def set_background_color(self, color): - """ Set rendering background color + @property + def background_color(self): + return self._background_color - Parameters - ---------- - color : tuple (, , ) - Color as a tuple of (red, green, blue) values. Each channel is - represented as a float value in (0, 1) - """ - self.background_color = color + @background_color.setter + def background_color(self, color): + if len(color) != 3: + raise TypeError('Background color must be a tuple of R, G, and B values') + for c in color: + if c < 0 or c > 1: + raise ValueError('Channel values must be between 0.0 and 1.0') + self._background_color = color - def set_alpha(self, alpha): - """ Set layer rendering opacity + @property + def alpha(self): + return self._alpha - .. note:: - Not all backends/rendering devices support this parameter. - - Parameters - ---------- - alpha : float - Rendering opacity. must be between 0.0 (transparent) and 1.0 (opaque) - """ - self.alpha = alpha + @alpha.setter + def alpha(self, alpha): + if alpha < 0 or alpha > 1: + raise ValueError('Alpha must be between 0.0 and 1.0') + self._alpha = alpha def render(self, primitive): color = (self.color if primitive.level_polarity == 'dark' diff --git a/gerber/tests/test_cam.py b/gerber/tests/test_cam.py index 1aeb18c..8e0270c 100644 --- a/gerber/tests/test_cam.py +++ b/gerber/tests/test_cam.py @@ -65,8 +65,14 @@ def test_camfile_settings(): cf = CamFile() assert_equal(cf.settings, FileSettings()) -def test_zeros(): +#def test_bounds_override(): +# cf = CamFile() +# assert_raises(NotImplementedError, cf.bounds) + +def test_zeros(): + """ Test zero/zero_suppression interaction + """ fs = FileSettings() assert_equal(fs.zero_suppression, 'trailing') assert_equal(fs.zeros, 'leading') @@ -89,3 +95,25 @@ def test_zeros(): assert_equal(fs.zeros, 'leading') assert_equal(fs.zero_suppression, 'trailing') + +def test_filesettings_validation(): + """ Test FileSettings constructor argument validation + """ + assert_raises(ValueError, FileSettings, 'absolute-ish', 'inch', None, (2, 5), None) + assert_raises(ValueError, FileSettings, 'absolute', 'degrees kelvin', None, (2, 5), None) + assert_raises(ValueError, FileSettings, 'absolute', 'inch', 'leading', (2, 5), 'leading') + assert_raises(ValueError, FileSettings, 'absolute', 'inch', 'following', (2, 5), None) + assert_raises(ValueError, FileSettings, 'absolute', 'inch', None, (2, 5), 'following') + assert_raises(ValueError, FileSettings, 'absolute', 'inch', None, (2, 5, 6), None) + +def test_key_validation(): + fs = FileSettings() + assert_raises(KeyError, fs.__getitem__, 'octopus') + assert_raises(KeyError, fs.__setitem__, 'octopus', 'do not care') + assert_raises(ValueError, fs.__setitem__, 'notation', 'absolute-ish') + assert_raises(ValueError, fs.__setitem__, 'units', 'degrees kelvin') + assert_raises(ValueError, fs.__setitem__, 'zero_suppression', 'following') + assert_raises(ValueError, fs.__setitem__, 'zeros', 'following') + assert_raises(ValueError, fs.__setitem__, 'format', (2, 5, 6)) + + diff --git a/gerber/tests/test_common.py b/gerber/tests/test_common.py index 1e1efe5..bf9760a 100644 --- a/gerber/tests/test_common.py +++ b/gerber/tests/test_common.py @@ -20,5 +20,12 @@ def test_file_type_detection(): """ ncdrill = read(NCDRILL_FILE) top_copper = read(TOP_COPPER_FILE) - assert(isinstance(ncdrill, ExcellonFile)) - assert(isinstance(top_copper, GerberFile)) + assert_true(isinstance(ncdrill, ExcellonFile)) + assert_true(isinstance(top_copper, GerberFile)) + +def test_file_type_validation(): + """ Test file format validation + """ + assert_raises(TypeError, read, 'LICENSE') + + diff --git a/gerber/tests/test_primitives.py b/gerber/tests/test_primitives.py index 4534484..e5ae770 100644 --- a/gerber/tests/test_primitives.py +++ b/gerber/tests/test_primitives.py @@ -165,6 +165,7 @@ def test_chamfer_rectangle_ctor(): assert_equal(r.chamfer, chamfer) assert_array_almost_equal(r.corners, corners) + def test_chamfer_rectangle_bounds(): """ Test chamfer rectangle bounding box calculation """ @@ -191,7 +192,8 @@ def test_round_rectangle_ctor(): assert_equal(r.height, height) assert_equal(r.radius, radius) assert_array_almost_equal(r.corners, corners) - + + def test_round_rectangle_bounds(): """ Test round rectangle bounding box calculation """ @@ -203,7 +205,76 @@ def test_round_rectangle_bounds(): xbounds, ybounds = r.bounding_box assert_array_almost_equal(xbounds, (-math.sqrt(2), math.sqrt(2))) assert_array_almost_equal(ybounds, (-math.sqrt(2), math.sqrt(2))) + + +def test_obround_ctor(): + """ Test obround creation + """ + test_cases = (((0,0), 1, 1), + ((0, 0), 1, 2), + ((1,1), 1, 2)) + for pos, width, height in test_cases: + o = Obround(pos, width, height) + assert_equal(o.position, pos) + assert_equal(o.width, width) + assert_equal(o.height, height) + + +def test_obround_bounds(): + """ Test obround bounding box calculation + """ + o = Obround((2,2),2,4) + xbounds, ybounds = o.bounding_box + assert_array_almost_equal(xbounds, (1, 3)) + assert_array_almost_equal(ybounds, (0, 4)) + o = Obround((2,2),4,2) + xbounds, ybounds = o.bounding_box + assert_array_almost_equal(xbounds, (0, 4)) + assert_array_almost_equal(ybounds, (1, 3)) + + +def test_obround_orientation(): + o = Obround((0, 0), 2, 1) + assert_equal(o.orientation, 'horizontal') + o = Obround((0, 0), 1, 2) + assert_equal(o.orientation, 'vertical') + + +def test_obround_subshapes(): + o = Obround((0,0), 1, 4) + ss = o.subshapes + assert_array_almost_equal(ss['rectangle'].position, (0, 0)) + assert_array_almost_equal(ss['circle1'].position, (0, 1.5)) + assert_array_almost_equal(ss['circle2'].position, (0, -1.5)) + o = Obround((0,0), 4, 1) + ss = o.subshapes + assert_array_almost_equal(ss['rectangle'].position, (0, 0)) + assert_array_almost_equal(ss['circle1'].position, (1.5, 0)) + assert_array_almost_equal(ss['circle2'].position, (-1.5, 0)) - - - \ No newline at end of file +def test_polygon_ctor(): + """ Test polygon creation + """ + test_cases = (((0,0), 3, 5), + ((0, 0), 5, 6), + ((1,1), 7, 7)) + for pos, sides, radius in test_cases: + p = Polygon(pos, sides, radius) + assert_equal(p.position, pos) + assert_equal(p.sides, sides) + assert_equal(p.radius, radius) + +def test_polygon_bounds(): + """ Test polygon bounding box calculation + """ + p = Polygon((2,2), 3, 2) + xbounds, ybounds = p.bounding_box + assert_array_almost_equal(xbounds, (0, 4)) + assert_array_almost_equal(ybounds, (0, 4)) + p = Polygon((2,2),3, 4) + xbounds, ybounds = p.bounding_box + assert_array_almost_equal(xbounds, (-2, 6)) + assert_array_almost_equal(ybounds, (-2, 6)) + + + diff --git a/gerber/tests/test_utils.py b/gerber/tests/test_utils.py index 706fa65..1077022 100644 --- a/gerber/tests/test_utils.py +++ b/gerber/tests/test_utils.py @@ -3,8 +3,8 @@ # Author: Hamilton Kibbe -from .tests import assert_equal -from ..utils import decimal_string, parse_gerber_value, write_gerber_value +from .tests import assert_equal, assert_raises +from ..utils import decimal_string, parse_gerber_value, write_gerber_value, detect_file_format def test_zero_suppression(): @@ -22,8 +22,8 @@ def test_zero_suppression(): ('-100000', -1.0), ('-1000000', -10.0), ('0', 0.0)] for string, value in test_cases: - assert(value == parse_gerber_value(string, fmt, zero_suppression)) - assert(string == write_gerber_value(value, fmt, zero_suppression)) + assert_equal(value, parse_gerber_value(string, fmt, zero_suppression)) + assert_equal(string, write_gerber_value(value, fmt, zero_suppression)) # Test trailing zero suppression zero_suppression = 'trailing' @@ -34,8 +34,8 @@ def test_zero_suppression(): ('-000001', -0.0001), ('-0000001', -0.00001), ('0', 0.0)] for string, value in test_cases: - assert(value == parse_gerber_value(string, fmt, zero_suppression)) - assert(string == write_gerber_value(value, fmt, zero_suppression)) + assert_equal(value, parse_gerber_value(string, fmt, zero_suppression)) + assert_equal(string, write_gerber_value(value, fmt, zero_suppression)) def test_format(): @@ -51,8 +51,8 @@ def test_format(): ((2, 2), '-1', -0.01), ((2, 1), '-1', -0.1), ((2, 6), '0', 0) ] for fmt, string, value in test_cases: - assert(value == parse_gerber_value(string, fmt, zero_suppression)) - assert(string == write_gerber_value(value, fmt, zero_suppression)) + assert_equal(value, parse_gerber_value(string, fmt, zero_suppression)) + assert_equal(string, write_gerber_value(value, fmt, zero_suppression)) zero_suppression = 'trailing' test_cases = [((6, 5), '1', 100000.0), ((5, 5), '1', 10000.0), @@ -63,8 +63,8 @@ def test_format(): ((2, 5), '-1', -10.0), ((1, 5), '-1', -1.0), ((2, 5), '0', 0)] for fmt, string, value in test_cases: - assert(value == parse_gerber_value(string, fmt, zero_suppression)) - assert(string == write_gerber_value(value, fmt, zero_suppression)) + assert_equal(value, parse_gerber_value(string, fmt, zero_suppression)) + assert_equal(string, write_gerber_value(value, fmt, zero_suppression)) def test_decimal_truncation(): @@ -74,7 +74,7 @@ def test_decimal_truncation(): for x in range(10): result = decimal_string(value, precision=x) calculated = '1.' + ''.join(str(y) for y in range(1,x+1)) - assert(result == calculated) + assert_equal(result, calculated) def test_decimal_padding(): @@ -85,6 +85,25 @@ def test_decimal_padding(): assert_equal(decimal_string(value, precision=4, padding=True), '1.1230') assert_equal(decimal_string(value, precision=5, padding=True), '1.12300') assert_equal(decimal_string(value, precision=6, padding=True), '1.123000') - assert_equal(decimal_string(0, precision=6, padding=True), '0.000000') + +def test_parse_format_validation(): + """ Test parse_gerber_value() format validation + """ + assert_raises(ValueError, parse_gerber_value, '00001111', (7, 5)) + assert_raises(ValueError, parse_gerber_value, '00001111', (5, 8)) + assert_raises(ValueError, parse_gerber_value, '00001111', (13,1)) + +def test_write_format_validation(): + """ Test write_gerber_value() format validation + """ + assert_raises(ValueError, write_gerber_value, 69.0, (7, 5)) + assert_raises(ValueError, write_gerber_value, 69.0, (5, 8)) + assert_raises(ValueError, write_gerber_value, 69.0, (13,1)) + + +def test_detect_format_with_short_file(): + """ Verify file format detection works with short files + """ + assert_equal('unknown', detect_file_format('gerber/tests/__init__.py')) From 1cc20b351c10b1fa19817f29edd8c54a27aeee4b Mon Sep 17 00:00:00 2001 From: Hamilton Kibbe Date: Mon, 2 Feb 2015 11:42:47 -0500 Subject: [PATCH 023/186] tests --- gerber/gerber_statements.py | 18 ++-- gerber/primitives.py | 21 ++++- gerber/tests/test_cam.py | 18 +++- gerber/tests/test_gerber_statements.py | 86 +++++++++++------- gerber/tests/test_primitives.py | 119 +++++++++++++++++++++++++ gerber/tests/test_utils.py | 10 ++- gerber/utils.py | 10 +++ 7 files changed, 238 insertions(+), 44 deletions(-) diff --git a/gerber/gerber_statements.py b/gerber/gerber_statements.py index d84b5e0..0978aca 100644 --- a/gerber/gerber_statements.py +++ b/gerber/gerber_statements.py @@ -145,12 +145,14 @@ class MOParamStmt(ParamStmt): @classmethod def from_dict(cls, stmt_dict): param = stmt_dict.get('param') - if stmt_dict.get('mo').lower() == 'in': - mo = 'inch' - elif stmt_dict.get('mo').lower() == 'mm': - mo = 'metric' - else: + if stmt_dict.get('mo') is None: mo = None + elif stmt_dict.get('mo').lower() not in ('in', 'mm'): + raise ValueError('Mode may be mm or in') + elif stmt_dict.get('mo').lower() == 'in': + mo = 'inch' + else: + mo = 'metric' return cls(param, mo) def __init__(self, param, mo): @@ -347,7 +349,7 @@ class AMOutlinePrimitive(AMPrimitive): return "{code},{exposure},{n_points},{start_point},{points},{rotation}".format(**data) -class AMUnsupportPrimitive: +class AMUnsupportPrimitive(object): @classmethod def from_gerber(cls, primitive): return cls(primitive) @@ -652,9 +654,9 @@ class OFParamStmt(ParamStmt): def __str__(self): offset_str = '' if self.a is not None: - offset_str += ('X: %f' % self.a) + offset_str += ('X: %f ' % self.a) if self.b is not None: - offset_str += ('Y: %f' % self.b) + offset_str += ('Y: %f ' % self.b) return ('' % offset_str) diff --git a/gerber/primitives.py b/gerber/primitives.py index da05127..2d666b8 100644 --- a/gerber/primitives.py +++ b/gerber/primitives.py @@ -16,6 +16,7 @@ # limitations under the License. import math from operator import sub +from .utils import validate_coordinates class Primitive(object): @@ -45,7 +46,7 @@ class Primitive(object): Return ((min x, max x), (min y, max y)) """ - pass + raise NotImplementedError('Bounding box calculation must be implemented in subclass') class Line(Primitive): @@ -155,6 +156,7 @@ class Circle(Primitive): """ def __init__(self, position, diameter, **kwargs): super(Circle, self).__init__(**kwargs) + validate_coordinates(position) self.position = position self.diameter = diameter @@ -180,6 +182,7 @@ class Ellipse(Primitive): """ def __init__(self, position, width, height, **kwargs): super(Ellipse, self).__init__(**kwargs) + validate_coordinates(position) self.position = position self.width = width self.height = height @@ -205,6 +208,7 @@ class Rectangle(Primitive): """ def __init__(self, position, width, height, **kwargs): super(Rectangle, self).__init__(**kwargs) + validate_coordinates(position) self.position = position self.width = width self.height = height @@ -239,6 +243,7 @@ class Diamond(Primitive): """ def __init__(self, position, width, height, **kwargs): super(Diamond, self).__init__(**kwargs) + validate_coordinates(position) self.position = position self.width = width self.height = height @@ -272,6 +277,7 @@ class ChamferRectangle(Primitive): """ def __init__(self, position, width, height, chamfer, corners, **kwargs): super(ChamferRectangle, self).__init__(**kwargs) + validate_coordinates(position) self.position = position self.width = width self.height = height @@ -307,6 +313,7 @@ class RoundRectangle(Primitive): """ def __init__(self, position, width, height, radius, corners, **kwargs): super(RoundRectangle, self).__init__(**kwargs) + validate_coordinates(position) self.position = position self.width = width self.height = height @@ -342,6 +349,7 @@ class Obround(Primitive): """ def __init__(self, position, width, height, **kwargs): super(Obround, self).__init__(**kwargs) + validate_coordinates(position) self.position = position self.width = width self.height = height @@ -397,6 +405,7 @@ class Polygon(Primitive): """ def __init__(self, position, sides, radius, **kwargs): super(Polygon, self).__init__(**kwargs) + validate_coordinates(position) self.position = position self.sides = sides self.radius = radius @@ -432,6 +441,7 @@ class RoundButterfly(Primitive): """ def __init__(self, position, diameter, **kwargs): super(RoundButterfly, self).__init__(**kwargs) + validate_coordinates(position) self.position = position self.diameter = diameter @@ -452,6 +462,7 @@ class SquareButterfly(Primitive): """ def __init__(self, position, side, **kwargs): super(SquareButterfly, self).__init__(**kwargs) + validate_coordinates(position) self.position = position self.side = side @@ -470,8 +481,14 @@ 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') self.position = position + if shape not in ('round', 'square', 'hexagon', 'octagon'): + raise ValueError('Valid shapes are round, square, hexagon or octagon') self.shape = shape + 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 if self.shape in ('round', 'square', 'octagon'): @@ -507,6 +524,8 @@ 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') self.position = position self.diameter = diameter diff --git a/gerber/tests/test_cam.py b/gerber/tests/test_cam.py index 8e0270c..185e716 100644 --- a/gerber/tests/test_cam.py +++ b/gerber/tests/test_cam.py @@ -77,7 +77,6 @@ def test_zeros(): assert_equal(fs.zero_suppression, 'trailing') assert_equal(fs.zeros, 'leading') - fs['zero_suppression'] = 'leading' assert_equal(fs.zero_suppression, 'leading') assert_equal(fs.zeros, 'trailing') @@ -90,11 +89,26 @@ def test_zeros(): assert_equal(fs.zeros, 'trailing') assert_equal(fs.zero_suppression, 'leading') - fs.zeros= 'leading' assert_equal(fs.zeros, 'leading') assert_equal(fs.zero_suppression, 'trailing') + fs = FileSettings(zeros='leading') + assert_equal(fs.zeros, 'leading') + assert_equal(fs.zero_suppression, 'trailing') + + fs = FileSettings(zero_suppression='leading') + assert_equal(fs.zeros, 'trailing') + assert_equal(fs.zero_suppression, 'leading') + + fs = FileSettings(zeros='leading', zero_suppression='trailing') + assert_equal(fs.zeros, 'leading') + assert_equal(fs.zero_suppression, 'trailing') + + fs = FileSettings(zeros='trailing', zero_suppression='leading') + assert_equal(fs.zeros, 'trailing') + assert_equal(fs.zero_suppression, 'leading') + def test_filesettings_validation(): """ Test FileSettings constructor argument validation diff --git a/gerber/tests/test_gerber_statements.py b/gerber/tests/test_gerber_statements.py index ff967f9..e797d5a 100644 --- a/gerber/tests/test_gerber_statements.py +++ b/gerber/tests/test_gerber_statements.py @@ -82,6 +82,12 @@ def test_MOParamStmt_factory(): assert_equal(mo.param, 'MO') assert_equal(mo.mode, 'metric') + stmt = {'param': 'MO'} + mo = MOParamStmt.from_dict(stmt) + assert_equal(mo.mode, None) + stmt = {'param': 'MO', 'mo': 'degrees kelvin'} + assert_raises(ValueError, MOParamStmt.from_dict, stmt) + def test_MOParamStmt(): """ Test MOParamStmt initialization @@ -182,6 +188,13 @@ def test_OFParamStmt_dump(): assert_equal(of.to_gerber(), '%OFA0.12345B0.12345*%') +def test_OFParamStmt_string(): + """ Test OFParamStmt __str__ + """ + stmt = {'param': 'OF', 'a': '0.123456', 'b': '0.123456'} + of = OFParamStmt.from_dict(stmt) + assert_equal(str(of), '') + def test_LPParamStmt_factory(): """ Test LPParamStmt factory """ @@ -377,38 +390,47 @@ def test_ADParamStmt_factory(): assert_equal(ad.d, 1) assert_equal(ad.shape, 'R') - stmt = {'param': 'AD', 'd': 2, 'shape': 'O'} - ad = ADParamStmt.from_dict(stmt) - assert_equal(ad.d, 2) - assert_equal(ad.shape, 'O') +def test_MIParamStmt_factory(): + stmt = {'param': 'MI', 'a': 1, 'b': 1} + mi = MIParamStmt.from_dict(stmt) + assert_equal(mi.a, 1) + assert_equal(mi.b, 1) + +def test_MIParamStmt_dump(): + stmt = {'param': 'MI', 'a': 1, 'b': 1} + mi = MIParamStmt.from_dict(stmt) + assert_equal(mi.to_gerber(), '%MIA1B1*%') + stmt = {'param': 'MI', 'a': 1} + mi = MIParamStmt.from_dict(stmt) + assert_equal(mi.to_gerber(), '%MIA1B0*%') + stmt = {'param': 'MI', 'b': 1} + mi = MIParamStmt.from_dict(stmt) + assert_equal(mi.to_gerber(), '%MIA0B1*%') + +def test_MIParamStmt_string(): + stmt = {'param': 'MI', 'a': 1, 'b': 1} + mi = MIParamStmt.from_dict(stmt) + assert_equal(str(mi), '') + + stmt = {'param': 'MI', 'b': 1} + mi = MIParamStmt.from_dict(stmt) + assert_equal(str(mi), '') + + stmt = {'param': 'MI', 'a': 1} + mi = MIParamStmt.from_dict(stmt) + assert_equal(str(mi), '') -def test_ADParamStmt_dump(): - """ Test ADParamStmt.to_gerber() - """ - stmt = {'param': 'AD', 'd': 0, 'shape': 'C'} - ad = ADParamStmt.from_dict(stmt) - assert_equal(ad.to_gerber(),'%ADD0C*%') - stmt = {'param': 'AD', 'd': 1, 'shape': 'R'} - ad = ADParamStmt.from_dict(stmt) - assert_equal(ad.to_gerber(),'%ADD1R*%') - - stmt = {'param': 'AD', 'd': 2, 'shape': 'O'} - ad = ADParamStmt.from_dict(stmt) - assert_equal(ad.to_gerber(),'%ADD2O*%') - -def test_ADParamStmt_string(): - """ Test ADParamStmt.__str__() - """ - stmt = {'param': 'AD', 'd': 0, 'shape': 'C'} - ad = ADParamStmt.from_dict(stmt) - assert_equal(str(ad), '') - - stmt = {'param': 'AD', 'd': 1, 'shape': 'R'} - ad = ADParamStmt.from_dict(stmt) - assert_equal(str(ad), '') - - stmt = {'param': 'AD', 'd': 2, 'shape': 'O'} - ad = ADParamStmt.from_dict(stmt) - assert_equal(str(ad), '') +def test_coordstmt_ctor(): + cs = CoordStmt('G04', 0.0, 0.1, 0.2, 0.3, 'D01', FileSettings()) + assert_equal(cs.function, 'G04') + assert_equal(cs.x, 0.0) + assert_equal(cs.y, 0.1) + assert_equal(cs.i, 0.2) + assert_equal(cs.j, 0.3) + assert_equal(cs.op, 'D01') + + + + \ No newline at end of file diff --git a/gerber/tests/test_primitives.py b/gerber/tests/test_primitives.py index e5ae770..14a3d39 100644 --- a/gerber/tests/test_primitives.py +++ b/gerber/tests/test_primitives.py @@ -6,6 +6,11 @@ from ..primitives import * from tests import * +def test_primitive_implementation_warning(): + p = Primitive() + assert_raises(NotImplementedError, p.bounding_box) + + def test_line_angle(): """ Test Line primitive angle calculation """ @@ -277,4 +282,118 @@ def test_polygon_bounds(): assert_array_almost_equal(ybounds, (-2, 6)) +def test_region_ctor(): + """ Test Region creation + """ + points = ((0, 0), (1,0), (1,1), (0,1)) + r = Region(points) + for i, point in enumerate(points): + assert_array_almost_equal(r.points[i], point) + +def test_region_bounds(): + """ Test region bounding box calculation + """ + points = ((0, 0), (1,0), (1,1), (0,1)) + r = Region(points) + xbounds, ybounds = r.bounding_box + assert_array_almost_equal(xbounds, (0, 1)) + assert_array_almost_equal(ybounds, (0, 1)) + + +def test_round_butterfly_ctor(): + """ Test round butterfly creation + """ + test_cases = (((0,0), 3), ((0, 0), 5), ((1,1), 7)) + for pos, diameter in test_cases: + b = RoundButterfly(pos, diameter) + assert_equal(b.position, pos) + assert_equal(b.diameter, diameter) + assert_equal(b.radius, diameter/2.) + +def test_round_butterfly_ctor_validation(): + """ Test RoundButterfly argument validation + """ + assert_raises(TypeError, RoundButterfly, 3, 5) + assert_raises(TypeError, RoundButterfly, (3,4,5), 5) + +def test_round_butterfly_bounds(): + """ Test RoundButterfly bounding box calculation + """ + b = RoundButterfly((0, 0), 2) + xbounds, ybounds = b.bounding_box + assert_array_almost_equal(xbounds, (-1, 1)) + assert_array_almost_equal(ybounds, (-1, 1)) + +def test_square_butterfly_ctor(): + """ Test SquareButterfly creation + """ + test_cases = (((0,0), 3), ((0, 0), 5), ((1,1), 7)) + for pos, side in test_cases: + b = SquareButterfly(pos, side) + assert_equal(b.position, pos) + assert_equal(b.side, side) + +def test_square_butterfly_ctor_validation(): + """ Test SquareButterfly argument validation + """ + assert_raises(TypeError, SquareButterfly, 3, 5) + assert_raises(TypeError, SquareButterfly, (3,4,5), 5) + + +def test_square_butterfly_bounds(): + """ Test SquareButterfly bounding box calculation + """ + b = SquareButterfly((0, 0), 2) + xbounds, ybounds = b.bounding_box + assert_array_almost_equal(xbounds, (-1, 1)) + assert_array_almost_equal(ybounds, (-1, 1)) + +def test_donut_ctor(): + """ Test Donut primitive creation + """ + test_cases = (((0,0), 'round', 3, 5), ((0, 0), 'square', 5, 7), + ((1,1), 'hexagon', 7, 9), ((2, 2), 'octagon', 9, 11)) + for pos, shape, in_d, out_d in test_cases: + d = Donut(pos, shape, in_d, out_d) + assert_equal(d.position, pos) + assert_equal(d.shape, shape) + assert_equal(d.inner_diameter, in_d) + assert_equal(d.outer_diameter, out_d) + +def test_donut_ctor_validation(): + assert_raises(TypeError, Donut, 3, 'round', 5, 7) + assert_raises(TypeError, Donut, (3, 4, 5), 'round', 5, 7) + assert_raises(ValueError, Donut, (0, 0), 'triangle', 3, 5) + assert_raises(ValueError, Donut, (0, 0), 'round', 5, 3) + +def test_donut_bounds(): + pass + +def test_drill_ctor(): + """ Test drill primitive creation + """ + test_cases = (((0, 0), 2), ((1, 1), 3), ((2, 2), 5)) + for position, diameter in test_cases: + d = Drill(position, diameter) + assert_equal(d.position, position) + assert_equal(d.diameter, diameter) + assert_equal(d.radius, diameter/2.) + +def test_drill_ctor_validation(): + """ Test drill argument validation + """ + assert_raises(TypeError, Drill, 3, 5) + assert_raises(TypeError, Drill, (3,4,5), 5) + +def test_drill_bounds(): + d = Drill((0, 0), 2) + xbounds, ybounds = d.bounding_box + assert_array_almost_equal(xbounds, (-1, 1)) + assert_array_almost_equal(ybounds, (-1, 1)) + d = Drill((1, 2), 2) + xbounds, ybounds = d.bounding_box + assert_array_almost_equal(xbounds, (0, 2)) + assert_array_almost_equal(ybounds, (1, 3)) + + \ No newline at end of file diff --git a/gerber/tests/test_utils.py b/gerber/tests/test_utils.py index 1077022..1c3f1e5 100644 --- a/gerber/tests/test_utils.py +++ b/gerber/tests/test_utils.py @@ -4,7 +4,7 @@ # Author: Hamilton Kibbe from .tests import assert_equal, assert_raises -from ..utils import decimal_string, parse_gerber_value, write_gerber_value, detect_file_format +from ..utils import * def test_zero_suppression(): @@ -107,3 +107,11 @@ def test_detect_format_with_short_file(): """ Verify file format detection works with short files """ assert_equal('unknown', detect_file_format('gerber/tests/__init__.py')) + +def test_validate_coordinates(): + assert_raises(TypeError, validate_coordinates, 3) + assert_raises(TypeError, validate_coordinates, 3.1) + assert_raises(TypeError, validate_coordinates, '14') + assert_raises(TypeError, validate_coordinates, (0,)) + assert_raises(TypeError, validate_coordinates, (0,1,2)) + assert_raises(TypeError, validate_coordinates, (0,'string')) diff --git a/gerber/utils.py b/gerber/utils.py index 64cd6ed..86119ba 100644 --- a/gerber/utils.py +++ b/gerber/utils.py @@ -220,3 +220,13 @@ def detect_file_format(filename): elif '%FS' in line: return'rs274x' return 'unknown' + + +def validate_coordinates(position): + if position is not None: + if len(position) != 2: + raise TypeError('Position must be a tuple (n=2) of coordinates') + else: + for coord in position: + if not (isinstance(coord, int) or isinstance(coord, float)): + raise TypeError('Coordinates must be integers or floats') From f98b918634f23cf822b0d054ac4b6a0b790bb760 Mon Sep 17 00:00:00 2001 From: Hamilton Kibbe Date: Mon, 2 Feb 2015 20:03:26 -0500 Subject: [PATCH 024/186] Added some Aperture Macro Primitives. Moved AM primitives to seperate file --- gerber/am_statements.py | 341 +++++++++++++++++++++++++++++ gerber/gerber_statements.py | 83 +------ gerber/tests/test_am_statements.py | 77 +++++++ 3 files changed, 426 insertions(+), 75 deletions(-) create mode 100644 gerber/am_statements.py create mode 100644 gerber/tests/test_am_statements.py diff --git a/gerber/am_statements.py b/gerber/am_statements.py new file mode 100644 index 0000000..3f6ff1e --- /dev/null +++ b/gerber/am_statements.py @@ -0,0 +1,341 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# copyright 2015 Hamilton Kibbe and Paulo Henrique Silva +# + +# 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. + +from .utils import validate_coordinates + + +# TODO: Add support for aperture macro variables + +class AMPrimitive(object): + """ Aperture Macro Primitive Base Class + """ + def __init__(self, code, exposure=None): + """ Initialize Aperture Macro Primitive base class + + Parameters + ---------- + code : int + primitive shape code + + exposure : str + on or off Primitives with exposure on create a slid part of + the macro aperture, and primitives with exposure off erase the + solid part created previously in the aperture macro definition. + .. note:: + The erasing effect is limited to the aperture definition in + which it occurs. + + Returns + ------- + primitive : :class: `gerber.am_statements.AMPrimitive` + + Raises + ------ + TypeError, ValueError + """ + VALID_CODES = (0, 1, 2, 4, 5, 6, 7, 20, 21, 22) + if not isinstance(code, int): + raise TypeError('Aperture Macro Primitive code must be an integer') + elif code not in VALID_CODES: + raise ValueError('Invalid Code. Valid codes are %s.' % ', '.join(map(str, VALID_CODES))) + if exposure is not None and exposure.lower() not in ('on', 'off'): + raise ValueError('Exposure must be either on or off') + self.code = code + self.exposure = exposure.lower() if exposure is not None else None + + def to_inch(self): + pass + + def to_metric(self): + pass + + +class AMCommentPrimitive(AMPrimitive): + """ Aperture Macro Comment primitive. Code 0 + """ + @classmethod + def from_gerber(cls, primitive): + primitive = primitive.strip() + code = int(primitive[0]) + comment = primitive[1:] + return cls(code, comment) + + def __init__(self, code, comment): + """ Initialize AMCommentPrimitive class + + Parameters + ---------- + code : int + Aperture Macro primitive code. 0 Indicates an AMCommentPrimitive + + comment : str + The comment as a string. + + Returns + ------- + CommentPrimitive : :class:`gerber.am_statements.AMCommentPrimitive` + An Initialized AMCommentPrimitive + + Raises + ------ + ValueError + """ + if code != 0: + raise ValueError('Not a valid Aperture Macro Comment statement') + super(AMCommentPrimitive, self).__init__(code) + self.comment = comment.strip(' *') + + def to_gerber(self, settings=None): + return '0 %s *' % self.comment + + def __str__(self): + return '' % self.comment + + +class AMCirclePrimitive(AMPrimitive): + """ Aperture macro Circle primitive. Code 1 + """ + @classmethod + def from_gerber(cls, primitive): + modifiers = primitive.strip(' *').split(',') + code = int(modifiers[0]) + exposure = 'on' if modifiers[1].strip() == '1' else 'off' + diameter = float(modifiers[2]) + position = (float(modifiers[3]), float(modifiers[4])) + return cls(code, exposure, diameter, position) + + def __init__(self, code, exposure, diameter, position): + """ Initialize AMCirclePrimitive + + Parameters + ---------- + code : int + Circle Primitive code. Must be 1 + + exposure : string + 'on' or 'off' + + diameter : float + Circle diameter + + position : tuple (, ) + Position of the circle relative to the macro origin + + Returns + ------- + CirclePrimitive : :class:`gerber.am_statements.AMCirclePrimitive` + An initialized AMCirclePrimitive + + Raises + ------ + ValueError, TypeError + """ + validate_coordinates(position) + if code != 1: + raise ValueError('Not a valid Aperture Macro Circle statement') + super(AMCirclePrimitive, self).__init__(code, exposure) + self.diameter = diameter + self.position = position + + def to_gerber(self, settings=None): + data = dict(code = self.code, + exposure = '1' if self.exposure == 'on' else 0, + diameter = self.diameter, + x = self.position[0], + y = self.position[1]) + return '{code},{exposure},{diameter},{x},{y}*'.format(**data) + + +class AMVectorLinePrimitive(AMPrimitive): + """ Aperture Macro Vector Line primitive. Code 2 or 20 + """ + @classmethod + def from_gerber(cls, primitive): + modifiers = primitive.strip(' *').split(',') + code = int(modifiers[0]) + exposure = 'on' if modifiers[1].strip() == '1' else 'off' + width = float(modifiers[2]) + start (float(modifiers[3]), float(modifiers[4])) + end = (float(modifiers[5]), float(modifiers[6])) + rotation = float(modifiers[7]) + return cls(code, exposure, width, start, end, rotation) + + def __init__(self, code, exposure, width, start, end, rotation): + """ Initialize AMVectorLinePrimitive + + Parameters + ---------- + code : int + Vector Line Primitive code. Must be either 2 or 20. + + exposure : string + 'on' or 'off' + + width : float + Line width + + start : tuple (, ) + coordinate of line start point + + end : tuple (, ) + coordinate of line end point + + rotation : float + Line rotation about the origin. + + Returns + ------- + LinePrimitive : :class:`gerber.am_statements.AMVectorLinePrimitive` + An initialized AMVectorLinePrimitive + + Raises + ------ + ValueError, TypeError + """ + validate_coordinates(start) + validate_coordinates(end) + if code not in (2, 20): + raise ValueError('Valid VectorLinePrimitive codes are 2 or 20') + super(AMVectorLinePrimitive, self).__init__(code, exposure) + self.width = width + self.start = start + self.end = end + self.rotation = rotation + + def to_gerber(self, settings=None): + fmtstr = '{code},{exp},{width},{startx},{starty},{endx},{endy},{rot}*' + data = dict(code = self.code, + exp = 1 if self.exposure == 'on' else 0, + startx = self.start[0], + starty = self.start[1], + endx = self.end[0], + endy = self.end[1], + rotation = self.rotation) + return fmtstr.format(**data) + +# Code 4 +class AMOutlinePrimitive(AMPrimitive): + + @classmethod + def from_gerber(cls, primitive): + modifiers = primitive.strip(' *').split(",") + + code = int(modifiers[0]) + exposure = "on" if modifiers[1].strip() == "1" else "off" + n = int(modifiers[2]) + start_point = (float(modifiers[3]), float(modifiers[4])) + points = [] + + for i in range(n): + points.append((float(modifiers[5 + i*2]), float(modifiers[5 + i*2 + 1]))) + + rotation = float(modifiers[-1]) + + return cls(code, exposure, start_point, points, rotation) + + def __init__(self, code, exposure, start_point, points, rotation): + """ Initialize AMOutlinePrimitive + + Parameters + ---------- + code : int + OutlinePrimitive code. Must be 4. + + exposure : string + 'on' or 'off' + + start_point : tuple (, ) + coordinate of outline start point + + points : list of tuples (, ) + coordinates of subsequent points + + rotation : float + outline rotation about the origin. + + Returns + ------- + OutlinePrimitive : :class:`gerber.am_statements.AMOutlineinePrimitive` + An initialized AMOutlinePrimitive + + Raises + ------ + ValueError, TypeError + """ + validate_coordinates(start_point) + for point in points: + validate_coordinates(point) + super(AMOutlinePrimitive, self).__init__(code, exposure) + self.start_point = start_point + self.points = points + self.rotation = rotation + + def to_inch(self): + self.start_point = tuple([x / 25.4 for x in self.start_point]) + self.points = tuple([(x / 25.4, y / 25.4) for x, y in self.points]) + + def to_metric(self): + self.start_point = tuple([x * 25.4 for x in self.start_point]) + self.points = tuple([(x * 25.4, y * 25.4) for x, y in self.points]) + + def to_gerber(self, settings=None): + data = dict( + code=self.code, + exposure="1" if self.exposure == "on" else "0", + n_points=len(self.points), + start_point="%.4f,%.4f" % self.start_point, + points=",".join(["%.4f,%.4f" % point for point in self.points]), + rotation=str(self.rotation) + ) + return "{code},{exposure},{n_points},{start_point},{points},{rotation}*".format(**data) + +# Code 5 +class AMPolygonPrimitive(AMPrimitive): + pass + + +# Code 6 +class AMMoirePrimitive(AMPrimitive): + pass + + +# Code 7 +class AMThermalPrimitive(AMPrimitive): + pass + + +# Code 21 +class AMCenterLinePrimitive(AMPrimitive): + pass + + +# Code 22 +class AMLowerLeftLinePrimitive(AMPrimitive): + pass + + +class AMUnsupportPrimitive(AMPrimitive): + @classmethod + def from_gerber(cls, primitive): + return cls(primitive) + + def __init__(self, primitive): + self.primitive = primitive + + def to_gerber(self, settings=None): + return self.primitive diff --git a/gerber/gerber_statements.py b/gerber/gerber_statements.py index 0978aca..7b1b56d 100644 --- a/gerber/gerber_statements.py +++ b/gerber/gerber_statements.py @@ -21,6 +21,7 @@ Gerber (RS-274X) Statements """ from .utils import parse_gerber_value, write_gerber_value, decimal_string +from .am_statements import * class Statement(object): @@ -290,77 +291,6 @@ class ADParamStmt(ParamStmt): return '' % (self.d, shape) -class AMPrimitive(object): - - def __init__(self, code, exposure): - self.code = code - self.exposure = exposure - - def to_inch(self): - pass - - def to_metric(self): - pass - - -class AMOutlinePrimitive(AMPrimitive): - - @classmethod - def from_gerber(cls, primitive): - modifiers = primitive.split(",") - - code = int(modifiers[0]) - exposure = "on" if modifiers[1] == "1" else "off" - n = int(modifiers[2]) - start_point = (float(modifiers[3]), float(modifiers[4])) - points = [] - - for i in range(n): - points.append((float(modifiers[5 + i*2]), float(modifiers[5 + i*2 + 1]))) - - rotation = float(modifiers[-1]) - - return cls(code, exposure, start_point, points, rotation) - - def __init__(self, code, exposure, start_point, points, rotation): - super(AMOutlinePrimitive, self).__init__(code, exposure) - - self.start_point = start_point - self.points = points - self.rotation = rotation - - def to_inch(self): - self.start_point = tuple([x / 25.4 for x in self.start_point]) - self.points = tuple([(x / 25.4, y / 25.4) for x, y in self.points]) - - def to_metric(self): - self.start_point = tuple([x * 25.4 for x in self.start_point]) - self.points = tuple([(x * 25.4, y * 25.4) for x, y in self.points]) - - def to_gerber(self, settings=None): - data = dict( - code=self.code, - exposure="1" if self.exposure == "on" else "0", - n_points=len(self.points), - start_point="%.4f,%.4f" % self.start_point, - points=",".join(["%.4f,%.4f" % point for point in self.points]), - rotation=str(self.rotation) - ) - return "{code},{exposure},{n_points},{start_point},{points},{rotation}".format(**data) - - -class AMUnsupportPrimitive(object): - @classmethod - def from_gerber(cls, primitive): - return cls(primitive) - - def __init__(self, primitive): - self.primitive = primitive - - def to_gerber(self, settings=None): - return self.primitive - - class AMParamStmt(ParamStmt): """ AM - Aperture Macro Statement """ @@ -396,9 +326,12 @@ class AMParamStmt(ParamStmt): def _parsePrimitives(self, macro): primitives = [] - - for primitive in macro.split("*"): - if primitive[0] == "4": + for primitive in macro.split('*'): + # Couldn't find anything explicit about leading whitespace in the spec... + primitive = primitive.lstrip() + if primitive[0] == '0': + primitives.append(AMCommentPrimitive.from_gerber(primitive)) + if primitive[0] == '4': primitives.append(AMOutlinePrimitive.from_gerber(primitive)) else: primitives.append(AMUnsupportPrimitive.from_gerber(primitive)) @@ -414,7 +347,7 @@ class AMParamStmt(ParamStmt): primitive.to_metric() def to_gerber(self, settings=None): - return '%AM{0}*{1}*%'.format(self.name, "".join([primitive.to_gerber(settings) for primitive in self.primitives])) + return '%AM{0}*{1}%'.format(self.name, '\n'.join([primitive.to_gerber(settings) for primitive in self.primitives])) def __str__(self): return '' % (self.name, self.macro) diff --git a/gerber/tests/test_am_statements.py b/gerber/tests/test_am_statements.py new file mode 100644 index 0000000..2ba7733 --- /dev/null +++ b/gerber/tests/test_am_statements.py @@ -0,0 +1,77 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- + +# Author: Hamilton Kibbe + +from .tests import * +from ..am_statements import * + +def test_AMPrimitive_ctor(): + for exposure in ('on', 'off', 'ON', 'OFF'): + for code in (0, 1, 2, 4, 5, 6, 7, 20, 21, 22): + p = AMPrimitive(code, exposure) + assert_equal(p.code, code) + assert_equal(p.exposure, exposure.lower()) + + +def test_AMPrimitive_validation(): + assert_raises(TypeError, AMPrimitive, '1', 'off') + assert_raises(ValueError, AMPrimitive, 0, 'exposed') + assert_raises(ValueError, AMPrimitive, 3, 'off') + + +def test_AMCommentPrimitive_ctor(): + c = AMCommentPrimitive(0, ' This is a comment *') + assert_equal(c.code, 0) + assert_equal(c.comment, 'This is a comment') + + +def test_AMCommentPrimitive_validation(): + assert_raises(ValueError, AMCommentPrimitive, 1, 'This is a comment') + + +def test_AMCommentPrimitive_factory(): + c = AMCommentPrimitive.from_gerber('0 Rectangle with rounded corners. *') + assert_equal(c.code, 0) + assert_equal(c.comment, 'Rectangle with rounded corners.') + + +def test_AMCommentPrimitive_dump(): + c = AMCommentPrimitive(0, 'Rectangle with rounded corners.') + assert_equal(c.to_gerber(), '0 Rectangle with rounded corners. *') + + +def test_AMCirclePrimitive_ctor(): + test_cases = ((1, 'on', 0, (0, 0)), + (1, 'off', 1, (0, 1)), + (1, 'on', 2.5, (0, 2)), + (1, 'off', 5.0, (3, 3))) + for code, exposure, diameter, position in test_cases: + c = AMCirclePrimitive(code, exposure, diameter, position) + assert_equal(c.code, code) + assert_equal(c.exposure, exposure) + assert_equal(c.diameter, diameter) + assert_equal(c.position, position) + + +def test_AMCirclePrimitive_validation(): + assert_raises(ValueError, AMCirclePrimitive, 2, 'on', 0, (0, 0)) + + +def test_AMCirclePrimitive_factory(): + c = AMCirclePrimitive.from_gerber('1,0,5,0,0*') + assert_equal(c.code, 1) + assert_equal(c.exposure, 'off') + assert_equal(c.diameter, 5) + assert_equal(c.position, (0,0)) + + +def test_AMCirclePrimitive_dump(): + c = AMCirclePrimitive(1, 'off', 5, (0, 0)) + assert_equal(c.to_gerber(), '1,0,5,0,0*') + c = AMCirclePrimitive(1, 'on', 5, (0, 0)) + assert_equal(c.to_gerber(), '1,1,5,0,0*') + + + + \ No newline at end of file From d7a453e5ab1eb52c121165f7d027fc66906edc81 Mon Sep 17 00:00:00 2001 From: Paulo Henrique Silva Date: Sun, 8 Feb 2015 00:28:17 -0200 Subject: [PATCH 025/186] Remove unused file --- gerber/render/apertures.py | 76 -------------------------------------- 1 file changed, 76 deletions(-) delete mode 100644 gerber/render/apertures.py diff --git a/gerber/render/apertures.py b/gerber/render/apertures.py deleted file mode 100644 index 52ae50c..0000000 --- a/gerber/render/apertures.py +++ /dev/null @@ -1,76 +0,0 @@ -#! /usr/bin/env python -# -*- coding: utf-8 -*- - -# Copyright 2014 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. -""" -gerber.render.apertures -============ -**Gerber Aperture base classes** - -This module provides base classes for gerber apertures. These are used by -the rendering engine to draw the gerber file. -""" -import math - -class Aperture(object): - """ Gerber Aperture base class - """ - def draw(self, ctx, x, y): - raise NotImplementedError('The draw method must be implemented \ - in an Aperture subclass.') - - def flash(self, ctx, x, y): - raise NotImplementedError('The flash method must be implemented \ - in an Aperture subclass.') - - def _arc_params(self, startx, starty, x, y, i, j): - center = (startx + i, starty + j) - radius = math.sqrt(math.pow(center[0] - x, 2) + - math.pow(center[1] - y, 2)) - delta_x0 = startx - center[0] - delta_y0 = center[1] - starty - delta_x1 = x - center[0] - delta_y1 = center[1] - y - start_angle = math.atan2(delta_y0, delta_x0) - end_angle = math.atan2(delta_y1, delta_x1) - return {'center': center, 'radius': radius, - 'start_angle': start_angle, 'end_angle': end_angle} - - -class Circle(Aperture): - """ Circular Aperture base class - """ - def __init__(self, diameter=0.0): - self.diameter = diameter - - -class Rect(Aperture): - """ Rectangular Aperture base class - """ - def __init__(self, size=(0, 0)): - self.size = size - - -class Obround(Aperture): - """ Obround Aperture base class - """ - def __init__(self, size=(0, 0)): - self.size = size - - -class Polygon(Aperture): - """ Polygon Aperture base class - """ - pass From e38071868a7ea676e6d4bf80e8f8646b8e0af80b Mon Sep 17 00:00:00 2001 From: Paulo Henrique Silva Date: Sun, 8 Feb 2015 00:29:08 -0200 Subject: [PATCH 026/186] Fix copy-paste error on ASParamStmt --- gerber/gerber_statements.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gerber/gerber_statements.py b/gerber/gerber_statements.py index 7b1b56d..48d5d93 100644 --- a/gerber/gerber_statements.py +++ b/gerber/gerber_statements.py @@ -362,7 +362,7 @@ class ASParamStmt(ParamStmt): mode = stmt_dict.get('mode') return cls(param, mode) - def __init__(self, param, ip): + def __init__(self, param, mode): """ Initialize ASParamStmt class Parameters From 3435fecd3b29716f91531dc2998776ab82897f09 Mon Sep 17 00:00:00 2001 From: Hamilton Kibbe Date: Sun, 8 Feb 2015 21:52:09 -0500 Subject: [PATCH 027/186] Add rest of Aperture Macro Primitives --- doc/source/documentation/index.rst | 3 +- gerber/am_statements.py | 705 +++++++++++++++++++++++------ 2 files changed, 572 insertions(+), 136 deletions(-) diff --git a/doc/source/documentation/index.rst b/doc/source/documentation/index.rst index 6fbfa94..28ecb99 100644 --- a/doc/source/documentation/index.rst +++ b/doc/source/documentation/index.rst @@ -7,5 +7,4 @@ PCB Tools Reference Gerber (RS-274X) Files Excellon Files Rendering - - + \ No newline at end of file diff --git a/gerber/am_statements.py b/gerber/am_statements.py index 3f6ff1e..9559424 100644 --- a/gerber/am_statements.py +++ b/gerber/am_statements.py @@ -23,31 +23,29 @@ from .utils import validate_coordinates class AMPrimitive(object): """ Aperture Macro Primitive Base Class + + Parameters + ---------- + code : int + primitive shape code + + exposure : str + on or off Primitives with exposure on create a slid part of + the macro aperture, and primitives with exposure off erase the + solid part created previously in the aperture macro definition. + .. note:: + The erasing effect is limited to the aperture definition in + which it occurs. + + Returns + ------- + primitive : :class: `gerber.am_statements.AMPrimitive` + + Raises + ------ + TypeError, ValueError """ def __init__(self, code, exposure=None): - """ Initialize Aperture Macro Primitive base class - - Parameters - ---------- - code : int - primitive shape code - - exposure : str - on or off Primitives with exposure on create a slid part of - the macro aperture, and primitives with exposure off erase the - solid part created previously in the aperture macro definition. - .. note:: - The erasing effect is limited to the aperture definition in - which it occurs. - - Returns - ------- - primitive : :class: `gerber.am_statements.AMPrimitive` - - Raises - ------ - TypeError, ValueError - """ VALID_CODES = (0, 1, 2, 4, 5, 6, 7, 20, 21, 22) if not isinstance(code, int): raise TypeError('Aperture Macro Primitive code must be an integer') @@ -67,6 +65,30 @@ class AMPrimitive(object): class AMCommentPrimitive(AMPrimitive): """ Aperture Macro Comment primitive. Code 0 + + The comment primitive has no image meaning. It is used to include human- + readable comments into the AM command. + + .. seealso:: + `The Gerber File Format Specification `_ + **Section 4.12.3.1:** Comment, primitive code 0 + + Parameters + ---------- + code : int + Aperture Macro primitive code. 0 Indicates an AMCommentPrimitive + + comment : str + The comment as a string. + + Returns + ------- + CommentPrimitive : :class:`gerber.am_statements.AMCommentPrimitive` + An Initialized AMCommentPrimitive + + Raises + ------ + ValueError """ @classmethod def from_gerber(cls, primitive): @@ -76,25 +98,6 @@ class AMCommentPrimitive(AMPrimitive): return cls(code, comment) def __init__(self, code, comment): - """ Initialize AMCommentPrimitive class - - Parameters - ---------- - code : int - Aperture Macro primitive code. 0 Indicates an AMCommentPrimitive - - comment : str - The comment as a string. - - Returns - ------- - CommentPrimitive : :class:`gerber.am_statements.AMCommentPrimitive` - An Initialized AMCommentPrimitive - - Raises - ------ - ValueError - """ if code != 0: raise ValueError('Not a valid Aperture Macro Comment statement') super(AMCommentPrimitive, self).__init__(code) @@ -109,6 +112,35 @@ class AMCommentPrimitive(AMPrimitive): class AMCirclePrimitive(AMPrimitive): """ Aperture macro Circle primitive. Code 1 + + A circle primitive is defined by its center point and diameter. + + .. seealso:: + `The Gerber File Format Specification `_ + **Section 4.12.3.2:** Circle, primitive code 1 + + Parameters + ---------- + code : int + Circle Primitive code. Must be 1 + + exposure : string + 'on' or 'off' + + diameter : float + Circle diameter + + position : tuple (, ) + Position of the circle relative to the macro origin + + Returns + ------- + CirclePrimitive : :class:`gerber.am_statements.AMCirclePrimitive` + An initialized AMCirclePrimitive + + Raises + ------ + ValueError, TypeError """ @classmethod def from_gerber(cls, primitive): @@ -120,31 +152,6 @@ class AMCirclePrimitive(AMPrimitive): return cls(code, exposure, diameter, position) def __init__(self, code, exposure, diameter, position): - """ Initialize AMCirclePrimitive - - Parameters - ---------- - code : int - Circle Primitive code. Must be 1 - - exposure : string - 'on' or 'off' - - diameter : float - Circle diameter - - position : tuple (, ) - Position of the circle relative to the macro origin - - Returns - ------- - CirclePrimitive : :class:`gerber.am_statements.AMCirclePrimitive` - An initialized AMCirclePrimitive - - Raises - ------ - ValueError, TypeError - """ validate_coordinates(position) if code != 1: raise ValueError('Not a valid Aperture Macro Circle statement') @@ -162,7 +169,43 @@ class AMCirclePrimitive(AMPrimitive): class AMVectorLinePrimitive(AMPrimitive): - """ Aperture Macro Vector Line primitive. Code 2 or 20 + """ Aperture Macro Vector Line primitive. Code 2 or 20. + + A vector line is a rectangle defined by its line width, start, and end + points. The line ends are rectangular. + + .. seealso:: + `The Gerber File Format Specification `_ + **Section 4.12.3.3:** Vector Line, primitive code 2 or 20. + + Parameters + ---------- + code : int + Vector Line Primitive code. Must be either 2 or 20. + + exposure : string + 'on' or 'off' + + width : float + Line width + + start : tuple (, ) + coordinate of line start point + + end : tuple (, ) + coordinate of line end point + + rotation : float + Line rotation about the origin. + + Returns + ------- + LinePrimitive : :class:`gerber.am_statements.AMVectorLinePrimitive` + An initialized AMVectorLinePrimitive + + Raises + ------ + ValueError, TypeError """ @classmethod def from_gerber(cls, primitive): @@ -170,43 +213,12 @@ class AMVectorLinePrimitive(AMPrimitive): code = int(modifiers[0]) exposure = 'on' if modifiers[1].strip() == '1' else 'off' width = float(modifiers[2]) - start (float(modifiers[3]), float(modifiers[4])) + start = (float(modifiers[3]), float(modifiers[4])) end = (float(modifiers[5]), float(modifiers[6])) rotation = float(modifiers[7]) return cls(code, exposure, width, start, end, rotation) def __init__(self, code, exposure, width, start, end, rotation): - """ Initialize AMVectorLinePrimitive - - Parameters - ---------- - code : int - Vector Line Primitive code. Must be either 2 or 20. - - exposure : string - 'on' or 'off' - - width : float - Line width - - start : tuple (, ) - coordinate of line start point - - end : tuple (, ) - coordinate of line end point - - rotation : float - Line rotation about the origin. - - Returns - ------- - LinePrimitive : :class:`gerber.am_statements.AMVectorLinePrimitive` - An initialized AMVectorLinePrimitive - - Raises - ------ - ValueError, TypeError - """ validate_coordinates(start) validate_coordinates(end) if code not in (2, 20): @@ -230,7 +242,43 @@ class AMVectorLinePrimitive(AMPrimitive): # Code 4 class AMOutlinePrimitive(AMPrimitive): + """ Aperture Macro Outline primitive. Code 6. + An outline primitive is an area enclosed by an n-point polygon defined by + its start point and n subsequent points. The outline must be closed, i.e. + the last point must be equal to the start point. Self intersecting + outlines are not allowed. + + .. seealso:: + `The Gerber File Format Specification `_ + **Section 4.12.3.6:** Outline, primitive code 4. + + Parameters + ---------- + code : int + OutlinePrimitive code. Must be 4. + + exposure : string + 'on' or 'off' + + start_point : tuple (, ) + coordinate of outline start point + + points : list of tuples (, ) + coordinates of subsequent points + + rotation : float + outline rotation about the origin. + + Returns + ------- + OutlinePrimitive : :class:`gerber.am_statements.AMOutlineinePrimitive` + An initialized AMOutlinePrimitive + + Raises + ------ + ValueError, TypeError + """ @classmethod def from_gerber(cls, primitive): modifiers = primitive.strip(' *').split(",") @@ -240,42 +288,13 @@ class AMOutlinePrimitive(AMPrimitive): n = int(modifiers[2]) start_point = (float(modifiers[3]), float(modifiers[4])) points = [] - for i in range(n): points.append((float(modifiers[5 + i*2]), float(modifiers[5 + i*2 + 1]))) - rotation = float(modifiers[-1]) - return cls(code, exposure, start_point, points, rotation) def __init__(self, code, exposure, start_point, points, rotation): """ Initialize AMOutlinePrimitive - - Parameters - ---------- - code : int - OutlinePrimitive code. Must be 4. - - exposure : string - 'on' or 'off' - - start_point : tuple (, ) - coordinate of outline start point - - points : list of tuples (, ) - coordinates of subsequent points - - rotation : float - outline rotation about the origin. - - Returns - ------- - OutlinePrimitive : :class:`gerber.am_statements.AMOutlineinePrimitive` - An initialized AMOutlinePrimitive - - Raises - ------ - ValueError, TypeError """ validate_coordinates(start_point) for point in points: @@ -306,27 +325,445 @@ class AMOutlinePrimitive(AMPrimitive): # Code 5 class AMPolygonPrimitive(AMPrimitive): - pass + """ Aperture Macro Polygon primitive. Code 5. + + A polygon primitive is a regular polygon defined by the number of + vertices, the center point, and the diameter of the circumscribed circle. + + .. seealso:: + `The Gerber File Format Specification `_ + **Section 4.12.3.8:** Polygon, primitive code 5. + + Parameters + ---------- + code : int + PolygonPrimitive code. Must be 5. + + exposure : string + 'on' or 'off' + + vertices : int, 3 <= vertices <= 12 + Number of vertices + + position : tuple (, ) + X and Y coordinates of polygon center + + diameter : float + diameter of circumscribed circle. + + rotation : float + polygon rotation about the origin. + + Returns + ------- + PolygonPrimitive : :class:`gerber.am_statements.AMPolygonPrimitive` + An initialized AMPolygonPrimitive + + Raises + ------ + ValueError, TypeError + """ + @classmethod + def from_gerber(cls, primitive): + modifiers = primitive.strip(' *').split(",") + code = int(modifiers[0]) + exposure = "on" if modifiers[1].strip() == "1" else "off" + vertices = int(modifiers[2]) + position = (float(modifiers[3]), float(modifiers[4])) + diameter = float(modifiers[5]) + rotation = float(modifiers[6]) + return cls(code, exposure, vertices, position, diameter, rotation) + + + def __init__(self, code, exposure, vertices, position, diameter, rotation): + """ Initialize AMPolygonPrimitive + """ + super(AMPolygonPrimitive, self).__init__(code, exposure) + if vertices < 3 or vertices > 12: + raise ValueError('Number of vertices must be between 3 and 12') + self.vertices = vertices + validate_coordinates(position) + self.position = position + self.diameter = diameter + self.rotation = rotation + + def to_inch(self): + self.position = tuple([x / 25.4 for x in self.position]) + self.diameter = self.diameter / 25.4 + + def to_metric(self): + self.position = tuple([x * 25.4 for x in self.position]) + self.diameter = self.diameter * 25.4 + + def to_gerber(self, settings=None): + data = dict( + code=self.code, + exposure="1" if self.exposure == "on" else "0", + vertices=self.vertices, + position="%.4f,%.4f" % self.position, + diameter = '%.4f' % self.diameter, + rotation=str(self.rotation) + ) + fmt = "{code},{exposure},{vertices},{position},{diameter},{rotation}*" + return fmt.format(**data) # Code 6 class AMMoirePrimitive(AMPrimitive): - pass + """ Aperture Macro Moire primitive. Code 6. + The moire primitive is a cross hair centered on concentric rings (annuli). + Exposure is always on. + + .. seealso:: + `The Gerber File Format Specification `_ + **Section 4.12.3.9:** Moire, primitive code 6. + + Parameters + ---------- + code : int + Moire Primitive code. Must be 6. + + position : tuple (, ) + X and Y coordinates of moire center + + diameter : float + outer diameter of outer ring. + + ring_thickness : float + thickness of concentric rings. + + gap : float + gap between concentric rings. + + max_rings : float + maximum number of rings + + crosshair_thickness : float + thickness of crosshairs + + crosshair_length : float + length of crosshairs + + rotation : float + moire rotation about the origin. + + Returns + ------- + MoirePrimitive : :class:`gerber.am_statements.AMMoirePrimitive` + An initialized AMMoirePrimitive + + Raises + ------ + ValueError, TypeError + """ + @classmethod + def from_gerber(cls, primitive): + modifiers = primitive.strip(' *').split(",") + code = int(modifiers[0]) + position = (float(modifiers[1]), float(modifiers[2])) + diameter = float(modifiers[3]) + ring_thickness = float(modifiers[4]) + gap = float(modifiers[5]) + max_rings = int(modifiers[6]) + crosshair_thickness = float(modifiers[7]) + crosshair_length = float(modifiers[8]) + rotation = float(modifiers[9]) + return cls(code, position, diameter, ring_thickness, gap, max_rings, crosshair_thickness, crosshair_length, rotation) + + def __init__(self, code, position, diameter, ring_thickness, gap, max_rings, crosshair_thickness, crosshair_length, rotation): + """ Initialize AMoirePrimitive + """ + super(AMMoirePrimitive, self).__init__(code, 'on') + validate_coordinates(position) + self.position = position + self.diameter = diameter + self.ring_thickness = ring_thickness + self.gap = gap + self.max_rings = max_rings + self.crosshair_thickness = crosshair_thickness + self.crosshair_length = crosshair_length + self.rotation = rotation + + def to_inch(self): + self.position = tuple([x / 25.4 for x in self.position]) + self.diameter = self.diameter / 25.4 + self.ring_thickness = self.ring_thickness / 25.4 + self.gap = self.gap / 25.4 + self.crosshair_thickness = self.crosshair_thickness / 25.4 + self.crosshair_length = self.crosshair_length / 25.4 + + def to_metric(self): + self.position = tuple([x * 25.4 for x in self.position]) + self.diameter = self.diameter * 25.4 + self.ring_thickness = self.ring_thickness * 25.4 + self.gap = self.gap / 25.4 + self.crosshair_thickness = self.crosshair_thickness * 25.4 + self.crosshair_length = self.crosshair_length * 25.4 + + + def to_gerber(self, settings=None): + data = dict( + code=self.code, + position="%.4f,%.4f" % self.position, + diameter = '%.4f' % self.diameter, + ring_thickness = '%.4f' % self.ring_thickness, + gap = '%.4f' % self.gap, + max_rings = str(self.max_rings), + crosshair_thickness = '%.4f' % self.crosshair_thickness, + crosshair_length = '%.4f' % self.crosshair_length, + rotation=str(self.rotation) + ) + fmt = "{code},{position},{diameter},{ring_thickness},{gap},{max_rings},{crosshair_thickness},{crosshair_length},{rotation}*" + return fmt.format(**data) # Code 7 class AMThermalPrimitive(AMPrimitive): - pass + """ Aperture Macro Thermal primitive. Code 7. + + The thermal primitive is a ring (annulus) interrupted by four gaps. + Exposure is always on. + + .. seealso:: + `The Gerber File Format Specification `_ + **Section 4.12.3.10:** Thermal, primitive code 7. + + Parameters + ---------- + code : int + Thermal Primitive code. Must be 7. + + position : tuple (, ) + X and Y coordinates of thermal center + + outer_diameter : float + outer diameter of thermal. + + inner_diameter : float + inner diameter of thermal. + + gap : float + gap thickness + + rotation : float + thermal rotation about the origin. + + Returns + ------- + ThermalPrimitive : :class:`gerber.am_statements.AMThermalPrimitive` + An initialized AMThermalPrimitive + + Raises + ------ + ValueError, TypeError + """ + @classmethod + def from_gerber(cls, primitive): + modifiers = primitive.strip(' *').split(",") + code = int(modifiers[0]) + position = (float(modifiers[1]), float(modifiers[2])) + outer_diameter = float(modifiers[3]) + inner_diameter= float(modifiers[4]) + gap = float(modifiers[5]) + rotation = float(modifiers[6]) + return cls(code, position, outer_diameter, inner_diameter, gap, rotation) + + def __init__(self, code, position, outer_diameter, inner_diameter, gap, rotation): + super(AMThermalPrimitive, self).__init(code, 'on') + validate_coordinates(position) + self.position = position + self.outer_diameter = outer_diameter + self.inner_diameter = inner_diameter + self.gap = gap + self.rotation = rotation + + def to_inch(self): + self.position = tuple([x / 25.4 for x in self.position]) + self.outer_diameter = self.outer_diameter / 25.4 + self.inner_diameter = self.inner_diameter / 25.4 + self.gap = self.gap / 25.4 + + + def to_metric(self): + self.position = tuple([x * 25.4 for x in self.position]) + self.outer_diameter = self.outer_diameter * 25.4 + self.inner_diameter = self.inner_diameter * 25.4 + self.gap = self.gap * 25.4 + + def to_gerber(self, settings=None): + data = dict( + code=self.code, + position="%.4f,%.4f" % self.position, + outer_diameter = '%.4f' % self.outer_diameter, + inner_diameter = '%.4f' % self.inner_diameter, + gap = '%.4f' % self.gap, + rotation=str(self.rotation) + ) + fmt = "{code},{position},{outer_diameter},{inner_diameter},{gap},{rotation}*" + return fmt.format(**data) # Code 21 class AMCenterLinePrimitive(AMPrimitive): - pass + """ Aperture Macro Center Line primitive. Code 21. + + The center line primitive is a rectangle defined by its width, height, and center point. + + .. seealso:: + `The Gerber File Format Specification `_ + **Section 4.12.3.4:** Center Line, primitive code 21. + + Parameters + ---------- + code : int + Center Line Primitive code. Must be 21. + + exposure : str + 'on' or 'off' + + width : float + Width of rectangle + + height : float + Height of rectangle + + center : tuple (, ) + X and Y coordinates of line center + + rotation : float + rectangle rotation about its center. + + Returns + ------- + CenterLinePrimitive : :class:`gerber.am_statements.AMCenterLinePrimitive` + An initialized AMCenterLinePrimitive + + Raises + ------ + ValueError, TypeError + """ + + @classmethod + def from_gerber(cls, primitive): + modifiers = primitive.strip(' *').split(",") + code = int(modifiers[0]) + exposure = 'on' if modifiers[1].strip() == '1' else 'off' + width = float(modifiers[2]) + height = float(modifiers[3]) + center= (float(modifiers[4]), float(modifiers[5])) + rotation = float(modifiers[6]) + return cls(code, exposure, width, height, center, rotation) + + def __init__(self, code, exposure, width, height, center, rotation): + super (AMCenterLinePrimitive, self).__init__(code, exposure) + self.width = width + self.height = height + validate_coordinates(center) + self.center = center + self.rotation = rotation + + def to_inch(self): + self.center = tuple([x / 25.4 for x in self.center]) + self.width = self.width / 25.4 + self.heignt = self.height / 25.4 + + def to_metric(self): + self.center = tuple([x * 25.4 for x in self.center]) + self.width = self.width * 25.4 + self.heignt = self.height * 25.4 + + def to_gerber(self, settings=None): + data = dict( + code=self.code, + exposure = '1' if self.exposure == 'on' else '0', + width = '%.4f' % self.width, + height = '%.4f' % self.height, + center="%.4f,%.4f" % self.center, + rotation=str(self.rotation) + ) + fmt = "{code},{exposure},{width},{height},{center},{rotation}*" + return fmt.format(**data) # Code 22 class AMLowerLeftLinePrimitive(AMPrimitive): - pass + """ Aperture Macro Lower Left Line primitive. Code 22. + + The lower left line primitive is a rectangle defined by its width, height, and the lower left point. + + .. seealso:: + `The Gerber File Format Specification `_ + **Section 4.12.3.5:** Lower Left Line, primitive code 22. + + Parameters + ---------- + code : int + Center Line Primitive code. Must be 21. + + exposure : str + 'on' or 'off' + + width : float + Width of rectangle + + height : float + Height of rectangle + + lower_left : tuple (, ) + X and Y coordinates of lower left corner + + rotation : float + rectangle rotation about its origin. + + Returns + ------- + LowerLeftLinePrimitive : :class:`gerber.am_statements.AMLowerLeftLinePrimitive` + An initialized AMLowerLeftLinePrimitive + + Raises + ------ + ValueError, TypeError + """ + @classmethod + def from_gerber(cls, primitive): + modifiers = primitive.strip(' *').split(",") + code = int(modifiers[0]) + exposure = 'on' if modifiers[1].strip() == '1' else 'off' + width = float(modifiers[2]) + height = float(modifiers[3]) + lower_left = (float(modifiers[4]), float(modifiers[5])) + rotation = float(modifiers[6]) + return cls(code, exposure, width, height, lower_left, rotation) + + def __init__(self, code, exposure, width, height, lower_left, rotation): + super (AMCenterLinePrimitive, self).__init__(code, exposure) + self.width = width + self.height = height + validate_coordinates(lower_left) + self.lower_left = lower_left + self.rotation = rotation + + def to_inch(self): + self.lower_left = tuple([x / 25.4 for x in self.lower_left]) + self.width = self.width / 25.4 + self.heignt = self.height / 25.4 + + def to_metric(self): + self.lower_left = tuple([x * 25.4 for x in self.lower_left]) + self.width = self.width * 25.4 + self.heignt = self.height * 25.4 + + def to_gerber(self, settings=None): + data = dict( + code=self.code, + exposure = '1' if self.exposure == 'on' else '0', + width = '%.4f' % self.width, + height = '%.4f' % self.height, + lower_left="%.4f,%.4f" % self.lower_left, + rotation=str(self.rotation) + ) + fmt = "{code},{exposure},{width},{height},{lower_left},{rotation}*" + return fmt.format(**data) class AMUnsupportPrimitive(AMPrimitive): From aea1f38597824085739aeed1fa6f33e264e23a4b Mon Sep 17 00:00:00 2001 From: Hamilton Kibbe Date: Sun, 8 Feb 2015 22:27:24 -0500 Subject: [PATCH 028/186] Fix write_gerber_value bug --- gerber/tests/test_utils.py | 3 +++ gerber/utils.py | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/gerber/tests/test_utils.py b/gerber/tests/test_utils.py index 1c3f1e5..fe9b2e6 100644 --- a/gerber/tests/test_utils.py +++ b/gerber/tests/test_utils.py @@ -37,6 +37,9 @@ def test_zero_suppression(): assert_equal(value, parse_gerber_value(string, fmt, zero_suppression)) assert_equal(string, write_gerber_value(value, fmt, zero_suppression)) + assert_equal(write_gerber_value(0.000000001, fmt, 'leading'), '0') + assert_equal(write_gerber_value(0.000000001, fmt, 'trailing'), '0') + def test_format(): """ Test gerber value parser and writer handle format correctly diff --git a/gerber/utils.py b/gerber/utils.py index 86119ba..23575b3 100644 --- a/gerber/utils.py +++ b/gerber/utils.py @@ -138,6 +138,11 @@ def write_gerber_value(value, format=(2, 5), zero_suppression='trailing'): fmtstring = '%%0%d.0%df' % (MAX_DIGITS + 1, decimal_digits) digits = [val for val in fmtstring % value if val != '.'] + # If all the digits are 0, return '0'. + digit_sum = reduce(lambda x,y:x+int(y), digits, 0) + if digit_sum == 0: + return '0' + # Suppression... if zero_suppression == 'trailing': while digits and digits[-1] == '0': From b0c55082b001a1232fb20bae25390a1514c9e8a9 Mon Sep 17 00:00:00 2001 From: Hamilton Kibbe Date: Mon, 9 Feb 2015 13:57:15 -0500 Subject: [PATCH 029/186] Add aperture macro statement tests --- gerber/am_statements.py | 218 ++++++++++++++---------- gerber/tests/test_am_statements.py | 261 ++++++++++++++++++++++++++++- gerber/tests/tests.py | 2 +- 3 files changed, 393 insertions(+), 88 deletions(-) diff --git a/gerber/am_statements.py b/gerber/am_statements.py index 9559424..0e27623 100644 --- a/gerber/am_statements.py +++ b/gerber/am_statements.py @@ -21,6 +21,18 @@ from .utils import validate_coordinates # TODO: Add support for aperture macro variables +__all__ = ['AMPrimitive', 'AMCommentPrimitive', 'AMCirclePrimitive', + 'AMVectorLinePrimitive', 'AMOutlinePrimitive', 'AMPolygonPrimitive', + 'AMMoirePrimitive', 'AMThermalPrimitive', 'AMCenterLinePrimitive', + 'AMLowerLeftLinePrimitive', 'AMUnsupportPrimitive'] + +def metric(value): + return value * 25.4 + +def inch(value): + return value / 25.4 + + class AMPrimitive(object): """ Aperture Macro Primitive Base Class @@ -57,10 +69,10 @@ class AMPrimitive(object): self.exposure = exposure.lower() if exposure is not None else None def to_inch(self): - pass + raise NotImplementedError('Subclass must implement `to-inch`') def to_metric(self): - pass + raise NotImplementedError('Subclass must implement `to-metric`') class AMCommentPrimitive(AMPrimitive): @@ -103,6 +115,12 @@ class AMCommentPrimitive(AMPrimitive): super(AMCommentPrimitive, self).__init__(code) self.comment = comment.strip(' *') + def to_inch(self): + pass + + def to_metric(self): + pass + def to_gerber(self, settings=None): return '0 %s *' % self.comment @@ -154,11 +172,19 @@ class AMCirclePrimitive(AMPrimitive): def __init__(self, code, exposure, diameter, position): validate_coordinates(position) if code != 1: - raise ValueError('Not a valid Aperture Macro Circle statement') + raise ValueError('CirclePrimitive code is 1') super(AMCirclePrimitive, self).__init__(code, exposure) self.diameter = diameter self.position = position + def to_inch(self): + self.diameter = inch(self.diameter) + self.position = tuple([inch(x) for x in self.position]) + + def to_metric(self): + self.diameter = metric(self.diameter) + self.position = tuple([metric(x) for x in self.position]) + def to_gerber(self, settings=None): data = dict(code = self.code, exposure = '1' if self.exposure == 'on' else 0, @@ -222,17 +248,29 @@ class AMVectorLinePrimitive(AMPrimitive): validate_coordinates(start) validate_coordinates(end) if code not in (2, 20): - raise ValueError('Valid VectorLinePrimitive codes are 2 or 20') + raise ValueError('VectorLinePrimitive codes are 2 or 20') super(AMVectorLinePrimitive, self).__init__(code, exposure) self.width = width self.start = start self.end = end self.rotation = rotation + def to_inch(self): + self.width = inch(self.width) + self.start = tuple([inch(x) for x in self.start]) + self.end = tuple([inch(x) for x in self.end]) + + def to_metric(self): + self.width = metric(self.width) + self.start = tuple([metric(x) for x in self.start]) + self.end = tuple([metric(x) for x in self.end]) + + def to_gerber(self, settings=None): - fmtstr = '{code},{exp},{width},{startx},{starty},{endx},{endy},{rot}*' + fmtstr = '{code},{exp},{width},{startx},{starty},{endx},{endy},{rotation}*' data = dict(code = self.code, exp = 1 if self.exposure == 'on' else 0, + width = self.width, startx = self.start[0], starty = self.start[1], endx = self.end[0], @@ -240,9 +278,9 @@ class AMVectorLinePrimitive(AMPrimitive): rotation = self.rotation) return fmtstr.format(**data) -# Code 4 + class AMOutlinePrimitive(AMPrimitive): - """ Aperture Macro Outline primitive. Code 6. + """ Aperture Macro Outline primitive. Code 4. An outline primitive is an area enclosed by an n-point polygon defined by its start point and n subsequent points. The outline must be closed, i.e. @@ -256,7 +294,7 @@ class AMOutlinePrimitive(AMPrimitive): Parameters ---------- code : int - OutlinePrimitive code. Must be 4. + OutlinePrimitive code. Must be 6. exposure : string 'on' or 'off' @@ -299,31 +337,35 @@ class AMOutlinePrimitive(AMPrimitive): validate_coordinates(start_point) for point in points: validate_coordinates(point) + if code != 4: + raise ValueError('OutlinePrimitive code is 4') super(AMOutlinePrimitive, self).__init__(code, exposure) self.start_point = start_point + if points[-1] != start_point: + raise ValueError('OutlinePrimitive must be closed') self.points = points self.rotation = rotation def to_inch(self): - self.start_point = tuple([x / 25.4 for x in self.start_point]) - self.points = tuple([(x / 25.4, y / 25.4) for x, y in self.points]) + self.start_point = tuple([inch(x) for x in self.start_point]) + self.points = tuple([(inch(x), inch(y)) for x, y in self.points]) def to_metric(self): - self.start_point = tuple([x * 25.4 for x in self.start_point]) - self.points = tuple([(x * 25.4, y * 25.4) for x, y in self.points]) + self.start_point = tuple([metric(x) for x in self.start_point]) + self.points = tuple([(metric(x), metric(y)) for x, y in self.points]) def to_gerber(self, settings=None): data = dict( code=self.code, exposure="1" if self.exposure == "on" else "0", n_points=len(self.points), - start_point="%.4f,%.4f" % self.start_point, - points=",".join(["%.4f,%.4f" % point for point in self.points]), + start_point="%.4g,%.4g" % self.start_point, + points=",".join(["%.4g,%.4g" % point for point in self.points]), rotation=str(self.rotation) ) return "{code},{exposure},{n_points},{start_point},{points},{rotation}*".format(**data) -# Code 5 + class AMPolygonPrimitive(AMPrimitive): """ Aperture Macro Polygon primitive. Code 5. @@ -378,6 +420,8 @@ class AMPolygonPrimitive(AMPrimitive): def __init__(self, code, exposure, vertices, position, diameter, rotation): """ Initialize AMPolygonPrimitive """ + if code != 5: + raise ValueError('PolygonPrimitive code is 5') super(AMPolygonPrimitive, self).__init__(code, exposure) if vertices < 3 or vertices > 12: raise ValueError('Number of vertices must be between 3 and 12') @@ -388,27 +432,26 @@ class AMPolygonPrimitive(AMPrimitive): self.rotation = rotation def to_inch(self): - self.position = tuple([x / 25.4 for x in self.position]) - self.diameter = self.diameter / 25.4 + self.position = tuple([inch(x) for x in self.position]) + self.diameter = inch(self.diameter) def to_metric(self): - self.position = tuple([x * 25.4 for x in self.position]) - self.diameter = self.diameter * 25.4 + self.position = tuple([metric(x) for x in self.position]) + self.diameter = metric(self.diameter) def to_gerber(self, settings=None): data = dict( code=self.code, exposure="1" if self.exposure == "on" else "0", vertices=self.vertices, - position="%.4f,%.4f" % self.position, - diameter = '%.4f' % self.diameter, + position="%.4g,%.4g" % self.position, + diameter = '%.4g' % self.diameter, rotation=str(self.rotation) ) fmt = "{code},{exposure},{vertices},{position},{diameter},{rotation}*" return fmt.format(**data) -# Code 6 class AMMoirePrimitive(AMPrimitive): """ Aperture Macro Moire primitive. Code 6. @@ -474,6 +517,8 @@ class AMMoirePrimitive(AMPrimitive): def __init__(self, code, position, diameter, ring_thickness, gap, max_rings, crosshair_thickness, crosshair_length, rotation): """ Initialize AMoirePrimitive """ + if code != 6: + raise ValueError('MoirePrimitive code is 6') super(AMMoirePrimitive, self).__init__(code, 'on') validate_coordinates(position) self.position = position @@ -486,38 +531,38 @@ class AMMoirePrimitive(AMPrimitive): self.rotation = rotation def to_inch(self): - self.position = tuple([x / 25.4 for x in self.position]) - self.diameter = self.diameter / 25.4 - self.ring_thickness = self.ring_thickness / 25.4 - self.gap = self.gap / 25.4 - self.crosshair_thickness = self.crosshair_thickness / 25.4 - self.crosshair_length = self.crosshair_length / 25.4 + self.position = tuple([inch(x) for x in self.position]) + self.diameter = inch(self.diameter) + self.ring_thickness = inch(self.ring_thickness) + self.gap = inch(self.gap) + self.crosshair_thickness = inch(self.crosshair_thickness) + self.crosshair_length = inch(self.crosshair_length) def to_metric(self): - self.position = tuple([x * 25.4 for x in self.position]) - self.diameter = self.diameter * 25.4 - self.ring_thickness = self.ring_thickness * 25.4 - self.gap = self.gap / 25.4 - self.crosshair_thickness = self.crosshair_thickness * 25.4 - self.crosshair_length = self.crosshair_length * 25.4 + self.position = tuple([metric(x) for x in self.position]) + self.diameter = metric(self.diameter) + self.ring_thickness = metric(self.ring_thickness) + self.gap = metric(self.gap) + self.crosshair_thickness = metric(self.crosshair_thickness) + self.crosshair_length = metric(self.crosshair_length) def to_gerber(self, settings=None): data = dict( code=self.code, - position="%.4f,%.4f" % self.position, - diameter = '%.4f' % self.diameter, - ring_thickness = '%.4f' % self.ring_thickness, - gap = '%.4f' % self.gap, - max_rings = str(self.max_rings), - crosshair_thickness = '%.4f' % self.crosshair_thickness, - crosshair_length = '%.4f' % self.crosshair_length, - rotation=str(self.rotation) + position="%.4g,%.4g" % self.position, + diameter = self.diameter, + ring_thickness = self.ring_thickness, + gap = self.gap, + max_rings = self.max_rings, + crosshair_thickness = self.crosshair_thickness, + crosshair_length = self.crosshair_length, + rotation=self.rotation ) fmt = "{code},{position},{diameter},{ring_thickness},{gap},{max_rings},{crosshair_thickness},{crosshair_length},{rotation}*" return fmt.format(**data) -# Code 7 + class AMThermalPrimitive(AMPrimitive): """ Aperture Macro Thermal primitive. Code 7. @@ -565,45 +610,43 @@ class AMThermalPrimitive(AMPrimitive): outer_diameter = float(modifiers[3]) inner_diameter= float(modifiers[4]) gap = float(modifiers[5]) - rotation = float(modifiers[6]) - return cls(code, position, outer_diameter, inner_diameter, gap, rotation) + return cls(code, position, outer_diameter, inner_diameter, gap) - def __init__(self, code, position, outer_diameter, inner_diameter, gap, rotation): - super(AMThermalPrimitive, self).__init(code, 'on') + def __init__(self, code, position, outer_diameter, inner_diameter, gap): + if code != 7: + raise ValueError('ThermalPrimitive code is 7') + super(AMThermalPrimitive, self).__init__(code, 'on') validate_coordinates(position) self.position = position self.outer_diameter = outer_diameter self.inner_diameter = inner_diameter self.gap = gap - self.rotation = rotation def to_inch(self): - self.position = tuple([x / 25.4 for x in self.position]) - self.outer_diameter = self.outer_diameter / 25.4 - self.inner_diameter = self.inner_diameter / 25.4 - self.gap = self.gap / 25.4 + self.position = tuple([inch(x) for x in self.position]) + self.outer_diameter = inch(self.outer_diameter) + self.inner_diameter = inch(self.inner_diameter) + self.gap = inch(self.gap) def to_metric(self): - self.position = tuple([x * 25.4 for x in self.position]) - self.outer_diameter = self.outer_diameter * 25.4 - self.inner_diameter = self.inner_diameter * 25.4 - self.gap = self.gap * 25.4 + self.position = tuple([metric(x) for x in self.position]) + self.outer_diameter = metric(self.outer_diameter) + self.inner_diameter = metric(self.inner_diameter) + self.gap = metric(self.gap) def to_gerber(self, settings=None): data = dict( code=self.code, - position="%.4f,%.4f" % self.position, - outer_diameter = '%.4f' % self.outer_diameter, - inner_diameter = '%.4f' % self.inner_diameter, - gap = '%.4f' % self.gap, - rotation=str(self.rotation) + position="%.4g,%.4g" % self.position, + outer_diameter = self.outer_diameter, + inner_diameter = self.inner_diameter, + gap = self.gap, ) - fmt = "{code},{position},{outer_diameter},{inner_diameter},{gap},{rotation}*" + fmt = "{code},{position},{outer_diameter},{inner_diameter},{gap}*" return fmt.format(**data) -# Code 21 class AMCenterLinePrimitive(AMPrimitive): """ Aperture Macro Center Line primitive. Code 21. @@ -655,6 +698,8 @@ class AMCenterLinePrimitive(AMPrimitive): return cls(code, exposure, width, height, center, rotation) def __init__(self, code, exposure, width, height, center, rotation): + if code != 21: + raise ValueError('CenterLinePrimitive code is 21') super (AMCenterLinePrimitive, self).__init__(code, exposure) self.width = width self.height = height @@ -663,29 +708,28 @@ class AMCenterLinePrimitive(AMPrimitive): self.rotation = rotation def to_inch(self): - self.center = tuple([x / 25.4 for x in self.center]) - self.width = self.width / 25.4 - self.heignt = self.height / 25.4 + self.center = tuple([inch(x) for x in self.center]) + self.width = inch(self.width) + self.height = inch(self.height) def to_metric(self): - self.center = tuple([x * 25.4 for x in self.center]) - self.width = self.width * 25.4 - self.heignt = self.height * 25.4 + self.center = tuple([metric(x) for x in self.center]) + self.width = metric(self.width) + self.height = metric(self.height) def to_gerber(self, settings=None): data = dict( code=self.code, exposure = '1' if self.exposure == 'on' else '0', - width = '%.4f' % self.width, - height = '%.4f' % self.height, - center="%.4f,%.4f" % self.center, - rotation=str(self.rotation) + width = self.width, + height = self.height, + center="%.4g,%.4g" % self.center, + rotation=self.rotation ) fmt = "{code},{exposure},{width},{height},{center},{rotation}*" return fmt.format(**data) -# Code 22 class AMLowerLeftLinePrimitive(AMPrimitive): """ Aperture Macro Lower Left Line primitive. Code 22. @@ -698,7 +742,7 @@ class AMLowerLeftLinePrimitive(AMPrimitive): Parameters ---------- code : int - Center Line Primitive code. Must be 21. + Center Line Primitive code. Must be 22. exposure : str 'on' or 'off' @@ -736,7 +780,9 @@ class AMLowerLeftLinePrimitive(AMPrimitive): return cls(code, exposure, width, height, lower_left, rotation) def __init__(self, code, exposure, width, height, lower_left, rotation): - super (AMCenterLinePrimitive, self).__init__(code, exposure) + if code != 22: + raise ValueError('LowerLeftLinePrimitive code is 22') + super (AMLowerLeftLinePrimitive, self).__init__(code, exposure) self.width = width self.height = height validate_coordinates(lower_left) @@ -744,23 +790,23 @@ class AMLowerLeftLinePrimitive(AMPrimitive): self.rotation = rotation def to_inch(self): - self.lower_left = tuple([x / 25.4 for x in self.lower_left]) - self.width = self.width / 25.4 - self.heignt = self.height / 25.4 + self.lower_left = tuple([inch(x) for x in self.lower_left]) + self.width = inch(self.width) + self.height = inch(self.height) def to_metric(self): - self.lower_left = tuple([x * 25.4 for x in self.lower_left]) - self.width = self.width * 25.4 - self.heignt = self.height * 25.4 + self.lower_left = tuple([metric(x) for x in self.lower_left]) + self.width = metric(self.width) + self.height = metric(self.height) def to_gerber(self, settings=None): data = dict( code=self.code, exposure = '1' if self.exposure == 'on' else '0', - width = '%.4f' % self.width, - height = '%.4f' % self.height, - lower_left="%.4f,%.4f" % self.lower_left, - rotation=str(self.rotation) + width = self.width, + height = self.height, + lower_left="%.4g,%.4g" % self.lower_left, + rotation=self.rotation ) fmt = "{code},{exposure},{width},{height},{lower_left},{rotation}*" return fmt.format(**data) diff --git a/gerber/tests/test_am_statements.py b/gerber/tests/test_am_statements.py index 2ba7733..696d951 100644 --- a/gerber/tests/test_am_statements.py +++ b/gerber/tests/test_am_statements.py @@ -5,6 +5,7 @@ from .tests import * from ..am_statements import * +from ..am_statements import inch, metric def test_AMPrimitive_ctor(): for exposure in ('on', 'off', 'ON', 'OFF'): @@ -19,6 +20,12 @@ def test_AMPrimitive_validation(): assert_raises(ValueError, AMPrimitive, 0, 'exposed') assert_raises(ValueError, AMPrimitive, 3, 'off') +def test_AMPrimitive_conversion(): + p = AMPrimitive(4, 'on') + assert_raises(NotImplementedError, p.to_inch) + assert_raises(NotImplementedError, p.to_metric) + + def test_AMCommentPrimitive_ctor(): c = AMCommentPrimitive(0, ' This is a comment *') @@ -40,6 +47,19 @@ def test_AMCommentPrimitive_dump(): c = AMCommentPrimitive(0, 'Rectangle with rounded corners.') assert_equal(c.to_gerber(), '0 Rectangle with rounded corners. *') +def test_AMCommentPrimitive_conversion(): + c = AMCommentPrimitive(0, 'Rectangle with rounded corners.') + ci = c + cm = c + ci.to_inch() + cm.to_metric() + assert_equal(c, ci) + assert_equal(c, cm) + +def test_AMCommentPrimitive_string(): + c = AMCommentPrimitive(0, 'Test Comment') + assert_equal(str(c), '') + def test_AMCirclePrimitive_ctor(): test_cases = ((1, 'on', 0, (0, 0)), @@ -72,6 +92,245 @@ def test_AMCirclePrimitive_dump(): c = AMCirclePrimitive(1, 'on', 5, (0, 0)) assert_equal(c.to_gerber(), '1,1,5,0,0*') +def test_AMCirclePrimitive_conversion(): + c = AMCirclePrimitive(1, 'off', 25.4, (25.4, 0)) + c.to_inch() + assert_equal(c.diameter, 1) + assert_equal(c.position, (1, 0)) + + c = AMCirclePrimitive(1, 'off', 1, (1, 0)) + c.to_metric() + assert_equal(c.diameter, 25.4) + assert_equal(c.position, (25.4, 0)) + +def test_AMVectorLinePrimitive_validation(): + assert_raises(ValueError, AMVectorLinePrimitive, 3, 'on', 0.1, (0,0), (3.3, 5.4), 0) + +def test_AMVectorLinePrimitive_factory(): + l = AMVectorLinePrimitive.from_gerber('20,1,0.9,0,0.45,12,0.45,0*') + assert_equal(l.code, 20) + assert_equal(l.exposure, 'on') + assert_equal(l.width, 0.9) + assert_equal(l.start, (0, 0.45)) + assert_equal(l.end, (12, 0.45)) + assert_equal(l.rotation, 0) + +def test_AMVectorLinePrimitive_dump(): + l = AMVectorLinePrimitive.from_gerber('20,1,0.9,0,0.45,12,0.45,0*') + assert_equal(l.to_gerber(), '20,1,0.9,0.0,0.45,12.0,0.45,0.0*') + +def test_AMVectorLinePrimtive_conversion(): + l = AMVectorLinePrimitive(20, 'on', 25.4, (0,0), (25.4, 25.4), 0) + l.to_inch() + assert_equal(l.width, 1) + assert_equal(l.start, (0, 0)) + assert_equal(l.end, (1, 1)) + + l = AMVectorLinePrimitive(20, 'on', 1, (0,0), (1, 1), 0) + l.to_metric() + assert_equal(l.width, 25.4) + assert_equal(l.start, (0, 0)) + assert_equal(l.end, (25.4, 25.4)) + +def test_AMOutlinePrimitive_validation(): + assert_raises(ValueError, AMOutlinePrimitive, 7, 'on', (0,0), [(3.3, 5.4), (4.0, 5.4), (0, 0)], 0) + assert_raises(ValueError, AMOutlinePrimitive, 4, 'on', (0,0), [(3.3, 5.4), (4.0, 5.4), (0, 1)], 0) + +def test_AMOutlinePrimitive_factory(): + o = AMOutlinePrimitive.from_gerber('4,1,3,0,0,3,3,3,0,0,0,0*') + assert_equal(o.code, 4) + assert_equal(o.exposure, 'on') + assert_equal(o.start_point, (0, 0)) + assert_equal(o.points, [(3, 3), (3, 0), (0, 0)]) + assert_equal(o.rotation, 0) + +def test_AMOUtlinePrimitive_dump(): + o = AMOutlinePrimitive(4, 'on', (0, 0), [(3, 3), (3, 0), (0, 0)], 0) + assert_equal(o.to_gerber(), '4,1,3,0,0,3,3,3,0,0,0,0*') + +def test_AMOutlinePrimitive_conversion(): + o = AMOutlinePrimitive(4, 'on', (0, 0), [(25.4, 25.4), (25.4, 0), (0, 0)], 0) + o.to_inch() + assert_equal(o.start_point, (0, 0)) + assert_equal(o.points, ((1., 1.), (1., 0.), (0., 0.))) + + o = AMOutlinePrimitive(4, 'on', (0, 0), [(1, 1), (1, 0), (0, 0)], 0) + o.to_metric() + assert_equal(o.start_point, (0, 0)) + assert_equal(o.points, ((25.4, 25.4), (25.4, 0), (0, 0))) + + +def test_AMPolygonPrimitive_validation(): + assert_raises(ValueError, AMPolygonPrimitive, 6, 'on', 3, (3.3, 5.4), 3, 0) + assert_raises(ValueError, AMPolygonPrimitive, 5, 'on', 2, (3.3, 5.4), 3, 0) + assert_raises(ValueError, AMPolygonPrimitive, 5, 'on', 13, (3.3, 5.4), 3, 0) + +def test_AMPolygonPrimitive_factory(): + p = AMPolygonPrimitive.from_gerber('5,1,3,3.3,5.4,3,0') + assert_equal(p.code, 5) + assert_equal(p.exposure, 'on') + assert_equal(p.vertices, 3) + assert_equal(p.position, (3.3, 5.4)) + assert_equal(p.diameter, 3) + assert_equal(p.rotation, 0) + +def test_AMPolygonPrimitive_dump(): + p = AMPolygonPrimitive(5, 'on', 3, (3.3, 5.4), 3, 0) + assert_equal(p.to_gerber(), '5,1,3,3.3,5.4,3,0*') + +def test_AMPolygonPrimitive_conversion(): + p = AMPolygonPrimitive(5, 'off', 3, (25.4, 0), 25.4, 0) + p.to_inch() + assert_equal(p.diameter, 1) + assert_equal(p.position, (1, 0)) + + p = AMPolygonPrimitive(5, 'off', 3, (1, 0), 1, 0) + p.to_metric() + assert_equal(p.diameter, 25.4) + assert_equal(p.position, (25.4, 0)) + + +def test_AMMoirePrimitive_validation(): + assert_raises(ValueError, AMMoirePrimitive, 7, (0, 0), 5.1, 0.2, 0.4, 6, 0.1, 6.1, 0) + +def test_AMMoirePrimitive_factory(): + m = AMMoirePrimitive.from_gerber('6,0,0,5,0.5,0.5,2,0.1,6,0*') + assert_equal(m.code, 6) + assert_equal(m.position, (0, 0)) + assert_equal(m.diameter, 5) + assert_equal(m.ring_thickness, 0.5) + assert_equal(m.gap, 0.5) + assert_equal(m.max_rings, 2) + assert_equal(m.crosshair_thickness, 0.1) + assert_equal(m.crosshair_length, 6) + assert_equal(m.rotation, 0) + +def test_AMMoirePrimitive_dump(): + m = AMMoirePrimitive.from_gerber('6,0,0,5,0.5,0.5,2,0.1,6,0*') + assert_equal(m.to_gerber(), '6,0,0,5.0,0.5,0.5,2,0.1,6.0,0.0*') + +def test_AMMoirePrimitive_conversion(): + m = AMMoirePrimitive(6, (25.4, 25.4), 25.4, 25.4, 25.4, 6, 25.4, 25.4, 0) + m.to_inch() + assert_equal(m.position, (1., 1.)) + assert_equal(m.diameter, 1.) + assert_equal(m.ring_thickness, 1.) + assert_equal(m.gap, 1.) + assert_equal(m.crosshair_thickness, 1.) + assert_equal(m.crosshair_length, 1.) + + m = AMMoirePrimitive(6, (1, 1), 1, 1, 1, 6, 1, 1, 0) + m.to_metric() + assert_equal(m.position, (25.4, 25.4)) + assert_equal(m.diameter, 25.4) + assert_equal(m.ring_thickness, 25.4) + assert_equal(m.gap, 25.4) + assert_equal(m.crosshair_thickness, 25.4) + assert_equal(m.crosshair_length, 25.4) + +def test_AMThermalPrimitive_validation(): + assert_raises(ValueError, AMThermalPrimitive, 8, (0.0, 0.0), 7, 5, 0.2) + assert_raises(TypeError, AMThermalPrimitive, 7, (0.0, '0'), 7, 5, 0.2) + +def test_AMThermalPrimitive_factory(): + t = AMThermalPrimitive.from_gerber('7,0,0,7,6,0.2*') + assert_equal(t.code, 7) + assert_equal(t.position, (0, 0)) + assert_equal(t.outer_diameter, 7) + assert_equal(t.inner_diameter, 6) + assert_equal(t.gap, 0.2) + +def test_AMThermalPrimitive_dump(): + t = AMThermalPrimitive.from_gerber('7,0,0,7,6,0.2*') + assert_equal(t.to_gerber(), '7,0,0,7.0,6.0,0.2*') + +def test_AMThermalPrimitive_conversion(): + t = AMThermalPrimitive(7, (25.4, 25.4), 25.4, 25.4, 25.4) + t.to_inch() + assert_equal(t.position, (1., 1.)) + assert_equal(t.outer_diameter, 1.) + assert_equal(t.inner_diameter, 1.) + assert_equal(t.gap, 1.) + + t = AMThermalPrimitive(7, (1, 1), 1, 1, 1) + t.to_metric() + assert_equal(t.position, (25.4, 25.4)) + assert_equal(t.outer_diameter, 25.4) + assert_equal(t.inner_diameter, 25.4) + assert_equal(t.gap, 25.4) + + +def test_AMCenterLinePrimitive_validation(): + assert_raises(ValueError, AMCenterLinePrimitive, 22, 1, 0.2, 0.5, (0, 0), 0) + +def test_AMCenterLinePrimtive_factory(): + l = AMCenterLinePrimitive.from_gerber('21,1,6.8,1.2,3.4,0.6,0*') + assert_equal(l.code, 21) + assert_equal(l.exposure, 'on') + assert_equal(l.width, 6.8) + assert_equal(l.height, 1.2) + assert_equal(l.center, (3.4, 0.6)) + assert_equal(l.rotation, 0) + +def test_AMCenterLinePrimitive_dump(): + l = AMCenterLinePrimitive.from_gerber('21,1,6.8,1.2,3.4,0.6,0*') + assert_equal(l.to_gerber(), '21,1,6.8,1.2,3.4,0.6,0.0*') + +def test_AMCenterLinePrimitive_conversion(): + l = AMCenterLinePrimitive(21, 'on', 25.4, 25.4, (25.4, 25.4), 0) + l.to_inch() + assert_equal(l.width, 1.) + assert_equal(l.height, 1.) + assert_equal(l.center, (1., 1.)) + + l = AMCenterLinePrimitive(21, 'on', 1, 1, (1, 1), 0) + l.to_metric() + assert_equal(l.width, 25.4) + assert_equal(l.height, 25.4) + assert_equal(l.center, (25.4, 25.4)) + +def test_AMLowerLeftLinePrimitive_validation(): + assert_raises(ValueError, AMLowerLeftLinePrimitive, 23, 1, 0.2, 0.5, (0, 0), 0) + +def test_AMLowerLeftLinePrimtive_factory(): + l = AMLowerLeftLinePrimitive.from_gerber('22,1,6.8,1.2,3.4,0.6,0*') + assert_equal(l.code, 22) + assert_equal(l.exposure, 'on') + assert_equal(l.width, 6.8) + assert_equal(l.height, 1.2) + assert_equal(l.lower_left, (3.4, 0.6)) + assert_equal(l.rotation, 0) + +def test_AMLowerLeftLinePrimitive_dump(): + l = AMLowerLeftLinePrimitive.from_gerber('22,1,6.8,1.2,3.4,0.6,0*') + assert_equal(l.to_gerber(), '22,1,6.8,1.2,3.4,0.6,0.0*') + +def test_AMLowerLeftLinePrimitive_conversion(): + l = AMLowerLeftLinePrimitive(22, 'on', 25.4, 25.4, (25.4, 25.4), 0) + l.to_inch() + assert_equal(l.width, 1.) + assert_equal(l.height, 1.) + assert_equal(l.lower_left, (1., 1.)) + + l = AMLowerLeftLinePrimitive(22, 'on', 1, 1, (1, 1), 0) + l.to_metric() + assert_equal(l.width, 25.4) + assert_equal(l.height, 25.4) + assert_equal(l.lower_left, (25.4, 25.4)) + +def test_AMUnsupportPrimitive(): + u = AMUnsupportPrimitive.from_gerber('Test') + assert_equal(u.primitive, 'Test') + u = AMUnsupportPrimitive('Test') + assert_equal(u.to_gerber(), 'Test') + + + +def test_inch(): + assert_equal(inch(25.4), 1) + +def test_metric(): + assert_equal(metric(1), 25.4) + - \ No newline at end of file diff --git a/gerber/tests/tests.py b/gerber/tests/tests.py index e7029e4..db02949 100644 --- a/gerber/tests/tests.py +++ b/gerber/tests/tests.py @@ -21,4 +21,4 @@ __all__ = ['assert_in', 'assert_not_in', 'assert_equal', 'assert_not_equal', def assert_array_almost_equal(arr1, arr2, decimal=6): assert_equal(len(arr1), len(arr2)) for i in xrange(len(arr1)): - assert_almost_equal(arr1[i], arr2[i], decimal) \ No newline at end of file + assert_almost_equal(arr1[i], arr2[i], decimal) From 41f9475b132001d52064392057e376c6423c33dc Mon Sep 17 00:00:00 2001 From: Hamilton Kibbe Date: Mon, 9 Feb 2015 17:39:24 -0500 Subject: [PATCH 030/186] Tests and bugfixes --- gerber/am_statements.py | 6 + gerber/gerber_statements.py | 47 ++++--- gerber/tests/resources/top_copper.GTL | 2 +- gerber/tests/test_gerber_statements.py | 177 +++++++++++++++++++++++-- 4 files changed, 202 insertions(+), 30 deletions(-) diff --git a/gerber/am_statements.py b/gerber/am_statements.py index 0e27623..dc97dfa 100644 --- a/gerber/am_statements.py +++ b/gerber/am_statements.py @@ -820,5 +820,11 @@ class AMUnsupportPrimitive(AMPrimitive): def __init__(self, primitive): self.primitive = primitive + def to_inch(self): + pass + + def to_metric(self): + pass + def to_gerber(self, settings=None): return self.primitive diff --git a/gerber/gerber_statements.py b/gerber/gerber_statements.py index 48d5d93..1401345 100644 --- a/gerber/gerber_statements.py +++ b/gerber/gerber_statements.py @@ -262,19 +262,19 @@ class ADParamStmt(ParamStmt): self.d = d self.shape = shape if modifiers is not None: - self.modifiers = [[float(x) for x in m.split("X")] for m in modifiers.split(",") if len(m)] + self.modifiers = [tuple([float(x) for x in m.split("X")]) for m in modifiers.split(",") if len(m)] else: self.modifiers = [] def to_inch(self): - self.modifiers = [[x / 25.4 for x in modifier] for modifier in self.modifiers] + self.modifiers = [tuple([x / 25.4 for x in modifier]) for modifier in self.modifiers] def to_metric(self): - self.modifiers = [[x * 25.4 for x in modifier] for modifier in self.modifiers] + self.modifiers = [tuple([x * 25.4 for x in modifier]) for modifier in self.modifiers] def to_gerber(self, settings=None): if len(self.modifiers): - return '%ADD{0}{1},{2}*%'.format(self.d, self.shape, ','.join(['X'.join(["%.4f" % x for x in modifier]) for modifier in self.modifiers])) + return '%ADD{0}{1},{2}*%'.format(self.d, self.shape, ','.join(['X'.join(["%.4g" % x for x in modifier]) for modifier in self.modifiers])) else: return '%ADD{0}{1}*%'.format(self.d, self.shape) @@ -326,16 +326,30 @@ class AMParamStmt(ParamStmt): def _parsePrimitives(self, macro): primitives = [] - for primitive in macro.split('*'): + for primitive in macro.strip('%\n').split('*'): # Couldn't find anything explicit about leading whitespace in the spec... - primitive = primitive.lstrip() - if primitive[0] == '0': - primitives.append(AMCommentPrimitive.from_gerber(primitive)) - if primitive[0] == '4': - primitives.append(AMOutlinePrimitive.from_gerber(primitive)) - else: - primitives.append(AMUnsupportPrimitive.from_gerber(primitive)) - + primitive = primitive.strip(' *%\n') + if len(primitive): + if primitive[0] == '0': + primitives.append(AMCommentPrimitive.from_gerber(primitive)) + elif primitive[0] == '1': + primitives.append(AMCirclePrimitive.from_gerber(primitive)) + elif primitive[0:2] in ('2,', '20'): + primitives.append(AMVectorLinePrimitive.from_gerber(primitive)) + elif primitive[0:2] == '21': + primitives.append(AMCenterLinePrimitive.from_gerber(primitive)) + elif primitive[0:2] == '22': + primitives.append(AMLowerLeftLinePrimitive.from_gerber(primitive)) + elif primitive[0] == '4': + primitives.append(AMOutlinePrimitive.from_gerber(primitive)) + elif primitive[0] == '5': + primitives.append(AMPolygonPrimitive.from_gerber(primitive)) + elif primitive[0] =='6': + primitives.append(AMMoirePrimitive.from_gerber(primitive)) + elif primitive[0] == '7': + primitives.append(AMThermalPrimitive.from_gerber(primitive)) + else: + primitives.append(AMUnsupportPrimitive.from_gerber(primitive)) return primitives def to_inch(self): @@ -465,7 +479,8 @@ class IRParamStmt(ParamStmt): """ @classmethod def from_dict(cls, stmt_dict): - return cls(**stmt_dict) + angle = int(stmt_dict['angle']) + return cls(stmt_dict['param'], angle) def __init__(self, param, angle): """ Initialize IRParamStmt class @@ -639,9 +654,9 @@ class SFParamStmt(ParamStmt): def __str__(self): scale_factor = '' if self.a is not None: - scale_factor += ('X: %f' % self.a) + scale_factor += ('X: %g' % self.a) if self.b is not None: - scale_factor += ('Y: %f' % self.b) + scale_factor += ('Y: %g' % self.b) return ('' % scale_factor) diff --git a/gerber/tests/resources/top_copper.GTL b/gerber/tests/resources/top_copper.GTL index 6d382c0..b49f7e7 100644 --- a/gerber/tests/resources/top_copper.GTL +++ b/gerber/tests/resources/top_copper.GTL @@ -6,7 +6,7 @@ G75* %LPD*% G04This is a comment,:* %AMOC8* -5,1,8,0,0,1.08239X$1,22.5* +5,1,8,0,0,1.08239,22.5* % %ADD10C,0.0000*% %ADD11R,0.0260X0.0800*% diff --git a/gerber/tests/test_gerber_statements.py b/gerber/tests/test_gerber_statements.py index e797d5a..0875b57 100644 --- a/gerber/tests/test_gerber_statements.py +++ b/gerber/tests/test_gerber_statements.py @@ -159,6 +159,31 @@ def test_IPParamStmt_dump(): ip = IPParamStmt.from_dict(stmt) assert_equal(ip.to_gerber(), '%IPNEG*%') +def test_IPParamStmt_string(): + stmt = {'param': 'IP', 'ip': 'POS'} + ip = IPParamStmt.from_dict(stmt) + assert_equal(str(ip), '') + + stmt = {'param': 'IP', 'ip': 'NEG'} + ip = IPParamStmt.from_dict(stmt) + assert_equal(str(ip), '') + +def test_IRParamStmt_factory(): + stmt = {'param': 'IR', 'angle': '45'} + ir = IRParamStmt.from_dict(stmt) + assert_equal(ir.param, 'IR') + assert_equal(ir.angle, 45) + +def test_IRParamStmt_dump(): + stmt = {'param': 'IR', 'angle': '45'} + ir = IRParamStmt.from_dict(stmt) + assert_equal(ir.to_gerber(), '%IR45*%') + +def test_IRParamStmt_string(): + stmt = {'param': 'IR', 'angle': '45'} + ir = IRParamStmt.from_dict(stmt) + assert_equal(str(ir), '') + def test_OFParamStmt_factory(): """ Test OFParamStmt factory @@ -195,6 +220,24 @@ def test_OFParamStmt_string(): of = OFParamStmt.from_dict(stmt) assert_equal(str(of), '') +def test_SFParamStmt_factory(): + stmt = {'param': 'SF', 'a': '1.4', 'b': '0.9'} + sf = SFParamStmt.from_dict(stmt) + assert_equal(sf.param, 'SF') + assert_equal(sf.a, 1.4) + assert_equal(sf.b, 0.9) + +def test_SFParamStmt_dump(): + stmt = {'param': 'SF', 'a': '1.4', 'b': '0.9'} + sf = SFParamStmt.from_dict(stmt) + assert_equal(sf.to_gerber(), '%SFA1.4B0.9*%') + +def test_SFParamStmt_string(): + stmt = {'param': 'SF', 'a': '1.4', 'b': '0.9'} + sf = SFParamStmt.from_dict(stmt) + assert_equal(str(sf), '') + + def test_LPParamStmt_factory(): """ Test LPParamStmt factory """ @@ -231,6 +274,75 @@ def test_LPParamStmt_string(): assert_equal(str(lp), '') +def test_AMParamStmt_factory(): + name = 'DONUTVAR' + macro = ( +'''0 Test Macro. * +1,1,1.5,0,0* +20,1,0.9,0,0.45,12,0.45,0* +21,1,6.8,1.2,3.4,0.6,0* +22,1,6.8,1.2,0,0,0* +4,1,4,0.1,0.1,0.5,0.1,0.5,0.5,0.1,0.5,0.1,0.1,0* +5,1,8,0,0,8,0* +6,0,0,5,0.5,0.5,2,0.1,6,0* +7,0,0,7,6,0.2,0* +8,THIS IS AN UNSUPPORTED PRIMITIVE* +''') + s = AMParamStmt.from_dict({'param': 'AM', 'name': name, 'macro': macro }) + assert_equal(len(s.primitives), 10) + assert_true(isinstance(s.primitives[0], AMCommentPrimitive)) + assert_true(isinstance(s.primitives[1], AMCirclePrimitive)) + assert_true(isinstance(s.primitives[2], AMVectorLinePrimitive)) + assert_true(isinstance(s.primitives[3], AMCenterLinePrimitive)) + assert_true(isinstance(s.primitives[4], AMLowerLeftLinePrimitive)) + assert_true(isinstance(s.primitives[5], AMOutlinePrimitive)) + assert_true(isinstance(s.primitives[6], AMPolygonPrimitive)) + assert_true(isinstance(s.primitives[7], AMMoirePrimitive)) + assert_true(isinstance(s.primitives[8], AMThermalPrimitive)) + assert_true(isinstance(s.primitives[9], AMUnsupportPrimitive)) + +def testAMParamStmt_conversion(): + name = 'POLYGON' + macro = '5,1,8,25.4,25.4,25.4,0*%' + s = AMParamStmt.from_dict({'param': 'AM', 'name': name, 'macro': macro }) + s.to_inch() + assert_equal(s.primitives[0].position, (1., 1.)) + assert_equal(s.primitives[0].diameter, 1.) + + macro = '5,1,8,1,1,1,0*%' + s = AMParamStmt.from_dict({'param': 'AM', 'name': name, 'macro': macro }) + s.to_metric() + assert_equal(s.primitives[0].position, (25.4, 25.4)) + assert_equal(s.primitives[0].diameter, 25.4) + +def test_AMParamStmt_dump(): + name = 'POLYGON' + macro = '5,1,8,25.4,25.4,25.4,0*%' + s = AMParamStmt.from_dict({'param': 'AM', 'name': name, 'macro': macro }) + assert_equal(s.to_gerber(), '%AMPOLYGON*5,1,8,25.4,25.4,25.4,0.0*%') + +def test_AMParamStmt_string(): + name = 'POLYGON' + macro = '5,1,8,25.4,25.4,25.4,0*%' + s = AMParamStmt.from_dict({'param': 'AM', 'name': name, 'macro': macro }) + assert_equal(str(s), '') + +def test_ASParamStmt_factory(): + stmt = {'param': 'AS', 'mode': 'AXBY'} + s = ASParamStmt.from_dict(stmt) + assert_equal(s.param, 'AS') + assert_equal(s.mode, 'AXBY') + +def test_ASParamStmt_dump(): + stmt = {'param': 'AS', 'mode': 'AXBY'} + s = ASParamStmt.from_dict(stmt) + assert_equal(s.to_gerber(), '%ASAXBY*%') + +def test_ASParamStmt_string(): + stmt = {'param': 'AS', 'mode': 'AXBY'} + s = ASParamStmt.from_dict(stmt) + assert_equal(str(s), '') + def test_INParamStmt_factory(): """ Test INParamStmt factory """ @@ -238,7 +350,6 @@ def test_INParamStmt_factory(): inp = INParamStmt.from_dict(stmt) assert_equal(inp.name, 'test') - def test_INParamStmt_dump(): """ Test INParamStmt to_gerber() """ @@ -246,6 +357,10 @@ def test_INParamStmt_dump(): inp = INParamStmt.from_dict(stmt) assert_equal(inp.to_gerber(), '%INtest*%') +def test_INParamStmt_string(): + stmt = {'param': 'IN', 'name': 'test'} + inp = INParamStmt.from_dict(stmt) + assert_equal(str(inp), '') def test_LNParamStmt_factory(): """ Test LNParamStmt factory @@ -254,7 +369,6 @@ def test_LNParamStmt_factory(): lnp = LNParamStmt.from_dict(stmt) assert_equal(lnp.name, 'test') - def test_LNParamStmt_dump(): """ Test LNParamStmt to_gerber() """ @@ -262,6 +376,10 @@ def test_LNParamStmt_dump(): lnp = LNParamStmt.from_dict(stmt) assert_equal(lnp.to_gerber(), '%LNtest*%') +def test_LNParamStmt_string(): + stmt = {'param': 'LN', 'name': 'test'} + lnp = LNParamStmt.from_dict(stmt) + assert_equal(str(lnp), '') def test_comment_stmt(): """ Test comment statement @@ -270,28 +388,24 @@ def test_comment_stmt(): assert_equal(stmt.type, 'COMMENT') assert_equal(stmt.comment, 'A comment') - def test_comment_stmt_dump(): """ Test CommentStmt to_gerber() """ stmt = CommentStmt('A comment') assert_equal(stmt.to_gerber(), 'G04A comment*') - def test_eofstmt(): """ Test EofStmt """ stmt = EofStmt() assert_equal(stmt.type, 'EOF') - def test_eofstmt_dump(): """ Test EofStmt to_gerber() """ stmt = EofStmt() assert_equal(stmt.to_gerber(), 'M02*') - def test_quadmodestmt_factory(): """ Test QuadrantModeStmt.from_gerber() """ @@ -390,12 +504,50 @@ def test_ADParamStmt_factory(): assert_equal(ad.d, 1) assert_equal(ad.shape, 'R') +def test_ADParamStmt_conversion(): + stmt = {'param': 'AD', 'd': 0, 'shape': 'C', 'modifiers': '25.4X25.4,25.4X25.4'} + ad = ADParamStmt.from_dict(stmt) + ad.to_inch() + assert_equal(ad.modifiers[0], (1., 1.)) + assert_equal(ad.modifiers[1], (1., 1.)) + + stmt = {'param': 'AD', 'd': 0, 'shape': 'C', 'modifiers': '1X1,1X1'} + ad = ADParamStmt.from_dict(stmt) + ad.to_metric() + assert_equal(ad.modifiers[0], (25.4, 25.4)) + assert_equal(ad.modifiers[1], (25.4, 25.4)) + +def test_ADParamStmt_dump(): + stmt = {'param': 'AD', 'd': 0, 'shape': 'C'} + ad = ADParamStmt.from_dict(stmt) + assert_equal(ad.to_gerber(), '%ADD0C*%') + stmt = {'param': 'AD', 'd': 0, 'shape': 'C', 'modifiers': '1X1,1X1'} + ad = ADParamStmt.from_dict(stmt) + assert_equal(ad.to_gerber(), '%ADD0C,1X1,1X1*%') + +def test_ADPamramStmt_string(): + stmt = {'param': 'AD', 'd': 0, 'shape': 'C'} + ad = ADParamStmt.from_dict(stmt) + assert_equal(str(ad), '') + + stmt = {'param': 'AD', 'd': 0, 'shape': 'R'} + ad = ADParamStmt.from_dict(stmt) + assert_equal(str(ad), '') + + stmt = {'param': 'AD', 'd': 0, 'shape': 'O'} + ad = ADParamStmt.from_dict(stmt) + assert_equal(str(ad), '') + + stmt = {'param': 'AD', 'd': 0, 'shape': 'test'} + ad = ADParamStmt.from_dict(stmt) + assert_equal(str(ad), '') + def test_MIParamStmt_factory(): stmt = {'param': 'MI', 'a': 1, 'b': 1} mi = MIParamStmt.from_dict(stmt) assert_equal(mi.a, 1) assert_equal(mi.b, 1) - + def test_MIParamStmt_dump(): stmt = {'param': 'MI', 'a': 1, 'b': 1} mi = MIParamStmt.from_dict(stmt) @@ -406,12 +558,12 @@ def test_MIParamStmt_dump(): stmt = {'param': 'MI', 'b': 1} mi = MIParamStmt.from_dict(stmt) assert_equal(mi.to_gerber(), '%MIA0B1*%') - + def test_MIParamStmt_string(): stmt = {'param': 'MI', 'a': 1, 'b': 1} mi = MIParamStmt.from_dict(stmt) assert_equal(str(mi), '') - + stmt = {'param': 'MI', 'b': 1} mi = MIParamStmt.from_dict(stmt) assert_equal(str(mi), '') @@ -430,7 +582,6 @@ def test_coordstmt_ctor(): assert_equal(cs.i, 0.2) assert_equal(cs.j, 0.3) assert_equal(cs.op, 'D01') - - - - \ No newline at end of file + + + From 8f69c1dfa281b6486c8fce16c1d58acef70c7ae7 Mon Sep 17 00:00:00 2001 From: Hamilton Kibbe Date: Thu, 12 Feb 2015 11:28:50 -0500 Subject: [PATCH 031/186] Update line primitive to take aperture parameter This fixes the exception referenced in #12. Still need to add rendering code for rectangle aperture lines and arcs. Rectangle strokes will be drawn as polygons by the rendering backends. --- gerber/primitives.py | 24 ++++++++--------- gerber/render/cairo_backend.py | 20 +++++++++----- gerber/render/svgwrite_backend.py | 20 +++++++++----- gerber/rs274x.py | 6 ++--- gerber/tests/test_primitives.py | 44 +++++++++++++++---------------- 5 files changed, 63 insertions(+), 51 deletions(-) diff --git a/gerber/primitives.py b/gerber/primitives.py index 2d666b8..a239cab 100644 --- a/gerber/primitives.py +++ b/gerber/primitives.py @@ -52,11 +52,11 @@ class Primitive(object): class Line(Primitive): """ """ - def __init__(self, start, end, width, **kwargs): + def __init__(self, start, end, aperture, **kwargs): super(Line, self).__init__(**kwargs) self.start = start self.end = end - self.width = width + self.aperture = aperture @property def angle(self): @@ -64,26 +64,26 @@ class Line(Primitive): angle = math.atan2(delta_y, delta_x) return angle - @property - def bounding_box(self): - width_2 = self.width / 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]) - width_2 - max_y = max(self.start[1], self.end[1]) + width_2 - return ((min_x, max_x), (min_y, max_y)) + #@property + #def bounding_box(self): + # width_2 = self.width / 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]) - width_2 + # max_y = max(self.start[1], self.end[1]) + width_2 + # return ((min_x, max_x), (min_y, max_y)) class Arc(Primitive): """ """ - def __init__(self, start, end, center, direction, width, **kwargs): + def __init__(self, start, end, center, direction, aperture, **kwargs): super(Arc, self).__init__(**kwargs) self.start = start self.end = end self.center = center self.direction = direction - self.width = width + self.aperture = aperture @property def radius(self): diff --git a/gerber/render/cairo_backend.py b/gerber/render/cairo_backend.py index 125a125..c1df87a 100644 --- a/gerber/render/cairo_backend.py +++ b/gerber/render/cairo_backend.py @@ -20,6 +20,8 @@ from operator import mul import cairocffi as cairo import math +from ..primitives import * + SCALE = 400. @@ -48,13 +50,17 @@ class GerberCairoContext(GerberContext): def _render_line(self, line, color): start = map(mul, line.start, self.scale) end = map(mul, line.end, self.scale) - width = line.width if line.width != 0 else 0.001 - self.ctx.set_source_rgba(*color, alpha=self.alpha) - self.ctx.set_line_width(width * SCALE) - self.ctx.set_line_cap(cairo.LINE_CAP_ROUND) - self.ctx.move_to(*start) - self.ctx.line_to(*end) - self.ctx.stroke() + if isinstance(line.aperture, Circle): + width = line.aperture.diameter if line.aperture.diameter != 0 else 0.001 + self.ctx.set_source_rgba(*color, alpha=self.alpha) + self.ctx.set_line_width(width * SCALE) + self.ctx.set_line_cap(cairo.LINE_CAP_ROUND) + self.ctx.move_to(*start) + self.ctx.line_to(*end) + self.ctx.stroke() + elif isinstance(line.aperture, rectangle): + # TODO: Render rectangle strokes as a polygon... + pass def _render_arc(self, arc, color): center = map(mul, arc.center, self.scale) diff --git a/gerber/render/svgwrite_backend.py b/gerber/render/svgwrite_backend.py index 27783d6..279d90f 100644 --- a/gerber/render/svgwrite_backend.py +++ b/gerber/render/svgwrite_backend.py @@ -21,6 +21,8 @@ from operator import mul import math import svgwrite +from ..primitives import * + SCALE = 400. @@ -57,13 +59,17 @@ class GerberSvgContext(GerberContext): def _render_line(self, line, color): start = map(mul, line.start, self.scale) end = map(mul, line.end, self.scale) - width = line.width if line.width != 0 else 0.001 - aline = self.dwg.line(start=start, end=end, - stroke=svg_color(color), - stroke_width=SCALE * width, - stroke_linecap='round') - aline.stroke(opacity=self.alpha) - self.dwg.add(aline) + if isinstance(line.aperture, Circle): + width = line.aperture.diameter if line.aperture.diameter != 0 else 0.001 + aline = self.dwg.line(start=start, end=end, + stroke=svg_color(color), + stroke_width=SCALE * width, + stroke_linecap='round') + aline.stroke(opacity=self.alpha) + self.dwg.add(aline) + elif isinstance(line.aperture, Rectangle): + # TODO: Render rectangle strokes as a polygon... + pass def _render_arc(self, arc, color): start = tuple(map(mul, arc.start, self.scale)) diff --git a/gerber/rs274x.py b/gerber/rs274x.py index abd7366..71ca111 100644 --- a/gerber/rs274x.py +++ b/gerber/rs274x.py @@ -416,12 +416,12 @@ class GerberParser(object): else: start = (self.x, self.y) end = (x, y) - width = self.apertures[self.aperture].stroke_width + #width = self.apertures[self.aperture].stroke_width if self.interpolation == 'linear': - self.primitives.append(Line(start, end, width, level_polarity=self.level_polarity)) + self.primitives.append(Line(start, end, self.apertures[self.aperture], level_polarity=self.level_polarity)) else: center = (start[0] + stmt.i, start[1] + stmt.j) - self.primitives.append(Arc(start, end, center, self.direction, width, level_polarity=self.level_polarity)) + self.primitives.append(Arc(start, end, center, self.direction, self.apertures[self.aperture], level_polarity=self.level_polarity)) elif stmt.op == "D02": pass diff --git a/gerber/tests/test_primitives.py b/gerber/tests/test_primitives.py index 14a3d39..912cebb 100644 --- a/gerber/tests/test_primitives.py +++ b/gerber/tests/test_primitives.py @@ -27,17 +27,17 @@ def test_line_angle(): line_angle = (l.angle + 2 * math.pi) % (2 * math.pi) assert_almost_equal(line_angle, expected) - -def test_line_bounds(): - """ Test Line primitive bounding box calculation - """ - cases = [((0, 0), (1, 1), ((0, 1), (0, 1))), - ((-1, -1), (1, 1), ((-1, 1), (-1, 1))), - ((1, 1), (-1, -1), ((-1, 1), (-1, 1))), - ((-1, 1), (1, -1), ((-1, 1), (-1, 1))),] - for start, end, expected in cases: - l = Line(start, end, 0) - assert_equal(l.bounding_box, expected) +# Need to update bounds calculation using aperture +#def test_line_bounds(): +# """ Test Line primitive bounding box calculation +# """ +# cases = [((0, 0), (1, 1), ((0, 1), (0, 1))), +# ((-1, -1), (1, 1), ((-1, 1), (-1, 1))), +# ((1, 1), (-1, -1), ((-1, 1), (-1, 1))), +# ((-1, 1), (1, -1), ((-1, 1), (-1, 1))),] +# for start, end, expected in cases: +# l = Line(start, end, 0) +# assert_equal(l.bounding_box, expected) def test_arc_radius(): @@ -63,17 +63,17 @@ def test_arc_sweep_angle(): a = Arc(start, end, center, direction, 0) assert_equal(a.sweep_angle, sweep) - -def test_arc_bounds(): - """ Test Arc primitive bounding box calculation - """ - cases = [((1, 0), (0, 1), (0, 0), 'clockwise', ((-1, 1), (-1, 1))), - ((1, 0), (0, 1), (0, 0), 'counterclockwise', ((0, 1), (0, 1))), - #TODO: ADD MORE TEST CASES HERE - ] - for start, end, center, direction, bounds in cases: - a = Arc(start, end, center, direction, 0) - assert_equal(a.bounding_box, bounds) +# Need to update bounds calculation using aperture +#def test_arc_bounds(): +# """ Test Arc primitive bounding box calculation +# """ +# cases = [((1, 0), (0, 1), (0, 0), 'clockwise', ((-1, 1), (-1, 1))), +# ((1, 0), (0, 1), (0, 0), 'counterclockwise', ((0, 1), (0, 1))), +# #TODO: ADD MORE TEST CASES HERE +# ] +# for start, end, center, direction, bounds in cases: +# a = Arc(start, end, center, direction, 0) +# assert_equal(a.bounding_box, bounds) def test_circle_radius(): From 5e23d07bcb5103b4607c6ad591a2a547c97ee1f6 Mon Sep 17 00:00:00 2001 From: Hamilton Kibbe Date: Fri, 13 Feb 2015 09:37:27 -0500 Subject: [PATCH 032/186] Fix rendering for line with rectangular aperture per #12. Still need to do the same for arcs. --- gerber/cam.py | 5 ++- gerber/primitives.py | 72 +++++++++++++++++++++++++++---- gerber/render/cairo_backend.py | 13 ++++-- gerber/render/svgwrite_backend.py | 10 ++++- gerber/tests/test_primitives.py | 35 ++++++++++----- 5 files changed, 108 insertions(+), 27 deletions(-) diff --git a/gerber/cam.py b/gerber/cam.py index 9c731aa..f49f5dd 100644 --- a/gerber/cam.py +++ b/gerber/cam.py @@ -21,7 +21,7 @@ CAM File This module provides common base classes for Excellon/Gerber CNC files """ - +from operator import mul class FileSettings(object): """ CAM File Settings @@ -241,7 +241,8 @@ class CamFile(object): filename : string If provided, save the rendered image to `filename` """ - ctx.set_bounds(self.bounds) + bounds = [tuple([x * 1.2, y*1.2]) for x, y in self.bounds] + ctx.set_bounds(bounds) for p in self.primitives: ctx.render(p) if filename is not None: diff --git a/gerber/primitives.py b/gerber/primitives.py index a239cab..1663a53 100644 --- a/gerber/primitives.py +++ b/gerber/primitives.py @@ -64,14 +64,70 @@ class Line(Primitive): angle = math.atan2(delta_y, delta_x) return angle - #@property - #def bounding_box(self): - # width_2 = self.width / 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]) - width_2 - # max_y = max(self.start[1], self.end[1]) + width_2 - # return ((min_x, max_x), (min_y, max_y)) + @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, max_x), (min_y, max_y)) + + @property + def vertices(self): + if not isinstance(self.aperture, Rectangle): + return None + else: + start = self.start + end = self.end + 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.)) + + if end[0] == start[0] and end[1] == start[1]: + return (start_ll, start_lr, start_ur, start_ul) + elif end[0] == start[0] and end[1] > start[1]: + return (start_ll, start_lr, end_ur, end_ul) + elif end[0] > start[0] and end[1] > start[1]: + return (start_ll, start_lr, end_lr, end_ur, end_ul, start_ul) + elif end[0] > start[0] and end[1] == start[1]: + return (start_ll, end_lr, end_ur, start_ul) + elif end[0] > start[0] and end[1] < start[1]: + return (start_ll, end_ll, end_lr, end_ur, start_ur, start_ul) + elif end[0] == start[0] and end[1] < start[1]: + return (end_ll, end_lr, start_ur, start_ul) + elif end[0] < start[0] and end[1] < start[1]: + return (end_ll, end_lr, start_lr, start_ur, start_ul, end_ul) + elif end[0] < start[0] and end[1] == start[1]: + return (end_ll, start_lr, start_ur, end_ul) + elif end[0] < start[0] and end[1] > start[1]: + return (start_ll, start_lr, start_ur, end_ur, end_ul, end_ll) + else: + return None + + class Arc(Primitive): diff --git a/gerber/render/cairo_backend.py b/gerber/render/cairo_backend.py index c1df87a..999269b 100644 --- a/gerber/render/cairo_backend.py +++ b/gerber/render/cairo_backend.py @@ -26,7 +26,7 @@ SCALE = 400. class GerberCairoContext(GerberContext): - def __init__(self, surface=None, size=(1000, 1000)): + def __init__(self, surface=None, size=(10000, 10000)): GerberContext.__init__(self) if surface is None: self.surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, @@ -58,9 +58,14 @@ class GerberCairoContext(GerberContext): self.ctx.move_to(*start) self.ctx.line_to(*end) self.ctx.stroke() - elif isinstance(line.aperture, rectangle): - # TODO: Render rectangle strokes as a polygon... - pass + elif isinstance(line.aperture, Rectangle): + points = [tuple(map(mul, x, self.scale)) for x in line.vertices] + self.ctx.set_source_rgba(*color, alpha=self.alpha) + self.ctx.set_line_width(0) + self.ctx.move_to(*points[0]) + for point in points[1:]: + self.ctx.line_to(*point) + self.ctx.fill() def _render_arc(self, arc, color): center = map(mul, arc.center, self.scale) diff --git a/gerber/render/svgwrite_backend.py b/gerber/render/svgwrite_backend.py index 279d90f..9e6a5e4 100644 --- a/gerber/render/svgwrite_backend.py +++ b/gerber/render/svgwrite_backend.py @@ -68,8 +68,14 @@ class GerberSvgContext(GerberContext): aline.stroke(opacity=self.alpha) self.dwg.add(aline) elif isinstance(line.aperture, Rectangle): - # TODO: Render rectangle strokes as a polygon... - pass + points = [tuple(map(mul, point, self.scale)) for point in line.vertices] + path = self.dwg.path(d='M %f, %f' % points[0], + fill=svg_color(color), + stroke='none') + path.fill(opacity=self.alpha) + for point in points[1:]: + path.push('L %f, %f' % point) + self.dwg.add(path) def _render_arc(self, arc, color): start = tuple(map(mul, arc.start, self.scale)) diff --git a/gerber/tests/test_primitives.py b/gerber/tests/test_primitives.py index 912cebb..877823d 100644 --- a/gerber/tests/test_primitives.py +++ b/gerber/tests/test_primitives.py @@ -27,17 +27,30 @@ def test_line_angle(): line_angle = (l.angle + 2 * math.pi) % (2 * math.pi) assert_almost_equal(line_angle, expected) -# Need to update bounds calculation using aperture -#def test_line_bounds(): -# """ Test Line primitive bounding box calculation -# """ -# cases = [((0, 0), (1, 1), ((0, 1), (0, 1))), -# ((-1, -1), (1, 1), ((-1, 1), (-1, 1))), -# ((1, 1), (-1, -1), ((-1, 1), (-1, 1))), -# ((-1, 1), (1, -1), ((-1, 1), (-1, 1))),] -# for start, end, expected in cases: -# l = Line(start, end, 0) -# assert_equal(l.bounding_box, expected) +def test_line_bounds(): + """ Test Line primitive bounding box calculation + """ + cases = [((0, 0), (1, 1), ((-1, 2), (-1, 2))), + ((-1, -1), (1, 1), ((-2, 2), (-2, 2))), + ((1, 1), (-1, -1), ((-2, 2), (-2, 2))), + ((-1, 1), (1, -1), ((-2, 2), (-2, 2))),] + + c = Circle((0, 0), 2) + r = Rectangle((0, 0), 2, 2) + for shape in (c, r): + for start, end, expected in cases: + l = Line(start, end, shape) + assert_equal(l.bounding_box, expected) + # Test a non-square rectangle + r = Rectangle((0, 0), 3, 2) + cases = [((0, 0), (1, 1), ((-1.5, 2.5), (-1, 2))), + ((-1, -1), (1, 1), ((-2.5, 2.5), (-2, 2))), + ((1, 1), (-1, -1), ((-2.5, 2.5), (-2, 2))), + ((-1, 1), (1, -1), ((-2.5, 2.5), (-2, 2))),] + for start, end, expected in cases: + l = Line(start, end, r) + assert_equal(l.bounding_box, expected) + def test_arc_radius(): From 5cf1fa74b42eb8feaab23078bef6f31f6d647c33 Mon Sep 17 00:00:00 2001 From: Hamilton Kibbe Date: Sun, 15 Feb 2015 02:20:02 -0500 Subject: [PATCH 033/186] Tests and bugfixes --- gerber/cam.py | 7 +- gerber/excellon.py | 29 +++-- gerber/excellon_statements.py | 59 ++++++++--- gerber/gerber_statements.py | 10 +- gerber/primitives.py | 8 +- gerber/render/svgwrite_backend.py | 2 +- gerber/tests/test_excellon.py | 112 +++++++++++++++++++- gerber/tests/test_excellon_statements.py | 129 +++++++++++++++++++++-- gerber/tests/test_gerber_statements.py | 71 ++++++++++++- gerber/tests/test_primitives.py | 25 ++--- 10 files changed, 391 insertions(+), 61 deletions(-) diff --git a/gerber/cam.py b/gerber/cam.py index f49f5dd..caca517 100644 --- a/gerber/cam.py +++ b/gerber/cam.py @@ -21,7 +21,6 @@ CAM File This module provides common base classes for Excellon/Gerber CNC files """ -from operator import mul class FileSettings(object): """ CAM File Settings @@ -62,14 +61,14 @@ class FileSettings(object): if units not in ['inch', 'metric']: raise ValueError('Units must be either inch or metric') self.units = units - + if zero_suppression is None and zeros is None: self.zero_suppression = 'trailing' - + elif zero_suppression == zeros: raise ValueError('Zeros and Zero Suppression must be different. \ Best practice is to specify only one.') - + elif zero_suppression is not None: if zero_suppression not in ['leading', 'trailing']: raise ValueError('Zero suppression must be either leading or \ diff --git a/gerber/excellon.py b/gerber/excellon.py index 79a6e1f..87eaf03 100755 --- a/gerber/excellon.py +++ b/gerber/excellon.py @@ -95,11 +95,27 @@ class ExcellonFile(CamFile): ymax = max(y + radius, ymax) return ((xmin, xmax), (ymin, ymax)) - def report(self): - """ Print drill report + def report(self, filename=None): + """ Print or save drill report """ - pass - + toolfmt = ' T%%02d %%%d.%df %%d\n' % self.settings.format + rprt = 'Excellon Drill Report\n\n' + if self.filename is not None: + rprt += 'NC Drill File: %s\n\n' % self.filename + rprt += 'Drill File Info:\n\n' + rprt += (' Data Mode %s\n' % 'Absolute' + if self.settings.notation == 'absolute' else 'Incremental') + rprt += (' Units %s\n' % 'Inches' + if self.settings.units == 'inch' else 'Millimeters') + rprt += '\nTool List:\n\n' + rprt += ' Code Size Hits\n' + rprt += ' --------------------------\n' + for tool in self.tools.itervalues(): + rprt += toolfmt % (tool.number, tool.diameter, tool.hit_count) + if filename is not None: + with open(filename, 'w') as f: + f.write(rprt) + return rprt def write(self, filename): with open(filename, 'w') as f: @@ -195,7 +211,7 @@ class ExcellonParser(object): self.state = 'DRILL' elif line[:3] == 'M30': - stmt = EndOfProgramStmt.from_excellon(line) + stmt = EndOfProgramStmt.from_excellon(line, self._settings()) self.statements.append(stmt) elif line[:3] == 'G00': @@ -230,8 +246,9 @@ class ExcellonParser(object): stmt = FormatStmt.from_excellon(line) self.statements.append(stmt) - elif line[:4] == 'G90': + elif line[:3] == 'G90': self.statements.append(AbsoluteModeStmt()) + self.notation = 'absolute' elif line[0] == 'T' and self.state == 'HEADER': tool = ExcellonTool.from_excellon(line, self._settings()) diff --git a/gerber/excellon_statements.py b/gerber/excellon_statements.py index 71009d8..a56c4a5 100644 --- a/gerber/excellon_statements.py +++ b/gerber/excellon_statements.py @@ -29,7 +29,7 @@ __all__ = ['ExcellonTool', 'ToolSelectionStmt', 'CoordinateStmt', 'RewindStopStmt', 'EndOfProgramStmt', 'UnitStmt', 'IncrementalModeStmt', 'VersionStmt', 'FormatStmt', 'LinkToolStmt', 'MeasuringModeStmt', 'RouteModeStmt', 'DrillModeStmt', 'AbsoluteModeStmt', - 'RepeatHoleStmt', 'UnknownStmt', + 'RepeatHoleStmt', 'UnknownStmt', 'ExcellonStatement' ] @@ -38,10 +38,10 @@ class ExcellonStatement(object): """ @classmethod def from_excellon(cls, line): - pass + raise NotImplementedError('`from_excellon` must be implemented in a subclass') def to_excellon(self, settings=None): - pass + raise NotImplementedError('`to_excellon` must be implemented in a subclass') class ExcellonTool(ExcellonStatement): @@ -144,7 +144,7 @@ class ExcellonTool(ExcellonStatement): tool : ExcellonTool An ExcellonTool initialized with the parameters in tool_dict. """ - return cls(settings, tool_dict) + return cls(settings, **tool_dict) def __init__(self, settings, **kwargs): self.settings = settings @@ -159,7 +159,7 @@ class ExcellonTool(ExcellonStatement): def to_excellon(self, settings=None): fmt = self.settings.format - zs = self.settings.format + zs = self.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) @@ -171,7 +171,7 @@ class ExcellonTool(ExcellonStatement): if self.rpm < 100000.: stmt += 'S%s' % write_gerber_value(self.rpm / 1000., fmt, zs) else: - stmt += 'S%g' % self.rpm / 1000. + 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: @@ -191,7 +191,8 @@ class ExcellonTool(ExcellonStatement): def __repr__(self): unit = 'in.' if self.settings.units == 'inch' else 'mm' - return '' % (self.number, self.diameter, unit) + fmtstr = '' % self.settings.format + return fmtstr % (self.number, self.diameter, unit) class ToolSelectionStmt(ExcellonStatement): @@ -273,9 +274,9 @@ class CoordinateStmt(ExcellonStatement): def __str__(self): coord_str = '' if self.x is not None: - coord_str += 'X: %f ' % self.x + coord_str += 'X: %g ' % self.x if self.y is not None: - coord_str += 'Y: %f ' % self.y + coord_str += 'Y: %g ' % self.y return '' % coord_str @@ -284,16 +285,32 @@ class RepeatHoleStmt(ExcellonStatement): @classmethod def from_excellon(cls, line, settings): - return cls(line) + match = re.compile(r'R(?P[0-9]*)X?(?P\d*\.?\d*)?Y?(?P\d*\.?\d*)?').match(line) + stmt = match.groupdict() + count = int(stmt['rcount']) + xdelta = (parse_gerber_value(stmt['xdelta'], settings.format, + settings.zero_suppression) + if stmt['xdelta'] is not '' else None) + ydelta = (parse_gerber_value(stmt['ydelta'], settings.format, + settings.zero_suppression) + if stmt['ydelta'] is not '' else None) + return cls(count, xdelta, ydelta) - def __init__(self, line): - self.line = line + def __init__(self, count, xdelta=None, ydelta=None): + self.count = count + self.xdelta = xdelta + self.ydelta = ydelta def to_excellon(self, settings): - return self.line + stmt = 'R%d' % self.count + if self.xdelta is not None: + stmt += 'X%s' % write_gerber_value(self.xdelta, settings.format, settings.zero_suppression) + if self.ydelta is not None: + stmt += 'Y%s' % write_gerber_value(self.ydelta, settings.format, settings.zero_suppression) + return stmt def __str__(self): - return '' % self.line + return '' % self.count class CommentStmt(ExcellonStatement): @@ -339,8 +356,16 @@ class RewindStopStmt(ExcellonStatement): class EndOfProgramStmt(ExcellonStatement): @classmethod - def from_excellon(cls, line): - return cls() + def from_excellon(cls, line, settings): + 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) + if stmt['x'] is not '' else None) + y = (parse_gerber_value(stmt['y'], settings.format, + settings.zero_suppression) + if stmt['y'] is not '' else None) + return cls(x, y) def __init__(self, x=None, y=None): self.x = x @@ -495,7 +520,7 @@ class UnknownStmt(ExcellonStatement): return self.stmt def __str__(self): - return "" % self.stmt + return "" % self.stmt def pairwise(iterator): diff --git a/gerber/gerber_statements.py b/gerber/gerber_statements.py index 1401345..a6feef6 100644 --- a/gerber/gerber_statements.py +++ b/gerber/gerber_statements.py @@ -802,13 +802,13 @@ class CoordStmt(Statement): if self.function: coord_str += 'Fn: %s ' % self.function if self.x is not None: - coord_str += 'X: %f ' % self.x + coord_str += 'X: %g ' % self.x if self.y is not None: - coord_str += 'Y: %f ' % self.y + coord_str += 'Y: %g ' % self.y if self.i is not None: - coord_str += 'I: %f ' % self.i + coord_str += 'I: %g ' % self.i if self.j is not None: - coord_str += 'J: %f ' % self.j + coord_str += 'J: %g ' % self.j if self.op: if self.op == 'D01': op = 'Lights On' @@ -829,7 +829,7 @@ class ApertureStmt(Statement): def __init__(self, d, deprecated=None): Statement.__init__(self, "APERTURE") self.d = int(d) - self.deprecated = True if deprecated is not None else False + self.deprecated = True if deprecated is not None and deprecated is not False else False def to_gerber(self, settings=None): if self.deprecated: diff --git a/gerber/primitives.py b/gerber/primitives.py index 1663a53..ffdbea7 100644 --- a/gerber/primitives.py +++ b/gerber/primitives.py @@ -200,10 +200,10 @@ class Arc(Primitive): if theta1 <= math.pi * 1.5 and (theta0 >= math.pi * 1.5 or theta0 < theta1): points.append((self.center[0], self.center[1] - self.radius )) x, y = zip(*points) - min_x = min(x) - max_x = max(x) - min_y = min(y) - max_y = max(y) + 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 return ((min_x, max_x), (min_y, max_y)) diff --git a/gerber/render/svgwrite_backend.py b/gerber/render/svgwrite_backend.py index 9e6a5e4..ae7c377 100644 --- a/gerber/render/svgwrite_backend.py +++ b/gerber/render/svgwrite_backend.py @@ -89,7 +89,7 @@ class GerberSvgContext(GerberContext): direction = '-' if arc.direction == 'clockwise' else '+' arc_path.push_arc(end, 0, radius, large_arc, direction, True) self.dwg.add(arc_path) - + def _render_region(self, region, color): points = [tuple(map(mul, point, self.scale)) for point in region.points] region_path = self.dwg.path(d='M %f, %f' % points[0], diff --git a/gerber/tests/test_excellon.py b/gerber/tests/test_excellon.py index 70e4560..de45b44 100644 --- a/gerber/tests/test_excellon.py +++ b/gerber/tests/test_excellon.py @@ -2,7 +2,8 @@ # -*- coding: utf-8 -*- # Author: Hamilton Kibbe -from ..excellon import read, detect_excellon_format, ExcellonFile +from ..cam import FileSettings +from ..excellon import read, detect_excellon_format, ExcellonFile, ExcellonParser from tests import * import os @@ -25,3 +26,112 @@ def test_read_settings(): ncdrill = read(NCDRILL_FILE) assert_equal(ncdrill.settings['format'], (2, 4)) assert_equal(ncdrill.settings['zeros'], 'trailing') + +def test_bounds(): + ncdrill = read(NCDRILL_FILE) + xbound, ybound = ncdrill.bounds + assert_array_almost_equal(xbound, (0.1300, 2.1430)) + assert_array_almost_equal(ybound, (0.3946, 1.7164)) + +def test_report(): + ncdrill = read(NCDRILL_FILE) + +def test_parser_hole_count(): + settings = FileSettings(**detect_excellon_format(NCDRILL_FILE)) + p = ExcellonParser(settings) + p.parse(NCDRILL_FILE) + assert_equal(p.hole_count, 36) + +def test_parser_hole_sizes(): + settings = FileSettings(**detect_excellon_format(NCDRILL_FILE)) + p = ExcellonParser(settings) + p.parse(NCDRILL_FILE) + assert_equal(p.hole_sizes, [0.0236, 0.0354, 0.04, 0.126, 0.128]) + +def test_parse_whitespace(): + p = ExcellonParser(FileSettings()) + assert_equal(p._parse(' '), None) + +def test_parse_comment(): + p = ExcellonParser(FileSettings()) + p._parse(';A comment') + assert_equal(p.statements[0].comment, 'A comment') + +def test_parse_format_comment(): + p = ExcellonParser(FileSettings()) + p._parse('; FILE_FORMAT=9:9 ') + assert_equal(p.format, (9, 9)) + +def test_parse_header(): + p = ExcellonParser(FileSettings()) + p._parse('M48 ') + assert_equal(p.state, 'HEADER') + p._parse('M95 ') + assert_equal(p.state, 'DRILL') + +def test_parse_rout(): + p = ExcellonParser(FileSettings()) + p._parse('G00 ') + assert_equal(p.state, 'ROUT') + p._parse('G05 ') + assert_equal(p.state, 'DRILL') + +def test_parse_version(): + p = ExcellonParser(FileSettings()) + p._parse('VER,1 ') + assert_equal(p.statements[0].version, 1) + p._parse('VER,2 ') + assert_equal(p.statements[1].version, 2) + +def test_parse_format(): + p = ExcellonParser(FileSettings()) + p._parse('FMAT,1 ') + assert_equal(p.statements[0].format, 1) + p._parse('FMAT,2 ') + assert_equal(p.statements[1].format, 2) + +def test_parse_units(): + settings = FileSettings(units='inch', zeros='trailing') + p = ExcellonParser(settings) + p._parse(';METRIC,LZ') + assert_equal(p.units, 'inch') + assert_equal(p.zeros, 'trailing') + p._parse('METRIC,LZ') + assert_equal(p.units, 'metric') + assert_equal(p.zeros, 'leading') + +def test_parse_incremental_mode(): + settings = FileSettings(units='inch', zeros='trailing') + p = ExcellonParser(settings) + assert_equal(p.notation, 'absolute') + p._parse('ICI,ON ') + assert_equal(p.notation, 'incremental') + p._parse('ICI,OFF ') + assert_equal(p.notation, 'absolute') + +def test_parse_absolute_mode(): + settings = FileSettings(units='inch', zeros='trailing') + p = ExcellonParser(settings) + assert_equal(p.notation, 'absolute') + p._parse('ICI,ON ') + assert_equal(p.notation, 'incremental') + p._parse('G90 ') + assert_equal(p.notation, 'absolute') + +def test_parse_repeat_hole(): + p = ExcellonParser(FileSettings()) + p._parse('R03X1.5Y1.5') + assert_equal(p.statements[0].count, 3) + +def test_parse_incremental_position(): + p = ExcellonParser(FileSettings(notation='incremental')) + p._parse('X01Y01') + p._parse('X01Y01') + assert_equal(p.pos, [2.,2.]) + +def test_parse_unknown(): + p = ExcellonParser(FileSettings()) + p._parse('Not A Valid Statement') + assert_equal(p.statements[0].stmt, 'Not A Valid Statement') + + diff --git a/gerber/tests/test_excellon_statements.py b/gerber/tests/test_excellon_statements.py index 2e508ff..35bd045 100644 --- a/gerber/tests/test_excellon_statements.py +++ b/gerber/tests/test_excellon_statements.py @@ -7,17 +7,36 @@ from .tests import assert_equal, assert_raises from ..excellon_statements import * from ..cam import FileSettings +def test_excellon_statement_implementation(): + stmt = ExcellonStatement() + assert_raises(NotImplementedError, stmt.from_excellon, None) + assert_raises(NotImplementedError, stmt.to_excellon) def test_excellontool_factory(): - """ Test ExcellonTool factory method + """ Test ExcellonTool factory methods """ - exc_line = 'T8F00S00C0.12500' + exc_line = 'T8F01B02S00003H04Z05C0.12500' settings = FileSettings(format=(2, 5), zero_suppression='trailing', units='inch', notation='absolute') tool = ExcellonTool.from_excellon(exc_line, settings) + assert_equal(tool.number, 8) assert_equal(tool.diameter, 0.125) - assert_equal(tool.feed_rate, 0) - assert_equal(tool.rpm, 0) + assert_equal(tool.feed_rate, 1) + assert_equal(tool.retract_rate,2) + assert_equal(tool.rpm, 3) + assert_equal(tool.max_hit_count, 4) + assert_equal(tool.depth_offset, 5) + + stmt = {'number': 8, 'feed_rate': 1, 'retract_rate': 2, 'rpm': 3, + 'diameter': 0.125, 'max_hit_count': 4, 'depth_offset': 5} + tool = ExcellonTool.from_dict(settings, stmt) + assert_equal(tool.number, 8) + assert_equal(tool.diameter, 0.125) + assert_equal(tool.feed_rate, 1) + assert_equal(tool.retract_rate,2) + assert_equal(tool.rpm, 3) + assert_equal(tool.max_hit_count, 4) + assert_equal(tool.depth_offset, 5) def test_excellontool_dump(): @@ -25,7 +44,8 @@ def test_excellontool_dump(): """ exc_lines = ['T01F0S0C0.01200', 'T02F0S0C0.01500', 'T03F0S0C0.01968', 'T04F0S0C0.02800', 'T05F0S0C0.03300', 'T06F0S0C0.03800', - 'T07F0S0C0.04300', 'T08F0S0C0.12500', 'T09F0S0C0.13000', ] + 'T07F0S0C0.04300', 'T08F0S0C0.12500', 'T09F0S0C0.13000', + 'T08B01F02H03S00003C0.12500Z04', 'T01F0S300.999C0.01200'] settings = FileSettings(format=(2, 5), zero_suppression='trailing', units='inch', notation='absolute') for line in exc_lines: @@ -44,6 +64,19 @@ def test_excellontool_order(): assert_equal(tool1.feed_rate, tool2.feed_rate) assert_equal(tool1.rpm, tool2.rpm) +def test_excellontool_conversion(): + tool = ExcellonTool.from_dict(FileSettings(), {'number': 8, 'diameter': 25.4}) + tool.to_inch() + assert_equal(tool.diameter, 1.) + tool = ExcellonTool.from_dict(FileSettings(), {'number': 8, 'diameter': 1}) + tool.to_metric() + assert_equal(tool.diameter, 25.4) + +def test_excellontool_repr(): + tool = ExcellonTool.from_dict(FileSettings(), {'number': 8, 'diameter': 0.125}) + assert_equal(str(tool), '') + tool = ExcellonTool.from_dict(FileSettings(units='metric'), {'number': 8, 'diameter': 0.125}) + assert_equal(str(tool), '') def test_toolselection_factory(): """ Test ToolSelectionStmt factory method @@ -93,22 +126,49 @@ def test_coordinatestmt_factory(): assert_equal(stmt.y, 0.4639) assert_equal(stmt.to_excellon(settings), "X9660Y4639") - - def test_coordinatestmt_dump(): """ Test CoordinateStmt to_excellon() """ lines = ['X278207Y65293', 'X243795', 'Y82528', 'Y86028', 'X251295Y81528', 'X2525Y78', 'X255Y575', 'Y52', 'X2675', 'Y575', 'X2425', 'Y52', 'X23', ] - settings = FileSettings(format=(2, 4), zero_suppression='leading', units='inch', notation='absolute') - for line in lines: stmt = CoordinateStmt.from_excellon(line, settings) assert_equal(stmt.to_excellon(settings), line) +def test_coordinatestmt_conversion(): + stmt = CoordinateStmt.from_excellon('X254Y254', FileSettings()) + stmt.to_inch() + assert_equal(stmt.x, 1.) + assert_equal(stmt.y, 1.) + stmt = CoordinateStmt.from_excellon('X01Y01', FileSettings()) + stmt.to_metric() + assert_equal(stmt.x, 25.4) + assert_equal(stmt.y, 25.4) + +def test_coordinatestmt_string(): + settings = FileSettings(format=(2, 4), zero_suppression='leading', + units='inch', notation='absolute') + stmt = CoordinateStmt.from_excellon('X9660Y4639', settings) + assert_equal(str(stmt), '') + + +def test_repeathole_stmt_factory(): + stmt = RepeatHoleStmt.from_excellon('R0004X015Y32', FileSettings(zeros='leading')) + assert_equal(stmt.count, 4) + assert_equal(stmt.xdelta, 1.5) + assert_equal(stmt.ydelta, 32) + +def test_repeatholestmt_dump(): + line = 'R4X015Y32' + stmt = RepeatHoleStmt.from_excellon(line, FileSettings()) + assert_equal(stmt.to_excellon(FileSettings()), line) + +def test_repeathole_str(): + stmt = RepeatHoleStmt.from_excellon('R4X015Y32', FileSettings()) + assert_equal(str(stmt), '') def test_commentstmt_factory(): """ Test CommentStmt factory method @@ -134,6 +194,35 @@ def test_commentstmt_dump(): stmt = CommentStmt.from_excellon(line) assert_equal(stmt.to_excellon(), line) +def test_header_begin_stmt(): + stmt = HeaderBeginStmt() + assert_equal(stmt.to_excellon(None), 'M48') + +def test_header_end_stmt(): + stmt = HeaderEndStmt() + assert_equal(stmt.to_excellon(None), 'M95') + +def test_rewindstop_stmt(): + stmt = RewindStopStmt() + assert_equal(stmt.to_excellon(None), '%') + +def test_endofprogramstmt_factory(): + stmt = EndOfProgramStmt.from_excellon('M30X01Y02', FileSettings()) + assert_equal(stmt.x, 1.) + assert_equal(stmt.y, 2.) + stmt = EndOfProgramStmt.from_excellon('M30X01', FileSettings()) + assert_equal(stmt.x, 1.) + assert_equal(stmt.y, None) + stmt = EndOfProgramStmt.from_excellon('M30Y02', FileSettings()) + assert_equal(stmt.x, None) + assert_equal(stmt.y, 2.) + +def test_endofprogramStmt_dump(): + lines = ['M30X01Y02',] + for line in lines: + stmt = EndOfProgramStmt.from_excellon(line, FileSettings()) + assert_equal(stmt.to_excellon(FileSettings()), line) + def test_unitstmt_factory(): """ Test UnitStmt factory method @@ -295,3 +384,25 @@ def test_measmodestmt_validation(): """ assert_raises(ValueError, MeasuringModeStmt.from_excellon, 'M70') assert_raises(ValueError, MeasuringModeStmt, 'millimeters') + + +def test_routemode_stmt(): + stmt = RouteModeStmt() + assert_equal(stmt.to_excellon(FileSettings()), 'G00') + +def test_drillmode_stmt(): + stmt = DrillModeStmt() + assert_equal(stmt.to_excellon(FileSettings()), 'G05') + +def test_absolutemode_stmt(): + stmt = AbsoluteModeStmt() + assert_equal(stmt.to_excellon(FileSettings()), 'G90') + +def test_unknownstmt(): + stmt = UnknownStmt('TEST') + assert_equal(stmt.stmt, 'TEST') + assert_equal(str(stmt), '') + +def test_unknownstmt_dump(): + stmt = UnknownStmt('TEST') + assert_equal(stmt.to_excellon(FileSettings()), 'TEST') diff --git a/gerber/tests/test_gerber_statements.py b/gerber/tests/test_gerber_statements.py index 0875b57..c6040c0 100644 --- a/gerber/tests/test_gerber_statements.py +++ b/gerber/tests/test_gerber_statements.py @@ -394,6 +394,10 @@ def test_comment_stmt_dump(): stmt = CommentStmt('A comment') assert_equal(stmt.to_gerber(), 'G04A comment*') +def test_comment_stmt_string(): + stmt = CommentStmt('A comment') + assert_equal(str(stmt), '') + def test_eofstmt(): """ Test EofStmt """ @@ -406,6 +410,9 @@ def test_eofstmt_dump(): stmt = EofStmt() assert_equal(stmt.to_gerber(), 'M02*') +def test_eofstmt_string(): + assert_equal(str(EofStmt()), '') + def test_quadmodestmt_factory(): """ Test QuadrantModeStmt.from_gerber() """ @@ -572,8 +579,6 @@ def test_MIParamStmt_string(): mi = MIParamStmt.from_dict(stmt) assert_equal(str(mi), '') - - def test_coordstmt_ctor(): cs = CoordStmt('G04', 0.0, 0.1, 0.2, 0.3, 'D01', FileSettings()) assert_equal(cs.function, 'G04') @@ -583,5 +588,67 @@ def test_coordstmt_ctor(): assert_equal(cs.j, 0.3) assert_equal(cs.op, 'D01') +def test_coordstmt_factory(): + stmt = {'function': 'G04', 'x': '0', 'y': '001', 'i': '002', 'j': '003', 'op': 'D01'} + cs = CoordStmt.from_dict(stmt, FileSettings()) + assert_equal(cs.function, 'G04') + assert_equal(cs.x, 0.0) + assert_equal(cs.y, 0.1) + assert_equal(cs.i, 0.2) + assert_equal(cs.j, 0.3) + assert_equal(cs.op, 'D01') + +def test_coordstmt_dump(): + cs = CoordStmt('G04', 0.0, 0.1, 0.2, 0.3, 'D01', FileSettings()) + assert_equal(cs.to_gerber(FileSettings()), 'G04X0Y001I002J003D01*') + +def test_coordstmt_conversion(): + cs = CoordStmt('G71', 25.4, 25.4, 25.4, 25.4, 'D01', FileSettings()) + cs.to_inch() + assert_equal(cs.x, 1.) + assert_equal(cs.y, 1.) + assert_equal(cs.i, 1.) + assert_equal(cs.j, 1.) + assert_equal(cs.function, 'G70') + + cs = CoordStmt('G70', 1., 1., 1., 1., 'D01', FileSettings()) + cs.to_metric() + assert_equal(cs.x, 25.4) + assert_equal(cs.y, 25.4) + assert_equal(cs.i, 25.4) + assert_equal(cs.j, 25.4) + assert_equal(cs.function, 'G71') + +def test_coordstmt_string(): + cs = CoordStmt('G04', 0, 1, 2, 3, 'D01', FileSettings()) + assert_equal(str(cs), '') + cs = CoordStmt('G04', None, None, None, None, 'D02', FileSettings()) + assert_equal(str(cs), '') + cs = CoordStmt('G04', None, None, None, None, 'D03', FileSettings()) + assert_equal(str(cs), '') + cs = CoordStmt('G04', None, None, None, None, 'TEST', FileSettings()) + assert_equal(str(cs), '') + +def test_aperturestmt_ctor(): + ast = ApertureStmt(3, False) + assert_equal(ast.d, 3) + assert_equal(ast.deprecated, False) + ast = ApertureStmt(4, True) + assert_equal(ast.d, 4) + assert_equal(ast.deprecated, True) + ast = ApertureStmt(4, 1) + assert_equal(ast.d, 4) + assert_equal(ast.deprecated, True) + ast = ApertureStmt(3) + assert_equal(ast.d, 3) + assert_equal(ast.deprecated, False) + +def test_aperturestmt_dump(): + ast = ApertureStmt(3, False) + assert_equal(ast.to_gerber(), 'D3*') + ast = ApertureStmt(3, True) + assert_equal(ast.to_gerber(), 'G54D3*') + assert_equal(str(ast), '') + \ No newline at end of file diff --git a/gerber/tests/test_primitives.py b/gerber/tests/test_primitives.py index 877823d..f8b8620 100644 --- a/gerber/tests/test_primitives.py +++ b/gerber/tests/test_primitives.py @@ -73,20 +73,21 @@ def test_arc_sweep_angle(): ((1, 0), (-1, 0), (0, 0), 'counterclockwise', math.radians(180)),] for start, end, center, direction, sweep in cases: - a = Arc(start, end, center, direction, 0) + c = Circle((0,0), 1) + a = Arc(start, end, center, direction, c) assert_equal(a.sweep_angle, sweep) -# Need to update bounds calculation using aperture -#def test_arc_bounds(): -# """ Test Arc primitive bounding box calculation -# """ -# cases = [((1, 0), (0, 1), (0, 0), 'clockwise', ((-1, 1), (-1, 1))), -# ((1, 0), (0, 1), (0, 0), 'counterclockwise', ((0, 1), (0, 1))), -# #TODO: ADD MORE TEST CASES HERE -# ] -# for start, end, center, direction, bounds in cases: -# a = Arc(start, end, center, direction, 0) -# assert_equal(a.bounding_box, bounds) +def test_arc_bounds(): + """ Test Arc primitive bounding box calculation + """ + cases = [((1, 0), (0, 1), (0, 0), 'clockwise', ((-1.5, 1.5), (-1.5, 1.5))), + ((1, 0), (0, 1), (0, 0), 'counterclockwise', ((-0.5, 1.5), (-0.5, 1.5))), + #TODO: ADD MORE TEST CASES HERE + ] + for start, end, center, direction, bounds in cases: + c = Circle((0,0), 1) + a = Arc(start, end, center, direction, c) + assert_equal(a.bounding_box, bounds) def test_circle_radius(): From bfe14841604b6be403e7123e8b6667b1f0aff6f6 Mon Sep 17 00:00:00 2001 From: Hamilton Kibbe Date: Sun, 15 Feb 2015 03:29:47 -0500 Subject: [PATCH 034/186] Add cairo example code, and use example-generated image in readme --- Makefile | 17 +- README.md | 8 +- examples/cairo_example.png | Bin 0 -> 2397907 bytes examples/cairo_example.py | 68 + examples/composite_bottom.png | Bin 146354 -> 0 bytes examples/composite_bottom.svg | 2 - examples/composite_top.png | Bin 292317 -> 0 bytes examples/composite_top.svg | 2 - examples/gerbers/copper.GTL | 3457 +++++++++++++++++++++++++++++++ examples/gerbers/ncdrill.DRD | 51 + examples/gerbers/silkscreen.GTO | 2099 +++++++++++++++++++ examples/gerbers/soldermask.GTS | 162 ++ gerber/excellon.py | 5 + gerber/excellon_statements.py | 6 +- gerber/render/cairo_backend.py | 12 +- gerber/tests/test_excellon.py | 2 + 16 files changed, 5869 insertions(+), 22 deletions(-) create mode 100644 examples/cairo_example.png create mode 100644 examples/cairo_example.py delete mode 100644 examples/composite_bottom.png delete mode 100644 examples/composite_bottom.svg delete mode 100644 examples/composite_top.png delete mode 100644 examples/composite_top.svg create mode 100644 examples/gerbers/copper.GTL create mode 100644 examples/gerbers/ncdrill.DRD create mode 100644 examples/gerbers/silkscreen.GTO create mode 100644 examples/gerbers/soldermask.GTS diff --git a/Makefile b/Makefile index 7de5b4a..8d3fd33 100644 --- a/Makefile +++ b/Makefile @@ -3,25 +3,32 @@ PYTHON ?= python NOSETESTS ?= nosetests DOC_ROOT = doc +EXAMPLES = examples +.PHONY: clean clean: doc-clean - #$(PYTHON) setup.py clean find . -name '*.pyc' -delete rm -rf coverage .coverage rm -rf *.egg-info +.PHONY: test test: $(NOSETESTS) -s -v gerber - + +.PHONY: test-coverage test-coverage: rm -rf coverage .coverage $(NOSETESTS) -s -v --with-coverage --cover-package=gerber - + +.PHONY: doc-html doc-html: (cd $(DOC_ROOT); make html) - +.PHONY: doc-clean doc-clean: (cd $(DOC_ROOT); make clean) - + +.PHONY: examples +examples: + PYTHONPATH=. $(PYTHON) examples/cairo_example.py diff --git a/README.md b/README.md index a2e3118..329d234 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ pcb-tools ============ -![Travis CI Build Status](https://travis-ci.org/curtacircuitos/pcb-tools.svg?branch=master) +[![Travis CI Build Status](https://travis-ci.org/curtacircuitos/pcb-tools.svg?branch=master)](https://travis-ci.org/curtacircuitos/pcb-tools) [![Coverage Status](https://coveralls.io/repos/curtacircuitos/pcb-tools/badge.png?branch=master)](https://coveralls.io/r/curtacircuitos/pcb-tools?branch=master) Tools to handle Gerber and Excellon files in Python. @@ -25,7 +25,5 @@ Useage Example: Rendering Examples: ------------------- ###Top Composite rendering -![Composite Top Image](examples/composite_top.png) - -###Bottom Composite rendering -![Composite Bottom Image](examples/composite_bottom.png) +![Composite Top Image](examples/cairo_example.png) +Source code for this example can be found [here](examples/cairo_example.py). diff --git a/examples/cairo_example.png b/examples/cairo_example.png new file mode 100644 index 0000000000000000000000000000000000000000..d6076b5abffd4cd697a01c67ba9f92c7379b9f1d GIT binary patch literal 2397907 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