summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore6
-rw-r--r--README.md15
-rw-r--r--gerber/am_statements.py224
-rw-r--r--gerber/cam.py12
-rwxr-xr-xgerber/excellon.py346
-rw-r--r--gerber/excellon_report/excellon_drr.py25
-rw-r--r--gerber/excellon_settings.py105
-rw-r--r--gerber/excellon_statements.py236
-rw-r--r--gerber/excellon_tool.py186
-rw-r--r--gerber/gerber_statements.py107
-rw-r--r--gerber/ncparam/allegro.py25
-rw-r--r--gerber/primitives.py437
-rw-r--r--gerber/render/cairo_backend.py141
-rw-r--r--gerber/render/excellon_backend.py189
-rw-r--r--gerber/render/render.py37
-rw-r--r--gerber/render/rs274x_backend.py470
-rw-r--r--gerber/rs274x.py163
-rw-r--r--gerber/tests/golden/example_two_square_boxes.pngbin0 -> 18247 bytes
-rw-r--r--gerber/tests/resources/example_two_square_boxes.gbr19
-rw-r--r--gerber/tests/test_am_statements.py19
-rw-r--r--gerber/tests/test_cairo_backend.py58
-rw-r--r--gerber/tests/test_cam.py11
-rw-r--r--gerber/tests/test_excellon.py5
-rw-r--r--gerber/tests/test_primitives.py67
-rw-r--r--gerber/utils.py23
-rw-r--r--test-requirements.txt1
26 files changed, 2728 insertions, 199 deletions
diff --git a/.gitignore b/.gitignore
index 01ba410..a3ffb1c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -37,10 +37,14 @@ nosetests.xml
.idea/workspace.xml
.idea/misc.xml
.idea
+.settings
# Komodo Files
*.komodoproject
# OS Files
.DS_Store
-Thumbs.db \ No newline at end of file
+Thumbs.db
+
+# Virtual environment
+venv
diff --git a/README.md b/README.md
index 298cf0c..27764be 100644
--- a/README.md
+++ b/README.md
@@ -34,3 +34,18 @@ Source code for this example can be found [here](examples/cairo_example.py).
Documentation:
--------------
[PCB Tools Documentation](http://pcb-tools.readthedocs.org/en/latest/)
+
+
+Development and Testing:
+------------------------
+
+Dependencies for developing and testing pcb-tools are listed in test-requirements.txt. Use of a virtual environment is strongly recommended.
+
+ $ virtualenv venv
+ $ source venv/bin/activate
+ (venv)$ pip install -r test-requirements.txt
+ (venv)$ pip install -e .
+
+We use nose to run pcb-tools's suite of unittests and doctests.
+
+ (venv)$ nosetests
diff --git a/gerber/am_statements.py b/gerber/am_statements.py
index 38f4d71..ed9f71e 100644
--- a/gerber/am_statements.py
+++ b/gerber/am_statements.py
@@ -16,7 +16,10 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-from .utils import validate_coordinates, inch, metric
+import math
+from .utils import validate_coordinates, inch, metric, rotate_point
+from .primitives import Circle, Line, Outline, Polygon, Rectangle
+from math import asin
# TODO: Add support for aperture macro variables
@@ -67,6 +70,18 @@ class AMPrimitive(object):
def to_metric(self):
raise NotImplementedError('Subclass must implement `to-metric`')
+
+ def to_primitive(self, units):
+ """
+ Convert to a primitive, as defines the primitives module (for drawing)
+ """
+ raise NotImplementedError('Subclass must implement `to-primitive`')
+
+ @property
+ def _level_polarity(self):
+ if self.exposure == 'off':
+ return 'clear'
+ return 'dark'
def __eq__(self, other):
return self.__dict__ == other.__dict__
@@ -120,6 +135,12 @@ class AMCommentPrimitive(AMPrimitive):
def to_gerber(self, settings=None):
return '0 %s *' % self.comment
+ def to_primitive(self, units):
+ """
+ Returns None - has not primitive representation
+ """
+ return None
+
def __str__(self):
return '<Aperture Macro Comment: %s>' % self.comment
@@ -164,6 +185,10 @@ class AMCirclePrimitive(AMPrimitive):
diameter = float(modifiers[2])
position = (float(modifiers[3]), float(modifiers[4]))
return cls(code, exposure, diameter, position)
+
+ @classmethod
+ def from_primitive(cls, primitive):
+ return cls(1, 'on', primitive.diameter, primitive.position)
def __init__(self, code, exposure, diameter, position):
validate_coordinates(position)
@@ -189,6 +214,9 @@ class AMCirclePrimitive(AMPrimitive):
y = self.position[1])
return '{code},{exposure},{diameter},{x},{y}*'.format(**data)
+ def to_primitive(self, units):
+ return Circle((self.position), self.diameter, units=units, level_polarity=self._level_polarity)
+
class AMVectorLinePrimitive(AMPrimitive):
""" Aperture Macro Vector Line primitive. Code 2 or 20.
@@ -229,6 +257,11 @@ class AMVectorLinePrimitive(AMPrimitive):
------
ValueError, TypeError
"""
+
+ @classmethod
+ def from_primitive(cls, primitive):
+ return cls(2, 'on', primitive.aperture.width, primitive.start, primitive.end, 0)
+
@classmethod
def from_gerber(cls, primitive):
modifiers = primitive.strip(' *').split(',')
@@ -273,6 +306,22 @@ class AMVectorLinePrimitive(AMPrimitive):
endy = self.end[1],
rotation = self.rotation)
return fmtstr.format(**data)
+
+ def to_primitive(self, units):
+
+ line = Line(self.start, self.end, Rectangle(None, self.width, self.width))
+ vertices = line.vertices
+
+ aperture = Circle((0, 0), 0)
+
+ lines = []
+ prev_point = rotate_point(vertices[-1], self.rotation, (0, 0))
+ for point in vertices:
+ cur_point = rotate_point(point, self.rotation, (0, 0))
+
+ lines.append(Line(prev_point, cur_point, aperture))
+
+ return Outline(lines, units=units, level_polarity=self._level_polarity)
class AMOutlinePrimitive(AMPrimitive):
@@ -313,6 +362,19 @@ class AMOutlinePrimitive(AMPrimitive):
------
ValueError, TypeError
"""
+
+ @classmethod
+ def from_primitive(cls, primitive):
+
+ start_point = (round(primitive.primitives[0].start[0], 6), round(primitive.primitives[0].start[1], 6))
+ points = []
+ for prim in primitive.primitives:
+ points.append((round(prim.end[0], 6), round(prim.end[1], 6)))
+
+ rotation = 0.0
+
+ return cls(4, 'on', start_point, points, rotation)
+
@classmethod
def from_gerber(cls, primitive):
modifiers = primitive.strip(' *').split(",")
@@ -355,11 +417,28 @@ class AMOutlinePrimitive(AMPrimitive):
code=self.code,
exposure="1" if self.exposure == "on" else "0",
n_points=len(self.points),
- start_point="%.4g,%.4g" % self.start_point,
- points=",".join(["%.4g,%.4g" % point for point in self.points]),
+ start_point="%.6g,%.6g" % self.start_point,
+ points=",\n".join(["%.6g,%.6g" % point for point in self.points]),
rotation=str(self.rotation)
)
- return "{code},{exposure},{n_points},{start_point},{points},{rotation}*".format(**data)
+ # TODO I removed a closing asterix - not sure if this works for items with multiple statements
+ return "{code},{exposure},{n_points},{start_point},{points},\n{rotation}*".format(**data)
+
+ def to_primitive(self, units):
+
+ lines = []
+ prev_point = rotate_point(self.start_point, self.rotation)
+ for point in self.points:
+ cur_point = rotate_point(point, self.rotation)
+
+ lines.append(Line(prev_point, cur_point, Circle((0,0), 0)))
+
+ prev_point = cur_point
+
+ if lines[0].start != lines[-1].end:
+ raise ValueError('Outline must be closed')
+
+ return Outline(lines, units=units, level_polarity=self._level_polarity)
class AMPolygonPrimitive(AMPrimitive):
@@ -401,6 +480,11 @@ class AMPolygonPrimitive(AMPrimitive):
------
ValueError, TypeError
"""
+
+ @classmethod
+ def from_primitive(cls, primitive):
+ return cls(5, 'on', primitive.sides, primitive.position, primitive.diameter, primitive.rotation)
+
@classmethod
def from_gerber(cls, primitive):
modifiers = primitive.strip(' *').split(",")
@@ -450,6 +534,9 @@ class AMPolygonPrimitive(AMPrimitive):
)
fmt = "{code},{exposure},{vertices},{position},{diameter},{rotation}*"
return fmt.format(**data)
+
+ def to_primitive(self, units):
+ return Polygon(self.position, self.vertices, self.diameter / 2.0, 0, rotation=math.radians(self.rotation), units=units, level_polarity=self._level_polarity)
class AMMoirePrimitive(AMPrimitive):
@@ -562,6 +649,9 @@ class AMMoirePrimitive(AMPrimitive):
fmt = "{code},{position},{diameter},{ring_thickness},{gap},{max_rings},{crosshair_thickness},{crosshair_length},{rotation}*"
return fmt.format(**data)
+ def to_primitive(self, units):
+ raise NotImplementedError()
+
class AMThermalPrimitive(AMPrimitive):
""" Aperture Macro Thermal primitive. Code 7.
@@ -610,9 +700,10 @@ class AMThermalPrimitive(AMPrimitive):
outer_diameter = float(modifiers[3])
inner_diameter= float(modifiers[4])
gap = float(modifiers[5])
- return cls(code, position, outer_diameter, inner_diameter, gap)
+ rotation = float(modifiers[6])
+ return cls(code, position, outer_diameter, inner_diameter, gap, rotation)
- def __init__(self, code, position, outer_diameter, inner_diameter, gap):
+ def __init__(self, code, position, outer_diameter, inner_diameter, gap, rotation):
if code != 7:
raise ValueError('ThermalPrimitive code is 7')
super(AMThermalPrimitive, self).__init__(code, 'on')
@@ -621,6 +712,7 @@ class AMThermalPrimitive(AMPrimitive):
self.outer_diameter = outer_diameter
self.inner_diameter = inner_diameter
self.gap = gap
+ self.rotation = rotation
def to_inch(self):
self.position = tuple([inch(x) for x in self.position])
@@ -642,9 +734,89 @@ class AMThermalPrimitive(AMPrimitive):
outer_diameter = self.outer_diameter,
inner_diameter = self.inner_diameter,
gap = self.gap,
+ rotation = self.rotation
)
- fmt = "{code},{position},{outer_diameter},{inner_diameter},{gap}*"
+ fmt = "{code},{position},{outer_diameter},{inner_diameter},{gap},{rotation}*"
return fmt.format(**data)
+
+ def _approximate_arc_cw(self, start_angle, end_angle, radius, center):
+ """
+ Get an arc as a series of points
+
+ Parameters
+ ----------
+ start_angle : The start angle in radians
+ end_angle : The end angle in radians
+ radius`: Radius of the arc
+ center : The center point of the arc (x, y) tuple
+
+ Returns
+ -------
+ array of point tuples
+ """
+
+ # The total sweep
+ sweep_angle = end_angle - start_angle
+ num_steps = 10
+
+ angle_step = sweep_angle / num_steps
+
+ radius = radius
+ center = center
+
+ points = []
+
+ for i in range(num_steps + 1):
+ current_angle = start_angle + (angle_step * i)
+
+ nextx = (center[0] + math.cos(current_angle) * radius)
+ nexty = (center[1] + math.sin(current_angle) * radius)
+
+ points.append((nextx, nexty))
+
+ return points
+
+ def to_primitive(self, units):
+
+ # We start with calculating the top right section, then duplicate it
+
+ inner_radius = self.inner_diameter / 2.0
+ outer_radius = self.outer_diameter / 2.0
+
+ # Calculate the start angle relative to the horizontal axis
+ inner_offset_angle = asin(self.gap / 2.0 / inner_radius)
+ outer_offset_angle = asin(self.gap / 2.0 / outer_radius)
+
+ rotation_rad = math.radians(self.rotation)
+ inner_start_angle = inner_offset_angle + rotation_rad
+ inner_end_angle = math.pi / 2 - inner_offset_angle + rotation_rad
+
+ outer_start_angle = outer_offset_angle + rotation_rad
+ outer_end_angle = math.pi / 2 - outer_offset_angle + rotation_rad
+
+ outlines = []
+ aperture = Circle((0, 0), 0)
+
+ points = (self._approximate_arc_cw(inner_start_angle, inner_end_angle, inner_radius, self.position)
+ + list(reversed(self._approximate_arc_cw(outer_start_angle, outer_end_angle, outer_radius, self.position))))
+ # Add in the last point since outlines should be closed
+ points.append(points[0])
+
+ # There are four outlines at rotated sections
+ for rotation in [0, 90.0, 180.0, 270.0]:
+
+ lines = []
+ prev_point = rotate_point(points[0], rotation, self.position)
+ for point in points[1:]:
+ cur_point = rotate_point(point, rotation, self.position)
+
+ lines.append(Line(prev_point, cur_point, aperture))
+
+ prev_point = cur_point
+
+ outlines.append(Outline(lines, units=units, level_polarity=self._level_polarity))
+
+ return outlines
class AMCenterLinePrimitive(AMPrimitive):
@@ -685,6 +857,14 @@ class AMCenterLinePrimitive(AMPrimitive):
------
ValueError, TypeError
"""
+
+ @classmethod
+ def from_primitive(cls, primitive):
+ width = primitive.width
+ height = primitive.height
+ center = primitive.position
+ rotation = math.degrees(primitive.rotation)
+ return cls(21, 'on', width, height, center, rotation)
@classmethod
def from_gerber(cls, primitive):
@@ -729,6 +909,30 @@ class AMCenterLinePrimitive(AMPrimitive):
fmt = "{code},{exposure},{width},{height},{center},{rotation}*"
return fmt.format(**data)
+ def to_primitive(self, units):
+
+ x = self.center[0]
+ y = self.center[1]
+ half_width = self.width / 2.0
+ half_height = self.height / 2.0
+
+ points = []
+ points.append((x - half_width, y + half_height))
+ points.append((x - half_width, y - half_height))
+ points.append((x + half_width, y - half_height))
+ points.append((x + half_width, y + half_height))
+
+ aperture = Circle((0, 0), 0)
+
+ lines = []
+ prev_point = rotate_point(points[3], self.rotation, self.center)
+ for point in points:
+ cur_point = rotate_point(point, self.rotation, self.center)
+
+ lines.append(Line(prev_point, cur_point, aperture))
+
+ return Outline(lines, units=units, level_polarity=self._level_polarity)
+
class AMLowerLeftLinePrimitive(AMPrimitive):
""" Aperture Macro Lower Left Line primitive. Code 22.
@@ -811,6 +1015,9 @@ class AMLowerLeftLinePrimitive(AMPrimitive):
fmt = "{code},{exposure},{width},{height},{lower_left},{rotation}*"
return fmt.format(**data)
+ def to_primitive(self, units):
+ raise NotImplementedError()
+
class AMUnsupportPrimitive(AMPrimitive):
@classmethod
@@ -829,3 +1036,6 @@ class AMUnsupportPrimitive(AMPrimitive):
def to_gerber(self, settings=None):
return self.primitive
+
+ def to_primitive(self, units):
+ return None \ No newline at end of file
diff --git a/gerber/cam.py b/gerber/cam.py
index 92ce83d..28918cb 100644
--- a/gerber/cam.py
+++ b/gerber/cam.py
@@ -72,9 +72,10 @@ class FileSettings(object):
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
+ # This is a common problem in Eagle files, so just suppress it
+ self.zero_suppression = 'leading'
+ else:
+ self.zero_suppression = zero_suppression
elif zeros is not None:
if zeros not in ['leading', 'trailing']:
@@ -165,6 +166,10 @@ class FileSettings(object):
self.zero_suppression == other.zero_suppression and
self.format == other.format and
self.angle_units == other.angle_units)
+
+ def __str__(self):
+ return ('<Settings: %s %s %s %s %s>' %
+ (self.units, self.notation, self.zero_suppression, self.format, self.angle_units))
class CamFile(object):
@@ -254,6 +259,7 @@ class CamFile(object):
filename : string <optional>
If provided, save the rendered image to `filename`
"""
+
ctx.set_bounds(self.bounds)
ctx._paint_background()
diff --git a/gerber/excellon.py b/gerber/excellon.py
index 3bb8611..a0bad4f 100755
--- a/gerber/excellon.py
+++ b/gerber/excellon.py
@@ -26,15 +26,18 @@ This module provides Excellon file classes and parsing utilities
import math
import operator
+from .cam import CamFile, FileSettings
+from .excellon_statements import *
+from .excellon_tool import ExcellonToolDefinitionParser
+from .primitives import Drill, Slot
+from .utils import inch, metric
+
+
try:
from cStringIO import StringIO
except(ImportError):
from io import StringIO
-from .excellon_statements import *
-from .cam import CamFile, FileSettings
-from .primitives import Drill
-from .utils import inch, metric
def read(filename):
@@ -55,14 +58,16 @@ def read(filename):
data = f.read()
settings = FileSettings(**detect_excellon_format(data))
return ExcellonParser(settings).parse(filename)
-
-
-def loads(data):
+
+def loads(data, settings = None, tools = None):
""" Read data from string and return an ExcellonFile
Parameters
----------
data : string
string containing Excellon file contents
+
+ tools: dict (optional)
+ externally defined tools
Returns
-------
@@ -71,24 +76,85 @@ def loads(data):
"""
# File object should use settings from source file by default.
- settings = FileSettings(**detect_excellon_format(data))
- return ExcellonParser(settings).parse_raw(data)
+ if not settings:
+ settings = FileSettings(**detect_excellon_format(data))
+ return ExcellonParser(settings, tools).parse_raw(data)
class DrillHit(object):
+ """Drill feature that is a single drill hole.
+
+ Attributes
+ ----------
+ tool : ExcellonTool
+ Tool to drill the hole. Defines the size of the hole that is generated.
+ position : tuple(float, float)
+ Center position of the drill.
+
+ """
def __init__(self, tool, position):
self.tool = tool
self.position = position
def to_inch(self):
- if self.tool.units == 'metric':
- self.tool.to_inch()
- self.position = tuple(map(inch, self.position))
+ self.position = tuple(map(inch, self.position))
def to_metric(self):
- if self.tool.units == 'inch':
- self.tool.to_metric()
- self.position = tuple(map(metric, self.position))
+ self.position = tuple(map(metric, self.position))
+
+ @property
+ def bounding_box(self):
+ position = self.position
+ radius = self.tool.diameter / 2.
+
+ min_x = position[0] - radius
+ max_x = position[0] + radius
+ min_y = position[1] - radius
+ max_y = position[1] + radius
+ return ((min_x, max_x), (min_y, max_y))
+
+ def offset(self, x_offset, y_offset):
+ self.position = tuple(map(operator.add, self.position, (x_offset, y_offset)))
+
+ def __str__(self):
+ return 'Hit (%f, %f) {%s}' % (self.position[0], self.position[1], self.tool)
+
+class DrillSlot(object):
+ """
+ A slot is created between two points. The way the slot is created depends on the statement used to create it
+ """
+
+ TYPE_ROUT = 1
+ TYPE_G85 = 2
+
+ def __init__(self, tool, start, end, slot_type):
+ self.tool = tool
+ self.start = start
+ self.end = end
+ self.slot_type = slot_type
+
+ def to_inch(self):
+ self.start = tuple(map(inch, self.start))
+ self.end = tuple(map(inch, self.end))
+
+ def to_metric(self):
+ self.start = tuple(map(metric, self.start))
+ self.end = tuple(map(metric, self.end))
+
+ @property
+ def bounding_box(self):
+ start = self.start
+ end = self.end
+ radius = self.tool.diameter / 2.
+ min_x = min(start[0], end[0]) - radius
+ max_x = max(start[0], end[0]) + radius
+ min_y = min(start[1], end[1]) - radius
+ max_y = max(start[1], end[1]) + radius
+ return ((min_x, max_x), (min_y, max_y))
+
+ def offset(self, x_offset, y_offset):
+ self.start = tuple(map(operator.add, self.start, (x_offset, y_offset)))
+ self.end = tuple(map(operator.add, self.end, (x_offset, y_offset)))
class ExcellonFile(CamFile):
@@ -127,7 +193,17 @@ class ExcellonFile(CamFile):
@property
def primitives(self):
- return [Drill(hit.position, hit.tool.diameter,units=self.settings.units) for hit in self.hits]
+
+ primitives = []
+ for hit in self.hits:
+ if isinstance(hit, DrillHit):
+ primitives.append(Drill(hit.position, hit.tool.diameter, hit, units=self.settings.units))
+ elif isinstance(hit, DrillSlot):
+ primitives.append(Slot(hit.start, hit.end, hit.tool.diameter, hit, units=self.settings.units))
+ else:
+ raise ValueError('Unknown hit type')
+
+ return primitives
@property
@@ -135,12 +211,11 @@ class ExcellonFile(CamFile):
xmin = ymin = 100000000000
xmax = ymax = -100000000000
for hit in self.hits:
- radius = hit.tool.diameter / 2.
- x, y = hit.position
- xmin = min(x - radius, xmin)
- xmax = max(x + radius, xmax)
- ymin = min(y - radius, ymin)
- ymax = max(y + radius, ymax)
+ bbox = hit.bounding_box
+ xmin = min(bbox[0][0], xmin)
+ xmax = max(bbox[0][1], xmax)
+ ymin = min(bbox[1][0], ymin)
+ ymax = max(bbox[1][1], ymax)
return ((xmin, xmax), (ymin, ymax))
def report(self, filename=None):
@@ -171,21 +246,12 @@ class ExcellonFile(CamFile):
def write(self, filename=None):
filename = filename if filename is not None else self.filename
with open(filename, 'w') as f:
-
- # Copy the header verbatim
- for statement in self.statements:
- if not isinstance(statement, ToolSelectionStmt):
- f.write(statement.to_excellon(self.settings) + '\n')
- else:
- break
-
- # Write out coordinates for drill hits by tool
- for tool in iter(self.tools.values()):
- f.write(ToolSelectionStmt(tool.number).to_excellon(self.settings) + '\n')
- for hit in self.hits:
- if hit.tool.number == tool.number:
- f.write(CoordinateStmt(*hit.position).to_excellon(self.settings) + '\n')
- f.write(EndOfProgramStmt().to_excellon() + '\n')
+ self.writes(f)
+
+ def writes(self, f):
+ # Copy the header verbatim
+ for statement in self.statements:
+ f.write(statement.to_excellon(self.settings) + '\n')
def to_inch(self):
"""
@@ -200,7 +266,7 @@ class ExcellonFile(CamFile):
for primitive in self.primitives:
primitive.to_inch()
for hit in self.hits:
- hit.position = tuple(map(inch, hit,position))
+ hit.to_inch()
def to_metric(self):
@@ -215,7 +281,7 @@ class ExcellonFile(CamFile):
for primitive in self.primitives:
primitive.to_metric()
for hit in self.hits:
- hit.position = tuple(map(metric, hit.position))
+ hit.to_metric()
def offset(self, x_offset=0, y_offset=0):
for statement in self.statements:
@@ -223,7 +289,7 @@ class ExcellonFile(CamFile):
for primitive in self.primitives:
primitive.offset(x_offset, y_offset)
for hit in self. hits:
- hit.position = tuple(map(operator.add, hit.position, (x_offset, y_offset)))
+ hit.offset(x_offset, y_offset)
def path_length(self, tool_number=None):
""" Return the path length for a given tool
@@ -283,7 +349,7 @@ class ExcellonParser(object):
settings : FileSettings or dict-like
Excellon file settings to use when interpreting the excellon file.
"""
- def __init__(self, settings=None):
+ def __init__(self, settings=None, ext_tools=None):
self.notation = 'absolute'
self.units = 'inch'
self.zeros = 'leading'
@@ -291,9 +357,14 @@ class ExcellonParser(object):
self.state = 'INIT'
self.statements = []
self.tools = {}
+ self.ext_tools = ext_tools or {}
+ self.comment_tools = {}
self.hits = []
self.active_tool = None
self.pos = [0., 0.]
+ self.drill_down = False
+ # Default for lated is None, which means we don't know
+ self.plated = ExcellonTool.PLATED_UNKNOWN
if settings is not None:
self.units = settings.units
self.zeros = settings.zeros
@@ -353,6 +424,24 @@ class ExcellonParser(object):
detected_format = tuple([int(x) for x in comment_stmt.comment.split('=')[1].split(":")])
if detected_format:
self.format = detected_format
+
+ if "TYPE=PLATED" in comment_stmt.comment:
+ self.plated = ExcellonTool.PLATED_YES
+
+ if "TYPE=NON_PLATED" in comment_stmt.comment:
+ self.plated = ExcellonTool.PLATED_NO
+
+ if "HEADER:" in comment_stmt.comment:
+ self.state = "HEADER"
+
+ if " Holesize " in comment_stmt.comment:
+ self.state = "HEADER"
+
+ # Parse this as a hole definition
+ tools = ExcellonToolDefinitionParser(self._settings()).parse_raw(comment_stmt.comment)
+ if len(tools) == 1:
+ tool = tools[tools.keys()[0]]
+ self._add_comment_tool(tool)
elif line[:3] == 'M48':
self.statements.append(HeaderBeginStmt())
@@ -364,6 +453,16 @@ class ExcellonParser(object):
self.state = 'DRILL'
elif self.state == 'INIT':
self.state = 'HEADER'
+
+ elif line[:3] == 'M00' and self.state == 'DRILL':
+ if self.active_tool:
+ cur_tool_number = self.active_tool.number
+ next_tool = self._get_tool(cur_tool_number + 1)
+
+ self.statements.append(NextToolSelectionStmt(self.active_tool, next_tool))
+ self.active_tool = next_tool
+ else:
+ raise Exception('Invalid state exception')
elif line[:3] == 'M95':
self.statements.append(HeaderEndStmt())
@@ -372,12 +471,15 @@ class ExcellonParser(object):
elif line[:3] == 'M15':
self.statements.append(ZAxisRoutPositionStmt())
+ self.drill_down = True
elif line[:3] == 'M16':
self.statements.append(RetractWithClampingStmt())
+ self.drill_down = False
elif line[:3] == 'M17':
self.statements.append(RetractWithoutClampingStmt())
+ self.drill_down = False
elif line[:3] == 'M30':
stmt = EndOfProgramStmt.from_excellon(line, self._settings())
@@ -410,6 +512,9 @@ class ExcellonParser(object):
stmt = CoordinateStmt.from_excellon(line[3:], self._settings())
stmt.mode = self.state
+
+ # The start position is where we were before the rout command
+ start = (self.pos[0], self.pos[1])
x = stmt.x
y = stmt.y
@@ -424,15 +529,28 @@ class ExcellonParser(object):
self.pos[0] += x
if y is not None:
self.pos[1] += y
-
+
+ # Our ending position
+ end = (self.pos[0], self.pos[1])
+
+ if self.drill_down:
+ if not self.active_tool:
+ self.active_tool = self._get_tool(1)
+
+ self.hits.append(DrillSlot(self.active_tool, start, end, DrillSlot.TYPE_ROUT))
+ self.active_tool._hit()
+
elif line[:3] == 'G05':
self.statements.append(DrillModeStmt())
+ self.drill_down = False
self.state = 'DRILL'
elif 'INCH' in line or 'METRIC' in line:
stmt = UnitStmt.from_excellon(line)
self.units = stmt.units
self.zeros = stmt.zeros
+ if stmt.format:
+ self.format = stmt.format
self.statements.append(stmt)
elif line[:3] == 'M71' or line [:3] == 'M72':
@@ -452,6 +570,7 @@ class ExcellonParser(object):
elif line[:4] == 'FMAT':
stmt = FormatStmt.from_excellon(line)
self.statements.append(stmt)
+ self.format = stmt.format_tuple
elif line[:3] == 'G40':
self.statements.append(CutterCompensationOffStmt())
@@ -471,9 +590,13 @@ class ExcellonParser(object):
self.statements.append(infeed_rate_stmt)
elif line[0] == 'T' and self.state == 'HEADER':
- tool = ExcellonTool.from_excellon(line, self._settings())
- self.tools[tool.number] = tool
- self.statements.append(tool)
+ if not ',OFF' in line and not ',ON' in line:
+ tool = ExcellonTool.from_excellon(line, self._settings(), None, self.plated)
+ self._merge_properties(tool)
+ self.tools[tool.number] = tool
+ self.statements.append(tool)
+ else:
+ self.statements.append(UnknownStmt.from_excellon(line))
elif line[0] == 'T' and self.state != 'HEADER':
stmt = ToolSelectionStmt.from_excellon(line)
@@ -481,8 +604,10 @@ class ExcellonParser(object):
# T0 is used as END marker, just ignore
if stmt.tool != 0:
- # FIXME: for weird files with no tools defined, original calc from gerbv
- if stmt.tool not in self.tools:
+ tool = self._get_tool(stmt.tool)
+
+ if not tool:
+ # FIXME: for weird files with no tools defined, original calc from gerbv
if self._settings().units == "inch":
diameter = (16 + 8 * stmt.tool) / 1000.0;
else:
@@ -497,7 +622,7 @@ class ExcellonParser(object):
self.statements.insert(i, tool)
break
- self.active_tool = self.tools[stmt.tool]
+ self.active_tool = tool
elif line[0] == 'R' and self.state != 'HEADER':
stmt = RepeatHoleStmt.from_excellon(line, self._settings())
@@ -509,30 +634,117 @@ class ExcellonParser(object):
self.active_tool._hit()
elif line[0] in ['X', 'Y']:
- stmt = CoordinateStmt.from_excellon(line, self._settings())
- x = stmt.x
- y = stmt.y
- self.statements.append(stmt)
- if self.notation == 'absolute':
- if x is not None:
- self.pos[0] = x
- if y is not None:
- self.pos[1] = y
+ if 'G85' in line:
+ stmt = SlotStmt.from_excellon(line, self._settings())
+
+ # I don't know if this is actually correct, but it makes sense that this is where the tool would end
+ x = stmt.x_end
+ y = stmt.y_end
+
+ self.statements.append(stmt)
+
+ if self.notation == 'absolute':
+ if x is not None:
+ self.pos[0] = x
+ if y is not None:
+ self.pos[1] = y
+ else:
+ if x is not None:
+ self.pos[0] += x
+ if y is not None:
+ self.pos[1] += y
+
+ if self.state == 'DRILL' or self.state == 'HEADER':
+ if not self.active_tool:
+ self.active_tool = self._get_tool(1)
+
+ self.hits.append(DrillSlot(self.active_tool, (stmt.x_start, stmt.y_start), (stmt.x_end, stmt.y_end), DrillSlot.TYPE_G85))
+ self.active_tool._hit()
else:
- if x is not None:
- self.pos[0] += x
- if y is not None:
- self.pos[1] += y
- if self.state == 'DRILL':
- self.hits.append(DrillHit(self.active_tool, tuple(self.pos)))
- self.active_tool._hit()
+ stmt = CoordinateStmt.from_excellon(line, self._settings())
+
+ # We need this in case we are in rout mode
+ start = (self.pos[0], self.pos[1])
+
+ x = stmt.x
+ y = stmt.y
+ self.statements.append(stmt)
+ if self.notation == 'absolute':
+ if x is not None:
+ self.pos[0] = x
+ if y is not None:
+ self.pos[1] = y
+ else:
+ if x is not None:
+ self.pos[0] += x
+ if y is not None:
+ self.pos[1] += y
+
+ if self.state == 'LINEAR' and self.drill_down:
+ if not self.active_tool:
+ self.active_tool = self._get_tool(1)
+
+ self.hits.append(DrillSlot(self.active_tool, start, tuple(self.pos), DrillSlot.TYPE_ROUT))
+
+ elif self.state == 'DRILL' or self.state == 'HEADER':
+ # Yes, drills in the header doesn't follow the specification, but it there are many
+ # files like this
+ if not self.active_tool:
+ self.active_tool = self._get_tool(1)
+
+ self.hits.append(DrillHit(self.active_tool, tuple(self.pos)))
+ self.active_tool._hit()
+
else:
self.statements.append(UnknownStmt.from_excellon(line))
def _settings(self):
return FileSettings(units=self.units, format=self.format,
zeros=self.zeros, notation=self.notation)
-
+
+ def _add_comment_tool(self, tool):
+ """
+ Add a tool that was defined in the comments to this file.
+
+ If we have already found this tool, then we will merge this comment tool definition into
+ the information for the tool
+ """
+
+ existing = self.tools.get(tool.number)
+ if existing and existing.plated == None:
+ existing.plated = tool.plated
+
+ self.comment_tools[tool.number] = tool
+
+ def _merge_properties(self, tool):
+ """
+ When we have externally defined tools, merge the properties of that tool into this one
+
+ For now, this is only plated
+ """
+
+ if tool.plated == ExcellonTool.PLATED_UNKNOWN:
+ ext_tool = self.ext_tools.get(tool.number)
+
+ if ext_tool:
+ tool.plated = ext_tool.plated
+
+ def _get_tool(self, toolid):
+
+ tool = self.tools.get(toolid)
+ if not tool:
+ tool = self.comment_tools.get(toolid)
+ if tool:
+ tool.settings = self._settings()
+ self.tools[toolid] = tool
+
+ if not tool:
+ tool = self.ext_tools.get(toolid)
+ if tool:
+ tool.settings = self._settings()
+ self.tools[toolid] = tool
+
+ return tool
def detect_excellon_format(data=None, filename=None):
""" Detect excellon file decimal format and zero-suppression settings.
@@ -635,7 +847,11 @@ def _layer_size_score(size, hole_count, hole_area):
Lower is better.
"""
board_area = size[0] * size[1]
+ if board_area == 0:
+ return 0
+
hole_percentage = hole_area / board_area
hole_score = (hole_percentage - 0.25) ** 2
size_score = (board_area - 8) **2
return hole_score * size_score
+ \ No newline at end of file
diff --git a/gerber/excellon_report/excellon_drr.py b/gerber/excellon_report/excellon_drr.py
new file mode 100644
index 0000000..ab9e857
--- /dev/null
+++ b/gerber/excellon_report/excellon_drr.py
@@ -0,0 +1,25 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+# Copyright 2015 Garret Fick <garret@ficksworkshop.com>
+
+# 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.
+
+"""
+Excellon DRR File module
+====================
+**Excellon file classes**
+
+Extra parsers for allegro misc files that can be useful when the Excellon file doesn't contain parameter information
+"""
+
diff --git a/gerber/excellon_settings.py b/gerber/excellon_settings.py
new file mode 100644
index 0000000..4dbe0ca
--- /dev/null
+++ b/gerber/excellon_settings.py
@@ -0,0 +1,105 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+from argparse import PARSER
+
+# Copyright 2015 Garret Fick <garret@ficksworkshop.com>
+
+# 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.
+
+"""
+Excellon Settings Definition File module
+====================
+**Excellon file classes**
+
+This module provides Excellon file classes and parsing utilities
+"""
+
+import re
+try:
+ from cStringIO import StringIO
+except(ImportError):
+ from io import StringIO
+
+from .cam import FileSettings
+
+def loads(data):
+ """ Read settings file information and return an FileSettings
+ Parameters
+ ----------
+ data : string
+ string containing Excellon settings file contents
+
+ Returns
+ -------
+ file settings: FileSettings
+
+ """
+
+ return ExcellonSettingsParser().parse_raw(data)
+
+def map_coordinates(value):
+ if value == 'ABSOLUTE':
+ return 'absolute'
+ return 'relative'
+
+def map_units(value):
+ if value == 'ENGLISH':
+ return 'inch'
+ return 'metric'
+
+def map_boolean(value):
+ return value == 'YES'
+
+SETTINGS_KEYS = {
+ 'INTEGER-PLACES': (int, 'format-int'),
+ 'DECIMAL-PLACES': (int, 'format-dec'),
+ 'COORDINATES': (map_coordinates, 'notation'),
+ 'OUTPUT-UNITS': (map_units, 'units'),
+ }
+
+class ExcellonSettingsParser(object):
+ """Excellon Settings PARSER
+
+ Parameters
+ ----------
+ None
+ """
+
+ def __init__(self):
+ self.values = {}
+ self.settings = None
+
+ def parse_raw(self, data):
+ for line in StringIO(data):
+ self._parse(line.strip())
+
+ # Create the FileSettings object
+ self.settings = FileSettings(
+ notation=self.values['notation'],
+ units=self.values['units'],
+ format=(self.values['format-int'], self.values['format-dec'])
+ )
+
+ return self.settings
+
+ def _parse(self, line):
+
+ line_items = line.split()
+ if len(line_items) == 2:
+
+ item_type_info = SETTINGS_KEYS.get(line_items[0])
+ if item_type_info:
+ # Convert the value to the expected type
+ item_value = item_type_info[0](line_items[1])
+
+ self.values[item_type_info[1]] = item_value \ No newline at end of file
diff --git a/gerber/excellon_statements.py b/gerber/excellon_statements.py
index 2be7a05..7153c82 100644
--- a/gerber/excellon_statements.py
+++ b/gerber/excellon_statements.py
@@ -36,7 +36,8 @@ __all__ = ['ExcellonTool', 'ToolSelectionStmt', 'CoordinateStmt',
'ExcellonStatement', 'ZAxisRoutPositionStmt',
'RetractWithClampingStmt', 'RetractWithoutClampingStmt',
'CutterCompensationOffStmt', 'CutterCompensationLeftStmt',
- 'CutterCompensationRightStmt', 'ZAxisInfeedRateStmt']
+ 'CutterCompensationRightStmt', 'ZAxisInfeedRateStmt',
+ 'NextToolSelectionStmt', 'SlotStmt']
class ExcellonStatement(object):
@@ -110,9 +111,29 @@ class ExcellonTool(ExcellonStatement):
hit_count : integer
Number of tool hits in excellon file.
"""
+
+ PLATED_UNKNOWN = None
+ PLATED_YES = 'plated'
+ PLATED_NO = 'nonplated'
+ PLATED_OPTIONAL = 'optional'
+
+ @classmethod
+ def from_tool(cls, tool):
+ args = {}
+
+ args['depth_offset'] = tool.depth_offset
+ args['diameter'] = tool.diameter
+ args['feed_rate'] = tool.feed_rate
+ args['max_hit_count'] = tool.max_hit_count
+ args['number'] = tool.number
+ args['plated'] = tool.plated
+ args['retract_rate'] = tool.retract_rate
+ args['rpm'] = tool.rpm
+
+ return cls(None, **args)
@classmethod
- def from_excellon(cls, line, settings, id=None):
+ def from_excellon(cls, line, settings, id=None, plated=None):
""" Create a Tool from an excellon file tool definition line.
Parameters
@@ -149,6 +170,10 @@ class ExcellonTool(ExcellonStatement):
args['number'] = int(val)
elif cmd == 'Z':
args['depth_offset'] = parse_gerber_value(val, nformat, zero_suppression)
+
+ if plated != ExcellonTool.PLATED_UNKNOWN:
+ # Sometimees we can can parse the
+ args['plated'] = plated
return cls(settings, **args)
@classmethod
@@ -181,11 +206,15 @@ class ExcellonTool(ExcellonStatement):
self.diameter = kwargs.get('diameter')
self.max_hit_count = kwargs.get('max_hit_count')
self.depth_offset = kwargs.get('depth_offset')
+ self.plated = kwargs.get('plated')
+
self.hit_count = 0
def to_excellon(self, settings=None):
- fmt = self.settings.format
- zs = self.settings.zero_suppression
+ if self.settings and not settings:
+ settings = self.settings
+ fmt = settings.format
+ zs = settings.zero_suppression
stmt = 'T%02d' % self.number
if self.retract_rate is not None:
stmt += 'B%s' % write_gerber_value(self.retract_rate, fmt, zs)
@@ -219,6 +248,23 @@ class ExcellonTool(ExcellonStatement):
def _hit(self):
self.hit_count += 1
+
+ def equivalent(self, other):
+ """
+ Is the other tool equal to this, ignoring the tool number, and other file specified properties
+ """
+
+ if type(self) != type(other):
+ return False
+
+ return (self.diameter == other.diameter
+ and self.feed_rate == other.feed_rate
+ and self.retract_rate == other.retract_rate
+ and self.rpm == other.rpm
+ and self.depth_offset == other.depth_offset
+ and self.max_hit_count == other.max_hit_count
+ and self.plated == other.plated
+ and self.settings.units == other.settings.units)
def __repr__(self):
unit = 'in.' if self.settings.units == 'inch' else 'mm'
@@ -267,7 +313,28 @@ class ToolSelectionStmt(ExcellonStatement):
if self.compensation_index is not None:
stmt += '%02d' % self.compensation_index
return stmt
-
+
+class NextToolSelectionStmt(ExcellonStatement):
+
+ # TODO the statement exists outside of the context of the file,
+ # so it is imposible to know that it is really the next tool
+
+ def __init__(self, cur_tool, next_tool, **kwargs):
+ """
+ Select the next tool in the wheel.
+ Parameters
+ ----------
+ cur_tool : the tool that is currently selected
+ next_tool : the that that is now selected
+ """
+ super(NextToolSelectionStmt, self).__init__(**kwargs)
+
+ self.cur_tool = cur_tool
+ self.next_tool = next_tool
+
+ def to_excellon(self, settings=None):
+ stmt = 'M00'
+ return stmt
class ZAxisInfeedRateStmt(ExcellonStatement):
@@ -300,6 +367,14 @@ class ZAxisInfeedRateStmt(ExcellonStatement):
class CoordinateStmt(ExcellonStatement):
@classmethod
+ def from_point(cls, point, mode=None):
+
+ stmt = cls(point[0], point[1])
+ if mode:
+ stmt.mode = mode
+ return stmt
+
+ @classmethod
def from_excellon(cls, line, settings, **kwargs):
x_coord = None
y_coord = None
@@ -574,19 +649,35 @@ class EndOfProgramStmt(ExcellonStatement):
self.y += y_offset
class UnitStmt(ExcellonStatement):
+
+ @classmethod
+ def from_settings(cls, settings):
+ """Create the unit statement from the FileSettings"""
+
+ return cls(settings.units, settings.zeros)
@classmethod
def from_excellon(cls, line, **kwargs):
units = 'inch' if 'INCH' in line else 'metric'
zeros = 'leading' if 'LZ' in line else 'trailing'
- return cls(units, zeros, **kwargs)
+ if '0000.00' in line:
+ format = (4, 2)
+ elif '000.000' in line:
+ format = (3, 3)
+ elif '00.0000' in line:
+ format = (2, 4)
+ else:
+ format = None
+ return cls(units, zeros, format, **kwargs)
- def __init__(self, units='inch', zeros='leading', **kwargs):
+ def __init__(self, units='inch', zeros='leading', format=None, **kwargs):
super(UnitStmt, self).__init__(**kwargs)
self.units = units.lower()
self.zeros = zeros
+ self.format = format
def to_excellon(self, settings=None):
+ # TODO This won't export the invalid format statement if it exists
stmt = '%s,%s' % ('INCH' if self.units == 'inch' else 'METRIC',
'LZ' if self.zeros == 'leading'
else 'TZ')
@@ -648,6 +739,10 @@ class FormatStmt(ExcellonStatement):
def to_excellon(self, settings=None):
return 'FMAT,%d' % self.format
+
+ @property
+ def format_tuple(self):
+ return (self.format, 6 - self.format)
class LinkToolStmt(ExcellonStatement):
@@ -742,6 +837,133 @@ class UnknownStmt(ExcellonStatement):
return "<Unknown Statement: %s>" % self.stmt
+class SlotStmt(ExcellonStatement):
+ """
+ G85 statement. Defines a slot created by multiple drills between two specified points.
+
+ Format is two coordinates, split by G85in the middle, for example, XnY0nG85XnYn
+ """
+
+ @classmethod
+ def from_points(cls, start, end):
+
+ return cls(start[0], start[1], end[0], end[1])
+
+ @classmethod
+ def from_excellon(cls, line, settings, **kwargs):
+ # Split the line based on the G85 separator
+ sub_coords = line.split('G85')
+ (x_start_coord, y_start_coord) = SlotStmt.parse_sub_coords(sub_coords[0], settings)
+ (x_end_coord, y_end_coord) = SlotStmt.parse_sub_coords(sub_coords[1], settings)
+
+ # Some files seem to specify only one of the coordinates
+ if x_end_coord == None:
+ x_end_coord = x_start_coord
+ if y_end_coord == None:
+ y_end_coord = y_start_coord
+
+ c = cls(x_start_coord, y_start_coord, x_end_coord, y_end_coord, **kwargs)
+ c.units = settings.units
+ return c
+
+ @staticmethod
+ def parse_sub_coords(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], settings.format,
+ settings.zero_suppression)
+ if len(splitline) == 2:
+ y_coord = parse_gerber_value(splitline[1], settings.format,
+ settings.zero_suppression)
+ else:
+ y_coord = parse_gerber_value(line.strip(' Y'), settings.format,
+ settings.zero_suppression)
+
+ return (x_coord, y_coord)
+
+
+ def __init__(self, x_start=None, y_start=None, x_end=None, y_end=None, **kwargs):
+ super(SlotStmt, self).__init__(**kwargs)
+ self.x_start = x_start
+ self.y_start = y_start
+ self.x_end = x_end
+ self.y_end = y_end
+ self.mode = None
+
+ def to_excellon(self, settings):
+ stmt = ''
+
+ if self.x_start is not None:
+ stmt += 'X%s' % write_gerber_value(self.x_start, settings.format,
+ settings.zero_suppression)
+ if self.y_start is not None:
+ stmt += 'Y%s' % write_gerber_value(self.y_start, settings.format,
+ settings.zero_suppression)
+
+ stmt += 'G85'
+
+ if self.x_end is not None:
+ stmt += 'X%s' % write_gerber_value(self.x_end, settings.format,
+ settings.zero_suppression)
+ if self.y_end is not None:
+ stmt += 'Y%s' % write_gerber_value(self.y_end, settings.format,
+ settings.zero_suppression)
+
+ return stmt
+
+ def to_inch(self):
+ if self.units == 'metric':
+ self.units = 'inch'
+ if self.x_start is not None:
+ self.x_start = inch(self.x_start)
+ if self.y_start is not None:
+ self.y_start = inch(self.y_start)
+ if self.x_end is not None:
+ self.x_end = inch(self.x_end)
+ if self.y_end is not None:
+ self.y_end = inch(self.y_end)
+
+ def to_metric(self):
+ if self.units == 'inch':
+ self.units = 'metric'
+ if self.x_start is not None:
+ self.x_start = metric(self.x_start)
+ if self.y_start is not None:
+ self.y_start = metric(self.y_start)
+ if self.x_end is not None:
+ self.x_end = metric(self.x_end)
+ if self.y_end is not None:
+ self.y_end = metric(self.y_end)
+
+ def offset(self, x_offset=0, y_offset=0):
+ if self.x_start is not None:
+ self.x_start += x_offset
+ if self.y_start is not None:
+ self.y_start += y_offset
+ if self.x_end is not None:
+ self.x_end += x_offset
+ if self.y_end is not None:
+ self.y_end += y_offset
+
+ def __str__(self):
+ start_str = ''
+ if self.x_start is not None:
+ start_str += 'X: %g ' % self.x_start
+ if self.y_start is not None:
+ start_str += 'Y: %g ' % self.y_start
+
+ end_str = ''
+ if self.x_end is not None:
+ end_str += 'X: %g ' % self.x_end
+ if self.y_end is not None:
+ end_str += 'Y: %g ' % self.y_end
+
+ return '<Slot Statement: %s to %s>' % (start_str, end_str)
+
def pairwise(iterator):
""" Iterate over list taking two elements at a time.
diff --git a/gerber/excellon_tool.py b/gerber/excellon_tool.py
new file mode 100644
index 0000000..bd76e54
--- /dev/null
+++ b/gerber/excellon_tool.py
@@ -0,0 +1,186 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+# Copyright 2015 Garret Fick <garret@ficksworkshop.com>
+
+# 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.
+
+"""
+Excellon Tool Definition File module
+====================
+**Excellon file classes**
+
+This module provides Excellon file classes and parsing utilities
+"""
+
+import re
+try:
+ from cStringIO import StringIO
+except(ImportError):
+ from io import StringIO
+
+from .excellon_statements import ExcellonTool
+
+def loads(data, settings=None):
+ """ Read tool file information and return a map of tools
+ Parameters
+ ----------
+ data : string
+ string containing Excellon Tool Definition file contents
+
+ Returns
+ -------
+ dict tool name: ExcellonTool
+
+ """
+ return ExcellonToolDefinitionParser(settings).parse_raw(data)
+
+class ExcellonToolDefinitionParser(object):
+ """ Excellon File Parser
+
+ Parameters
+ ----------
+ None
+ """
+
+ allegro_tool = re.compile(r'(?P<size>[0-9/.]+)\s+(?P<plated>P|N)\s+T(?P<toolid>[0-9]{2})\s+(?P<xtol>[0-9/.]+)\s+(?P<ytol>[0-9/.]+)')
+ allegro_comment_mils = re.compile('Holesize (?P<toolid>[0-9]{1,2})\. = (?P<size>[0-9/.]+) Tolerance = \+(?P<xtol>[0-9/.]+)/-(?P<ytol>[0-9/.]+) (?P<plated>(PLATED)|(NON_PLATED)|(OPTIONAL)) MILS Quantity = [0-9]+')
+ allegro2_comment_mils = re.compile('T(?P<toolid>[0-9]{1,2}) Holesize (?P<toolid2>[0-9]{1,2})\. = (?P<size>[0-9/.]+) Tolerance = \+(?P<xtol>[0-9/.]+)/-(?P<ytol>[0-9/.]+) (?P<plated>(PLATED)|(NON_PLATED)|(OPTIONAL)) MILS Quantity = [0-9]+')
+ allegro_comment_mm = re.compile('Holesize (?P<toolid>[0-9]{1,2})\. = (?P<size>[0-9/.]+) Tolerance = \+(?P<xtol>[0-9/.]+)/-(?P<ytol>[0-9/.]+) (?P<plated>(PLATED)|(NON_PLATED)|(OPTIONAL)) MM Quantity = [0-9]+')
+ allegro2_comment_mm = re.compile('T(?P<toolid>[0-9]{1,2}) Holesize (?P<toolid2>[0-9]{1,2})\. = (?P<size>[0-9/.]+) Tolerance = \+(?P<xtol>[0-9/.]+)/-(?P<ytol>[0-9/.]+) (?P<plated>(PLATED)|(NON_PLATED)|(OPTIONAL)) MM Quantity = [0-9]+')
+
+ matchers = [
+ (allegro_tool, 'mils'),
+ (allegro_comment_mils, 'mils'),
+ (allegro2_comment_mils, 'mils'),
+ (allegro_comment_mm, 'mm'),
+ (allegro2_comment_mm, 'mm'),
+ ]
+
+ def __init__(self, settings=None):
+ self.tools = {}
+ self.settings = settings
+
+ def parse_raw(self, data):
+ for line in StringIO(data):
+ self._parse(line.strip())
+
+ return self.tools
+
+ def _parse(self, line):
+
+ for matcher in ExcellonToolDefinitionParser.matchers:
+ m = matcher[0].match(line)
+ if m:
+ unit = matcher[1]
+
+ size = float(m.group('size'))
+ platedstr = m.group('plated')
+ toolid = int(m.group('toolid'))
+ xtol = float(m.group('xtol'))
+ ytol = float(m.group('ytol'))
+
+ size = self._convert_length(size, unit)
+ xtol = self._convert_length(xtol, unit)
+ ytol = self._convert_length(ytol, unit)
+
+ if platedstr == 'PLATED':
+ plated = ExcellonTool.PLATED_YES
+ elif platedstr == 'NON_PLATED':
+ plated = ExcellonTool.PLATED_NO
+ elif platedstr == 'OPTIONAL':
+ plated = ExcellonTool.PLATED_OPTIONAL
+ else:
+ plated = ExcellonTool.PLATED_UNKNOWN
+
+ tool = ExcellonTool(None, number=toolid, diameter=size, plated=plated)
+
+ self.tools[tool.number] = tool
+
+ break
+
+ def _convert_length(self, value, unit):
+
+ # Convert the value to mm
+ if unit == 'mils':
+ value /= 39.3700787402
+
+ # Now convert to the settings unit
+ if self.settings.units == 'inch':
+ return value / 25.4
+ else:
+ # Already in mm
+ return value
+
+def loads_rep(data, settings=None):
+ """ Read tool report information generated by PADS and return a map of tools
+ Parameters
+ ----------
+ data : string
+ string containing Excellon Report file contents
+
+ Returns
+ -------
+ dict tool name: ExcellonTool
+
+ """
+ return ExcellonReportParser(settings).parse_raw(data)
+
+class ExcellonReportParser(object):
+
+ # We sometimes get files with different encoding, so we can't actually
+ # match the text - the best we can do it detect the table header
+ header = re.compile(r'====\s+====\s+====\s+====\s+=====\s+===')
+
+ def __init__(self, settings=None):
+ self.tools = {}
+ self.settings = settings
+
+ self.found_header = False
+
+ def parse_raw(self, data):
+ for line in StringIO(data):
+ self._parse(line.strip())
+
+ return self.tools
+
+ def _parse(self, line):
+
+ # skip empty lines and "comments"
+ if not line.strip():
+ return
+
+ if not self.found_header:
+ # Try to find the heaader, since we need that to be sure we understand the contents correctly.
+ if ExcellonReportParser.header.match(line):
+ self.found_header = True
+
+ elif line[0] != '=':
+ # Already found the header, so we know to to map the contents
+ parts = line.split()
+ if len(parts) == 6:
+ toolid = int(parts[0])
+ size = float(parts[1])
+ if parts[2] == 'x':
+ plated = ExcellonTool.PLATED_YES
+ elif parts[2] == '-':
+ plated = ExcellonTool.PLATED_NO
+ else:
+ plated = ExcellonTool.PLATED_UNKNOWN
+ feedrate = int(parts[3])
+ speed = int(parts[4])
+ qty = int(parts[5])
+
+ tool = ExcellonTool(None, number=toolid, diameter=size, plated=plated, feed_rate=feedrate, rpm=speed)
+
+ self.tools[tool.number] = tool \ No newline at end of file
diff --git a/gerber/gerber_statements.py b/gerber/gerber_statements.py
index 9931acf..52e7ac3 100644
--- a/gerber/gerber_statements.py
+++ b/gerber/gerber_statements.py
@@ -26,6 +26,7 @@ from .utils import (parse_gerber_value, write_gerber_value, decimal_string,
from .am_statements import *
from .am_read import read_macro
from .am_eval import eval_macro
+from .primitives import AMGroup
class Statement(object):
@@ -92,6 +93,11 @@ class ParamStmt(Statement):
class FSParamStmt(ParamStmt):
""" FS - Gerber Format Specification Statement
"""
+
+ @classmethod
+ def from_settings(cls, settings):
+
+ return cls('FS', settings.zero_suppression, settings.notation, settings.format)
@classmethod
def from_dict(cls, stmt_dict):
@@ -167,6 +173,10 @@ class FSParamStmt(ParamStmt):
class MOParamStmt(ParamStmt):
""" MO - Gerber Mode (measurement units) Statement.
"""
+
+ @classmethod
+ def from_units(cls, units):
+ return cls(None, units)
@classmethod
def from_dict(cls, stmt_dict):
@@ -225,6 +235,11 @@ class LPParamStmt(ParamStmt):
param = stmt_dict['param']
lp = 'clear' if stmt_dict.get('lp') == 'C' else 'dark'
return cls(param, lp)
+
+ @classmethod
+ def from_region(cls, region):
+ #todo what is the first param?
+ return cls(None, region.level_polarity)
def __init__(self, param, lp):
""" Initialize LPParamStmt class
@@ -257,7 +272,31 @@ class LPParamStmt(ParamStmt):
class ADParamStmt(ParamStmt):
""" AD - Gerber Aperture Definition Statement
"""
-
+
+ @classmethod
+ def rect(cls, dcode, width, height):
+ '''Create a rectangular aperture definition statement'''
+ return cls('AD', dcode, 'R', ([width, height],))
+
+ @classmethod
+ def circle(cls, dcode, diameter):
+ '''Create a circular aperture definition statement'''
+ return cls('AD', dcode, 'C', ([diameter],))
+
+ @classmethod
+ def obround(cls, dcode, width, height):
+ '''Create an obround aperture definition statement'''
+ return cls('AD', dcode, 'O', ([width, height],))
+
+ @classmethod
+ def polygon(cls, dcode, diameter, num_vertices, rotation, hole_diameter):
+ '''Create a polygon aperture definition statement'''
+ return cls('AD', dcode, 'P', ([diameter, num_vertices, rotation, hole_diameter],))
+
+ @classmethod
+ def macro(cls, dcode, name):
+ return cls('AD', dcode, name, '')
+
@classmethod
def from_dict(cls, stmt_dict):
param = stmt_dict.get('param')
@@ -292,7 +331,9 @@ class ADParamStmt(ParamStmt):
ParamStmt.__init__(self, param)
self.d = d
self.shape = shape
- if modifiers:
+ if isinstance(modifiers, tuple):
+ self.modifiers = modifiers
+ elif modifiers:
self.modifiers = [tuple([float(x) for x in m.split("X") if len(x)]) for m in modifiers.split(",") if len(m)]
else:
self.modifiers = [tuple()]
@@ -388,6 +429,8 @@ class AMParamStmt(ParamStmt):
self.primitives.append(AMThermalPrimitive.from_gerber(primitive))
else:
self.primitives.append(AMUnsupportPrimitive.from_gerber(primitive))
+
+ return AMGroup(self.primitives, stmt=self, units=self.units)
def to_inch(self):
if self.units == 'metric':
@@ -814,6 +857,14 @@ class CoordStmt(Statement):
""" Coordinate Data Block
"""
+ OP_DRAW = 'D01'
+ OP_MOVE = 'D02'
+ OP_FLASH = 'D03'
+
+ FUNC_LINEAR = 'G01'
+ FUNC_ARC_CW = 'G02'
+ FUNC_ARC_CCW = 'G03'
+
@classmethod
def from_dict(cls, stmt_dict, settings):
function = stmt_dict['function']
@@ -832,6 +883,32 @@ class CoordStmt(Statement):
if j is not None:
j = parse_gerber_value(stmt_dict.get('j'), settings.format, settings.zero_suppression)
return cls(function, x, y, i, j, op, settings)
+
+ @classmethod
+ def move(cls, func, point):
+ if point:
+ return cls(func, point[0], point[1], None, None, CoordStmt.OP_MOVE, None)
+ # No point specified, so just write the function. This is normally for ending a region (D02*)
+ return cls(func, None, None, None, None, CoordStmt.OP_MOVE, None)
+
+ @classmethod
+ def line(cls, func, point):
+ return cls(func, point[0], point[1], None, None, CoordStmt.OP_DRAW, None)
+
+ @classmethod
+ def mode(cls, func):
+ return cls(func, None, None, None, None, None, None)
+
+ @classmethod
+ def arc(cls, func, point, center):
+ return cls(func, point[0], point[1], center[0], center[1], CoordStmt.OP_DRAW, None)
+
+ @classmethod
+ def flash(cls, point):
+ if point:
+ return cls(None, point[0], point[1], None, None, CoordStmt.OP_FLASH, None)
+ else:
+ return cls(None, None, None, None, None, CoordStmt.OP_FLASH, None)
def __init__(self, function, x, y, i, j, op, settings):
""" Initialize CoordStmt class
@@ -951,6 +1028,16 @@ class CoordStmt(Statement):
coord_str += 'Op: %s' % op
return '<Coordinate Statement: %s>' % coord_str
+
+ @property
+ def only_function(self):
+ """
+ Returns if the statement only set the function.
+ """
+
+ # TODO I would like to refactor this so that the function is handled separately and then
+ # TODO this isn't required
+ return self.function != None and self.op == None and self.x == None and self.y == None and self.i == None and self.j == None
class ApertureStmt(Statement):
@@ -1000,6 +1087,14 @@ class EofStmt(Statement):
class QuadrantModeStmt(Statement):
+
+ @classmethod
+ def single(cls):
+ return cls('single-quadrant')
+
+ @classmethod
+ def multi(cls):
+ return cls('multi-quadrant')
@classmethod
def from_gerber(cls, line):
@@ -1028,6 +1123,14 @@ class RegionModeStmt(Statement):
if 'G36' not in line and 'G37' not in line:
raise ValueError('%s is not a valid region mode statement' % line)
return (cls('on') if line[:3] == 'G36' else cls('off'))
+
+ @classmethod
+ def on(cls):
+ return cls('on')
+
+ @classmethod
+ def off(cls):
+ return cls('off')
def __init__(self, mode):
super(RegionModeStmt, self).__init__('RegionMode')
diff --git a/gerber/ncparam/allegro.py b/gerber/ncparam/allegro.py
new file mode 100644
index 0000000..a67bcf1
--- /dev/null
+++ b/gerber/ncparam/allegro.py
@@ -0,0 +1,25 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+# Copyright 2015 Garret Fick <garret@ficksworkshop.com>
+
+# 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.
+
+"""
+Allegro File module
+====================
+**Excellon file classes**
+
+Extra parsers for allegro misc files that can be useful when the Excellon file doesn't contain parameter information
+"""
+
diff --git a/gerber/primitives.py b/gerber/primitives.py
index 0ac12af..90b6fb9 100644
--- a/gerber/primitives.py
+++ b/gerber/primitives.py
@@ -17,7 +17,7 @@
import math
from operator import add, sub
-from .utils import validate_coordinates, inch, metric
+from .utils import validate_coordinates, inch, metric, rotate_point, nearly_equal
class Primitive(object):
@@ -43,7 +43,15 @@ class Primitive(object):
self._to_convert = list()
self.id = id
self.statement_id = statement_id
+
+ @property
+ def flashed(self):
+ '''Is this a flashed primitive'''
+
+ raise NotImplementedError('Is flashed must be '
+ 'implemented in subclass')
+ @property
def bounding_box(self):
""" Calculate bounding box
@@ -53,6 +61,17 @@ class Primitive(object):
"""
raise NotImplementedError('Bounding box calculation must be '
'implemented in subclass')
+
+ @property
+ def bounding_box_no_aperture(self):
+ """ Calculate bouxing box without considering the aperture
+
+ for most objects, this is the same as the bounding_box, but is different for
+ Lines and Arcs (which are not flashed)
+
+ Return ((min x, max x), (min y, max y))
+ """
+ return self.bounding_box
def to_inch(self):
if self.units == 'metric':
@@ -96,10 +115,13 @@ class Primitive(object):
setattr(self, attr, metric(value))
def offset(self, x_offset=0, y_offset=0):
- pass
+ raise NotImplementedError('The offset member must be implemented')
def __eq__(self, other):
return self.__dict__ == other.__dict__
+
+ def to_statement(self):
+ pass
class Line(Primitive):
@@ -111,6 +133,10 @@ class Line(Primitive):
self.end = end
self.aperture = aperture
self._to_convert = ['start', 'end', 'aperture']
+
+ @property
+ def flashed(self):
+ return False
@property
def angle(self):
@@ -131,6 +157,15 @@ class Line(Primitive):
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 bounding_box_no_aperture(self):
+ '''Gets the bounding box without the aperture'''
+ min_x = min(self.start[0], self.end[0])
+ max_x = max(self.start[0], self.end[0])
+ min_y = min(self.start[1], self.end[1])
+ max_y = max(self.start[1], self.end[1])
+ return ((min_x, max_x), (min_y, max_y))
@property
def vertices(self):
@@ -183,20 +218,34 @@ class Line(Primitive):
def offset(self, x_offset=0, y_offset=0):
self.start = tuple(map(add, self.start, (x_offset, y_offset)))
self.end = tuple(map(add, self.end, (x_offset, y_offset)))
+
+ def equivalent(self, other, offset):
+
+ if not isinstance(other, Line):
+ return False
+
+ equiv_start = tuple(map(add, other.start, offset))
+ equiv_end = tuple(map(add, other.end, offset))
+ return nearly_equal(self.start, equiv_start) and nearly_equal(self.end, equiv_end)
class Arc(Primitive):
"""
"""
- def __init__(self, start, end, center, direction, aperture, **kwargs):
+ def __init__(self, start, end, center, direction, aperture, quadrant_mode, **kwargs):
super(Arc, self).__init__(**kwargs)
self.start = start
self.end = end
self.center = center
self.direction = direction
self.aperture = aperture
+ self.quadrant_mode = quadrant_mode
self._to_convert = ['start', 'end', 'center', 'aperture']
+ @property
+ def flashed(self):
+ return False
+
@property
def radius(self):
dy, dx = map(sub, self.start, self.center)
@@ -256,10 +305,60 @@ 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) - self.aperture.radius
- max_x = max(x) + self.aperture.radius
- min_y = min(y) - self.aperture.radius
- max_y = max(y) + self.aperture.radius
+
+ if isinstance(self.aperture, Circle):
+ radius = self.aperture.radius
+ else:
+ # TODO this is actually not valid, but files contain it
+ width = self.aperture.width
+ height = self.aperture.height
+ radius = max(width, height)
+
+ min_x = min(x) - radius
+ max_x = max(x) + radius
+ min_y = min(y) - radius
+ max_y = max(y) + radius
+ return ((min_x, max_x), (min_y, max_y))
+
+ @property
+ def bounding_box_no_aperture(self):
+ '''Gets the bounding box without considering the aperture'''
+ two_pi = 2 * math.pi
+ theta0 = (self.start_angle + two_pi) % two_pi
+ theta1 = (self.end_angle + two_pi) % two_pi
+ points = [self.start, self.end]
+ 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))
def offset(self, x_offset=0, y_offset=0):
@@ -278,6 +377,10 @@ class Circle(Primitive):
self.diameter = diameter
self._to_convert = ['position', 'diameter']
+ @property
+ def flashed(self):
+ return True
+
@property
def radius(self):
return self.diameter / 2.
@@ -292,6 +395,19 @@ class Circle(Primitive):
def offset(self, x_offset=0, y_offset=0):
self.position = tuple(map(add, self.position, (x_offset, y_offset)))
+
+ def equivalent(self, other, offset):
+ '''Is this the same as the other circle, ignoring the offiset?'''
+
+ if not isinstance(other, Circle):
+ return False
+
+ if self.diameter != other.diameter:
+ return False
+
+ equiv_position = tuple(map(add, other.position, offset))
+
+ return nearly_equal(self.position, equiv_position)
class Ellipse(Primitive):
@@ -305,6 +421,9 @@ class Ellipse(Primitive):
self.height = height
self._to_convert = ['position', 'width', 'height']
+ @property
+ def flashed(self):
+ return True
@property
def bounding_box(self):
@@ -332,6 +451,10 @@ class Ellipse(Primitive):
class Rectangle(Primitive):
"""
+ When rotated, the rotation is about the center point.
+
+ Only aperture macro generated Rectangle objects can be rotated. If you aren't in a AMGroup,
+ then you don't need to worry about rotation
"""
def __init__(self, position, width, height, **kwargs):
super(Rectangle, self).__init__(**kwargs)
@@ -341,7 +464,10 @@ class Rectangle(Primitive):
self.height = height
self._to_convert = ['position', 'width', 'height']
-
+ @property
+ def flashed(self):
+ return True
+
@property
def lower_left(self):
return (self.position[0] - (self._abs_width / 2.),
@@ -372,6 +498,19 @@ class Rectangle(Primitive):
return (math.cos(math.radians(self.rotation)) * self.height +
math.sin(math.radians(self.rotation)) * self.width)
+ def equivalent(self, other, offset):
+ '''Is this the same as the other rect, ignoring the offiset?'''
+
+ if not isinstance(other, Rectangle):
+ return False
+
+ if self.width != other.width or self.height != other.height or self.rotation != other.rotation:
+ return False
+
+ equiv_position = tuple(map(add, other.position, offset))
+
+ return nearly_equal(self.position, equiv_position)
+
class Diamond(Primitive):
"""
@@ -383,6 +522,10 @@ class Diamond(Primitive):
self.width = width
self.height = height
self._to_convert = ['position', 'width', 'height']
+
+ @property
+ def flashed(self):
+ return True
@property
def lower_left(self):
@@ -427,6 +570,10 @@ class ChamferRectangle(Primitive):
self.chamfer = chamfer
self.corners = corners
self._to_convert = ['position', 'width', 'height', 'chamfer']
+
+ @property
+ def flashed(self):
+ return True
@property
def lower_left(self):
@@ -470,6 +617,10 @@ class RoundRectangle(Primitive):
self.radius = radius
self.corners = corners
self._to_convert = ['position', 'width', 'height', 'radius']
+
+ @property
+ def flashed(self):
+ return True
@property
def lower_left(self):
@@ -511,6 +662,10 @@ class Obround(Primitive):
self.width = width
self.height = height
self._to_convert = ['position', 'width', 'height']
+
+ @property
+ def flashed(self):
+ return True
@property
def lower_left(self):
@@ -566,14 +721,24 @@ class Obround(Primitive):
class Polygon(Primitive):
"""
+ Polygon flash defined by a set number of sides.
"""
- def __init__(self, position, sides, radius, **kwargs):
+ def __init__(self, position, sides, radius, hole_radius, **kwargs):
super(Polygon, self).__init__(**kwargs)
validate_coordinates(position)
self.position = position
self.sides = sides
self.radius = radius
+ self.hole_radius = hole_radius
self._to_convert = ['position', 'radius']
+
+ @property
+ def flashed(self):
+ return True
+
+ @property
+ def diameter(self):
+ return self.radius * 2
@property
def bounding_box(self):
@@ -585,7 +750,188 @@ class Polygon(Primitive):
def offset(self, x_offset=0, y_offset=0):
self.position = tuple(map(add, self.position, (x_offset, y_offset)))
+
+ @property
+ def vertices(self):
+
+ offset = self.rotation
+ da = 360.0 / self.sides
+
+ points = []
+ for i in xrange(self.sides):
+ points.append(rotate_point((self.position[0] + self.radius, self.position[1]), offset + da * i, self.position))
+
+ return points
+
+ def equivalent(self, other, offset):
+ """
+ Is this the outline the same as the other, ignoring the position offset?
+ """
+
+ # Quick check if it even makes sense to compare them
+ if type(self) != type(other) or self.sides != other.sides or self.radius != other.radius:
+ return False
+ equiv_pos = tuple(map(add, other.position, offset))
+
+ return nearly_equal(self.position, equiv_pos)
+
+
+class AMGroup(Primitive):
+ """
+ """
+ def __init__(self, amprimitives, stmt = None, **kwargs):
+ """
+
+ stmt : The original statment that generated this, since it is really hard to re-generate from primitives
+ """
+ super(AMGroup, self).__init__(**kwargs)
+
+ self.primitives = []
+ for amprim in amprimitives:
+ prim = amprim.to_primitive(self.units)
+ if isinstance(prim, list):
+ for p in prim:
+ self.primitives.append(p)
+ elif prim:
+ self.primitives.append(prim)
+ self._position = None
+ self._to_convert = ['_position', 'primitives']
+ self.stmt = stmt
+
+ def to_inch(self):
+ if self.units == 'metric':
+ super(AMGroup, self).to_inch()
+
+ # If we also have a stmt, convert that too
+ if self.stmt:
+ self.stmt.to_inch()
+
+
+ def to_metric(self):
+ if self.units == 'inch':
+ super(AMGroup, self).to_metric()
+
+ # If we also have a stmt, convert that too
+ if self.stmt:
+ self.stmt.to_metric()
+
+ @property
+ def flashed(self):
+ return True
+
+ @property
+ def bounding_box(self):
+ xlims, ylims = zip(*[p.bounding_box for p in self.primitives])
+ minx, maxx = zip(*xlims)
+ miny, maxy = zip(*ylims)
+ min_x = min(minx)
+ max_x = max(maxx)
+ min_y = min(miny)
+ max_y = max(maxy)
+ return ((min_x, max_x), (min_y, max_y))
+
+ @property
+ def position(self):
+ return self._position
+
+ def offset(self, x_offset=0, y_offset=0):
+ self._position = tuple(map(add, self._position, (x_offset, y_offset)))
+
+ for primitive in self.primitives:
+ primitive.offset(x_offset, y_offset)
+
+ @position.setter
+ def position(self, new_pos):
+ '''
+ Sets the position of the AMGroup.
+ This offset all of the objects by the specified distance.
+ '''
+
+ if self._position:
+ dx = new_pos[0] - self._position[0]
+ dy = new_pos[1] - self._position[1]
+ else:
+ dx = new_pos[0]
+ dy = new_pos[1]
+
+ for primitive in self.primitives:
+ primitive.offset(dx, dy)
+
+ self._position = new_pos
+
+ def equivalent(self, other, offset):
+ '''
+ Is this the macro group the same as the other, ignoring the position offset?
+ '''
+
+ if len(self.primitives) != len(other.primitives):
+ return False
+
+ # We know they have the same number of primitives, so now check them all
+ for i in range(0, len(self.primitives)):
+ if not self.primitives[i].equivalent(other.primitives[i], offset):
+ return False
+
+ # If we didn't find any differences, then they are the same
+ return True
+
+class Outline(Primitive):
+ """
+ Outlines only exist as the rendering for a apeture macro outline.
+ They don't exist outside of AMGroup objects
+ """
+ def __init__(self, primitives, **kwargs):
+ super(Outline, self).__init__(**kwargs)
+ self.primitives = primitives
+ self._to_convert = ['primitives']
+
+ if self.primitives[0].start != self.primitives[-1].end:
+ raise ValueError('Outline must be closed')
+
+ @property
+ def flashed(self):
+ return True
+
+ @property
+ def bounding_box(self):
+ xlims, ylims = zip(*[p.bounding_box for p in self.primitives])
+ minx, maxx = zip(*xlims)
+ miny, maxy = zip(*ylims)
+ min_x = min(minx)
+ max_x = max(maxx)
+ min_y = min(miny)
+ max_y = max(maxy)
+ return ((min_x, max_x), (min_y, max_y))
+
+ def offset(self, x_offset=0, y_offset=0):
+ for p in self.primitives:
+ p.offset(x_offset, y_offset)
+
+ @property
+ def width(self):
+ bounding_box = self.bounding_box()
+ return bounding_box[0][1] - bounding_box[0][0]
+
+ @property
+ def width(self):
+ bounding_box = self.bounding_box()
+ return bounding_box[1][1] - bounding_box[1][0]
+
+ def equivalent(self, other, offset):
+ '''
+ Is this the outline the same as the other, ignoring the position offset?
+ '''
+
+ # Quick check if it even makes sense to compare them
+ if type(self) != type(other) or len(self.primitives) != len(other.primitives):
+ return False
+
+ for i in range(0, len(self.primitives)):
+ if not self.primitives[i].equivalent(other.primitives[i], offset):
+ return False
+
+ return True
class Region(Primitive):
"""
@@ -594,10 +940,14 @@ class Region(Primitive):
super(Region, self).__init__(**kwargs)
self.primitives = primitives
self._to_convert = ['primitives']
+
+ @property
+ def flashed(self):
+ return False
@property
def bounding_box(self):
- xlims, ylims = zip(*[p.bounding_box for p in self.primitives])
+ xlims, ylims = zip(*[p.bounding_box_no_aperture for p in self.primitives])
minx, maxx = zip(*xlims)
miny, maxy = zip(*ylims)
min_x = min(minx)
@@ -620,6 +970,10 @@ class RoundButterfly(Primitive):
self.position = position
self.diameter = diameter
self._to_convert = ['position', 'diameter']
+
+ @property
+ def flashed(self):
+ return True
@property
def radius(self):
@@ -646,7 +1000,10 @@ class SquareButterfly(Primitive):
self.position = position
self.side = side
self._to_convert = ['position', 'side']
-
+
+ @property
+ def flashed(self):
+ return True
@property
def bounding_box(self):
@@ -682,6 +1039,10 @@ class Donut(Primitive):
self.width = 0.5 * math.sqrt(3.) * outer_diameter
self.height = outer_diameter
self._to_convert = ['position', 'width', 'height', 'inner_diameter', 'outer_diameter']
+
+ @property
+ def flashed(self):
+ return True
@property
def lower_left(self):
@@ -717,7 +1078,11 @@ class SquareRoundDonut(Primitive):
self.inner_diameter = inner_diameter
self.outer_diameter = outer_diameter
self._to_convert = ['position', 'inner_diameter', 'outer_diameter']
-
+
+ @property
+ def flashed(self):
+ return True
+
@property
def lower_left(self):
return tuple([c - self.outer_diameter / 2. for c in self.position])
@@ -741,12 +1106,17 @@ class SquareRoundDonut(Primitive):
class Drill(Primitive):
""" A drill hole
"""
- def __init__(self, position, diameter, **kwargs):
+ def __init__(self, position, diameter, hit, **kwargs):
super(Drill, self).__init__('dark', **kwargs)
validate_coordinates(position)
self.position = position
self.diameter = diameter
- self._to_convert = ['position', 'diameter']
+ self.hit = hit
+ self._to_convert = ['position', 'diameter', 'hit']
+
+ @property
+ def flashed(self):
+ return False
@property
def radius(self):
@@ -762,6 +1132,45 @@ class Drill(Primitive):
def offset(self, x_offset=0, y_offset=0):
self.position = tuple(map(add, self.position, (x_offset, y_offset)))
+
+ def __str__(self):
+ return '<Drill %f (%f, %f) [%s]>' % (self.diameter, self.position[0], self.position[1], self.hit)
+
+
+class Slot(Primitive):
+ """ A drilled slot
+ """
+ def __init__(self, start, end, diameter, hit, **kwargs):
+ super(Slot, self).__init__('dark', **kwargs)
+ validate_coordinates(start)
+ validate_coordinates(end)
+ self.start = start
+ self.end = end
+ self.diameter = diameter
+ self.hit = hit
+ self._to_convert = ['start', 'end', 'diameter', 'hit']
+
+ @property
+ def flashed(self):
+ return False
+
+ @property
+ def radius(self):
+ return self.diameter / 2.
+
+ @property
+ def bounding_box(self):
+ radius = self.radius
+ min_x = min(self.start[0], self.end[0]) - radius
+ max_x = max(self.start[0], self.end[0]) + radius
+ min_y = min(self.start[1], self.end[1]) - radius
+ max_y = max(self.start[1], self.end[1]) + radius
+ return ((min_x, max_x), (min_y, max_y))
+
+ def offset(self, x_offset=0, y_offset=0):
+ self.start = tuple(map(add, self.start, (x_offset, y_offset)))
+ self.end = tuple(map(add, self.end, (x_offset, y_offset)))
+
class TestRecord(Primitive):
""" Netlist Test record
diff --git a/gerber/render/cairo_backend.py b/gerber/render/cairo_backend.py
index 4e71e75..0cb230b 100644
--- a/gerber/render/cairo_backend.py
+++ b/gerber/render/cairo_backend.py
@@ -13,11 +13,14 @@
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
-# limitations under the License.
-
-
-import cairocffi as cairo
-from operator import mul
+# limitations under the License.
+
+try:
+ import cairo
+except ImportError:
+ import cairocffi as cairo
+
+from operator import mul, div
import math
import tempfile
@@ -84,8 +87,7 @@ class GerberCairoContext(GerberContext):
def dump(self, filename):
""" Save image as `filename`
"""
- is_svg = filename.lower().endswith(".svg")
- if is_svg:
+ if filename and filename.lower().endswith(".svg"):
self.surface.finish()
self.surface_buffer.flush()
with open(filename, "w") as f:
@@ -93,7 +95,7 @@ class GerberCairoContext(GerberContext):
f.write(self.surface_buffer.read())
f.flush()
else:
- self.surface.write_to_png(filename)
+ return self.surface.write_to_png(filename)
def dump_str(self):
""" Return a string containing the rendered image.
@@ -128,7 +130,7 @@ class GerberCairoContext(GerberContext):
end = map(mul, line.end, self.scale)
if not self.invert:
ctx = self.ctx
- ctx.set_source_rgba(*color, alpha=self.alpha)
+ ctx.set_source_rgba(color[0], color[1], color[2], alpha=self.alpha)
ctx.set_operator(cairo.OPERATOR_OVER if line.level_polarity == "dark" else cairo.OPERATOR_CLEAR)
else:
ctx = self.mask_ctx
@@ -138,6 +140,7 @@ class GerberCairoContext(GerberContext):
width = line.aperture.diameter
ctx.set_line_width(width * self.scale[0])
ctx.set_line_cap(cairo.LINE_CAP_ROUND)
+
ctx.move_to(*start)
ctx.line_to(*end)
ctx.stroke()
@@ -156,33 +159,43 @@ class GerberCairoContext(GerberContext):
radius = self.scale[0] * arc.radius
angle1 = arc.start_angle
angle2 = arc.end_angle
- width = arc.aperture.diameter if arc.aperture.diameter != 0 else 0.001
+ if angle1 == angle2 and arc.quadrant_mode != 'single-quadrant':
+ # Make the angles slightly different otherwise Cario will draw nothing
+ angle2 -= 0.000000001
+ if isinstance(arc.aperture, Circle):
+ width = arc.aperture.diameter if arc.aperture.diameter != 0 else 0.001
+ else:
+ width = max(arc.aperture.width, arc.aperture.height, 0.001)
+
if not self.invert:
ctx = self.ctx
- ctx.set_source_rgba(*color, alpha=self.alpha)
+ ctx.set_source_rgba(color[0], color[1], color[2], alpha=self.alpha)
ctx.set_operator(cairo.OPERATOR_OVER if arc.level_polarity == "dark" else cairo.OPERATOR_CLEAR)
else:
ctx = self.mask_ctx
ctx.set_source_rgba(0.0, 0.0, 0.0, 1.0)
ctx.set_operator(cairo.OPERATOR_CLEAR)
+
ctx.set_line_width(width * self.scale[0])
ctx.set_line_cap(cairo.LINE_CAP_ROUND)
ctx.move_to(*start) # You actually have to do this...
if arc.direction == 'counterclockwise':
- ctx.arc(*center, radius=radius, angle1=angle1, angle2=angle2)
+ ctx.arc(center[0], center[1], radius, angle1, angle2)
else:
- ctx.arc_negative(*center, radius=radius, angle1=angle1, angle2=angle2)
+ ctx.arc_negative(center[0], center[1], radius, angle1, angle2)
ctx.move_to(*end) # ...lame
+ ctx.stroke()
def _render_region(self, region, color):
if not self.invert:
ctx = self.ctx
- ctx.set_source_rgba(*color, alpha=self.alpha)
+ ctx.set_source_rgba(color[0], color[1], color[2], alpha=self.alpha)
ctx.set_operator(cairo.OPERATOR_OVER if region.level_polarity == "dark" else cairo.OPERATOR_CLEAR)
else:
ctx = self.mask_ctx
ctx.set_source_rgba(0.0, 0.0, 0.0, 1.0)
ctx.set_operator(cairo.OPERATOR_CLEAR)
+
ctx.set_line_width(0)
ctx.set_line_cap(cairo.LINE_CAP_ROUND)
ctx.move_to(*tuple(map(mul, region.primitives[0].start, self.scale)))
@@ -197,9 +210,9 @@ class GerberCairoContext(GerberContext):
angle1 = p.start_angle
angle2 = p.end_angle
if p.direction == 'counterclockwise':
- ctx.arc(*center, radius=radius, angle1=angle1, angle2=angle2)
+ ctx.arc(center[0], center[1], radius, angle1, angle2)
else:
- ctx.arc_negative(*center, radius=radius, angle1=angle1, angle2=angle2)
+ ctx.arc_negative(center[0], center[1], radius, angle1, angle2)
ctx.fill()
def _render_circle(self, circle, color):
@@ -213,31 +226,106 @@ class GerberCairoContext(GerberContext):
ctx.set_source_rgba(0.0, 0.0, 0.0, 1.0)
ctx.set_operator(cairo.OPERATOR_CLEAR)
ctx.set_line_width(0)
- ctx.arc(*center, radius=circle.radius * self.scale[0], angle1=0, angle2=2 * math.pi)
- ctx.fill()
+ ctx.arc(center[0], center[1], radius=circle.radius * self.scale[0], angle1=0, angle2=2 * math.pi)
+ ctx.fill()
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)))
+
if not self.invert:
ctx = self.ctx
- ctx.set_source_rgba(*color, alpha=self.alpha)
+ ctx.set_source_rgba(color[0], color[1], color[2], alpha=self.alpha)
ctx.set_operator(cairo.OPERATOR_OVER if rectangle.level_polarity == "dark" else cairo.OPERATOR_CLEAR)
else:
ctx = self.mask_ctx
ctx.set_source_rgba(0.0, 0.0, 0.0, 1.0)
ctx.set_operator(cairo.OPERATOR_CLEAR)
+
+ if rectangle.rotation != 0:
+ ctx.save()
+
+ center = map(mul, rectangle.position, self.scale)
+ matrix = cairo.Matrix()
+ matrix.translate(center[0], center[1])
+ # For drawing, we already handles the translation
+ ll[0] = ll[0] - center[0]
+ ll[1] = ll[1] - center[1]
+ matrix.rotate(rectangle.rotation)
+ ctx.transform(matrix)
+
ctx.set_line_width(0)
- ctx.rectangle(*ll, width=width, height=height)
+ ctx.rectangle(ll[0], ll[1], width, height)
ctx.fill()
+
+ if rectangle.rotation != 0:
+ ctx.restore()
def _render_obround(self, obround, color):
self._render_circle(obround.subshapes['circle1'], color)
self._render_circle(obround.subshapes['circle2'], color)
self._render_rectangle(obround.subshapes['rectangle'], color)
+
+ def _render_polygon(self, polygon, color):
+ if polygon.hole_radius > 0:
+ self.ctx.push_group()
+
+ vertices = polygon.vertices
+
+ self.ctx.set_source_rgba(color[0], color[1], color[2], self.alpha)
+ self.ctx.set_operator(cairo.OPERATOR_OVER if (polygon.level_polarity == "dark" and not self.invert) else cairo.OPERATOR_CLEAR)
+ self.ctx.set_line_width(0)
+ self.ctx.set_line_cap(cairo.LINE_CAP_ROUND)
+
+ # Start from before the end so it is easy to iterate and make sure it is closed
+ self.ctx.move_to(*map(mul, vertices[-1], self.scale))
+ for v in vertices:
+ self.ctx.line_to(*map(mul, v, self.scale))
+
+ self.ctx.fill()
+
+ if polygon.hole_radius > 0:
+ # Render the center clear
+ center = tuple(map(mul, polygon.position, self.scale))
+ self.ctx.set_source_rgba(color[0], color[1], color[2], self.alpha)
+ self.ctx.set_operator(cairo.OPERATOR_CLEAR)
+ self.ctx.set_line_width(0)
+ self.ctx.arc(center[0], center[1], polygon.hole_radius * self.scale[0], 0, 2 * math.pi)
+ self.ctx.fill()
+
+ self.ctx.pop_group_to_source()
+ self.ctx.paint_with_alpha(1)
def _render_drill(self, circle, color):
self._render_circle(circle, color)
+
+ def _render_slot(self, slot, color):
+ start = map(mul, slot.start, self.scale)
+ end = map(mul, slot.end, self.scale)
+
+ width = slot.diameter
+
+ if not self.invert:
+ ctx = self.ctx
+ ctx.set_source_rgba(color[0], color[1], color[2], alpha=self.alpha)
+ ctx.set_operator(cairo.OPERATOR_OVER if slot.level_polarity == "dark" else cairo.OPERATOR_CLEAR)
+ else:
+ ctx = self.mask_ctx
+ ctx.set_source_rgba(0.0, 0.0, 0.0, 1.0)
+ ctx.set_operator(cairo.OPERATOR_CLEAR)
+
+ ctx.set_line_width(width * self.scale[0])
+ ctx.set_line_cap(cairo.LINE_CAP_ROUND)
+ ctx.move_to(*start)
+ ctx.line_to(*end)
+ ctx.stroke()
+
+ def _render_amgroup(self, amgroup, color):
+ self.ctx.push_group()
+ for primitive in amgroup.primitives:
+ self.render(primitive)
+ self.ctx.pop_group_to_source()
+ self.ctx.paint_with_alpha(1)
def _render_test_record(self, primitive, color):
position = tuple(map(add, primitive.position, self.origin_in_inch))
@@ -251,13 +339,13 @@ class GerberCairoContext(GerberContext):
self.ctx.scale(1, -1)
self.ctx.show_text(primitive.net_name)
self.ctx.scale(1, -1)
-
- def _clear_mask(self):
+
+ def _clear_mask(self):
self.mask_ctx.set_operator(cairo.OPERATOR_OVER)
- self.mask_ctx.set_source_rgba(*self.color, alpha=self.alpha)
+ self.mask_ctx.set_source_rgba(self.background_color[0], self.background_color[1], self.background_color[2], alpha=self.alpha)
self.mask_ctx.paint()
- def _render_mask(self):
+ def _render_mask(self):
self.ctx.set_operator(cairo.OPERATOR_OVER)
ptn = cairo.SurfacePattern(self.mask)
ptn.set_matrix(self._xform_matrix)
@@ -266,6 +354,7 @@ class GerberCairoContext(GerberContext):
def _paint_background(self, force=False):
if (not self.bg) or force:
- self.bg = True
- self.ctx.set_source_rgba(*self.background_color, alpha=1.0)
+ self.bg = True
+ self.ctx.set_source_rgba(self.background_color[0], self.background_color[1], self.background_color[2], alpha=1.0)
self.ctx.paint()
+
diff --git a/gerber/render/excellon_backend.py b/gerber/render/excellon_backend.py
new file mode 100644
index 0000000..da5b22b
--- /dev/null
+++ b/gerber/render/excellon_backend.py
@@ -0,0 +1,189 @@
+
+from .render import GerberContext
+from ..excellon import DrillSlot
+from ..excellon_statements import *
+
+class ExcellonContext(GerberContext):
+
+ MODE_DRILL = 1
+ MODE_SLOT =2
+
+ def __init__(self, settings):
+ GerberContext.__init__(self)
+
+ # Statements that we write
+ self.comments = []
+ self.header = []
+ self.tool_def = []
+ self.body_start = [RewindStopStmt()]
+ self.body = []
+ self.start = [HeaderBeginStmt()]
+
+ # Current tool and position
+ self.handled_tools = set()
+ self.cur_tool = None
+ self.drill_mode = ExcellonContext.MODE_DRILL
+ self.drill_down = False
+ self._pos = (None, None)
+
+ self.settings = settings
+
+ self._start_header()
+ self._start_comments()
+
+ def _start_header(self):
+ """Create the header from the settings"""
+
+ self.header.append(UnitStmt.from_settings(self.settings))
+
+ if self.settings.notation == 'incremental':
+ raise NotImplementedError('Incremental mode is not implemented')
+ else:
+ self.body.append(AbsoluteModeStmt())
+
+ def _start_comments(self):
+
+ # Write the digits used - this isn't valid Excellon statement, so we write as a comment
+ self.comments.append(CommentStmt('FILE_FORMAT=%d:%d' % (self.settings.format[0], self.settings.format[1])))
+
+ def _get_end(self):
+ """How we end depends on our mode"""
+
+ end = []
+
+ if self.drill_down:
+ end.append(RetractWithClampingStmt())
+ end.append(RetractWithoutClampingStmt())
+
+ end.append(EndOfProgramStmt())
+
+ return end
+
+ @property
+ def statements(self):
+ return self.start + self.comments + self.header + self.body_start + self.body + self._get_end()
+
+ def set_bounds(self, bounds):
+ pass
+
+ def _paint_background(self):
+ pass
+
+ def _render_line(self, line, color):
+ raise ValueError('Invalid Excellon object')
+ def _render_arc(self, arc, color):
+ raise ValueError('Invalid Excellon object')
+
+ def _render_region(self, region, color):
+ raise ValueError('Invalid Excellon object')
+
+ def _render_level_polarity(self, region):
+ raise ValueError('Invalid Excellon object')
+
+ def _render_circle(self, circle, color):
+ raise ValueError('Invalid Excellon object')
+
+ def _render_rectangle(self, rectangle, color):
+ raise ValueError('Invalid Excellon object')
+
+ def _render_obround(self, obround, color):
+ raise ValueError('Invalid Excellon object')
+
+ def _render_polygon(self, polygon, color):
+ raise ValueError('Invalid Excellon object')
+
+ def _simplify_point(self, point):
+ return (point[0] if point[0] != self._pos[0] else None, point[1] if point[1] != self._pos[1] else None)
+
+ def _render_drill(self, drill, color):
+
+ if self.drill_mode != ExcellonContext.MODE_DRILL:
+ self._start_drill_mode()
+
+ tool = drill.hit.tool
+ if not tool in self.handled_tools:
+ self.handled_tools.add(tool)
+ self.header.append(ExcellonTool.from_tool(tool))
+
+ if tool != self.cur_tool:
+ self.body.append(ToolSelectionStmt(tool.number))
+ self.cur_tool = tool
+
+ point = self._simplify_point(drill.position)
+ self._pos = drill.position
+ self.body.append(CoordinateStmt.from_point(point))
+
+ def _start_drill_mode(self):
+ """
+ If we are not in drill mode, then end the ROUT so we can do basic drilling
+ """
+
+ if self.drill_mode == ExcellonContext.MODE_SLOT:
+
+ # Make sure we are retracted before changing modes
+ last_cmd = self.body[-1]
+ if self.drill_down:
+ self.body.append(RetractWithClampingStmt())
+ self.body.append(RetractWithoutClampingStmt())
+ self.drill_down = False
+
+ # Switch to drill mode
+ self.body.append(DrillModeStmt())
+ self.drill_mode = ExcellonContext.MODE_DRILL
+
+ else:
+ raise ValueError('Should be in slot mode')
+
+ def _render_slot(self, slot, color):
+
+ # Set the tool first, before we might go into drill mode
+ tool = slot.hit.tool
+ if not tool in self.handled_tools:
+ self.handled_tools.add(tool)
+ self.header.append(ExcellonTool.from_tool(tool))
+
+ if tool != self.cur_tool:
+ self.body.append(ToolSelectionStmt(tool.number))
+ self.cur_tool = tool
+
+ # Two types of drilling - normal drill and slots
+ if slot.hit.slot_type == DrillSlot.TYPE_ROUT:
+
+ # For ROUT, setting the mode is part of the actual command.
+
+ # Are we in the right position?
+ if slot.start != self._pos:
+ if self.drill_down:
+ # We need to move into the right position, so retract
+ self.body.append(RetractWithClampingStmt())
+ self.drill_down = False
+
+ # Move to the right spot
+ point = self._simplify_point(slot.start)
+ self._pos = slot.start
+ self.body.append(CoordinateStmt.from_point(point, mode="ROUT"))
+
+ # Now we are in the right spot, so drill down
+ if not self.drill_down:
+ self.body.append(ZAxisRoutPositionStmt())
+ self.drill_down = True
+
+ # Do a linear move from our current position to the end position
+ point = self._simplify_point(slot.end)
+ self._pos = slot.end
+ self.body.append(CoordinateStmt.from_point(point, mode="LINEAR"))
+
+ self.drill_mode = ExcellonContext.MODE_SLOT
+
+ else:
+ # This is a G85 slot, so do this in normally drilling mode
+ if self.drill_mode != ExcellonContext.MODE_DRILL:
+ self._start_drill_mode()
+
+ # Slots don't use simplified points
+ self._pos = slot.end
+ self.body.append(SlotStmt.from_points(slot.start, slot.end))
+
+ def _render_inverted_layer(self):
+ pass
+ \ No newline at end of file
diff --git a/gerber/render/render.py b/gerber/render/render.py
index 6af8bf1..f521c44 100644
--- a/gerber/render/render.py
+++ b/gerber/render/render.py
@@ -132,8 +132,13 @@ class GerberContext(object):
self._invert = invert
def render(self, primitive):
+ if not primitive:
+ return
color = (self.color if primitive.level_polarity == 'dark'
else self.background_color)
+
+ self._pre_render_primitive(primitive)
+
if isinstance(primitive, Line):
self._render_line(primitive, color)
elif isinstance(primitive, Arc):
@@ -149,11 +154,31 @@ class GerberContext(object):
elif isinstance(primitive, Polygon):
self._render_polygon(primitive, color)
elif isinstance(primitive, Drill):
- self._render_drill(primitive, color)
+ self._render_drill(primitive, self.color)
+ elif isinstance(primitive, Slot):
+ self._render_slot(primitive, self.color)
+ elif isinstance(primitive, AMGroup):
+ self._render_amgroup(primitive, color)
+ elif isinstance(primitive, Outline):
+ self._render_region(primitive, color)
elif isinstance(primitive, TestRecord):
self._render_test_record(primitive, color)
- else:
- return
+
+ self._post_render_primitive(primitive)
+
+ def _pre_render_primitive(self, primitive):
+ """
+ Called before rendering a primitive. Use the callback to perform some action before rendering
+ a primitive, for example adding a comment.
+ """
+ return
+
+ def _post_render_primitive(self, primitive):
+ """
+ Called after rendering a primitive. Use the callback to perform some action after rendering
+ a primitive
+ """
+ return
def _render_line(self, primitive, color):
pass
@@ -178,6 +203,12 @@ class GerberContext(object):
def _render_drill(self, primitive, color):
pass
+
+ def _render_slot(self, primitive, color):
+ pass
+
+ def _render_amgroup(self, primitive, color):
+ pass
def _render_test_record(self, primitive, color):
pass
diff --git a/gerber/render/rs274x_backend.py b/gerber/render/rs274x_backend.py
new file mode 100644
index 0000000..972edcb
--- /dev/null
+++ b/gerber/render/rs274x_backend.py
@@ -0,0 +1,470 @@
+
+from .render import GerberContext
+from ..am_statements import *
+from ..gerber_statements import *
+from ..primitives import AMGroup, Arc, Circle, Line, Obround, Outline, Polygon, Rectangle
+from copy import deepcopy
+
+class AMGroupContext(object):
+ '''A special renderer to generate aperature macros from an AMGroup'''
+
+ def __init__(self):
+ self.statements = []
+
+ def render(self, amgroup, name):
+
+ if amgroup.stmt:
+ # We know the statement it was generated from, so use that to create the AMParamStmt
+ # It will give a much better result
+
+ stmt = deepcopy(amgroup.stmt)
+ stmt.name = name
+
+ return stmt
+
+ else:
+ # Clone ourselves, then offset by the psotion so that
+ # our render doesn't have to consider offset. Just makes things simpler
+ nooffset_group = deepcopy(amgroup)
+ nooffset_group.position = (0, 0)
+
+ # Now draw the shapes
+ for primitive in nooffset_group.primitives:
+ if isinstance(primitive, Outline):
+ self._render_outline(primitive)
+ elif isinstance(primitive, Circle):
+ self._render_circle(primitive)
+ elif isinstance(primitive, Rectangle):
+ self._render_rectangle(primitive)
+ elif isinstance(primitive, Line):
+ self._render_line(primitive)
+ elif isinstance(primitive, Polygon):
+ self._render_polygon(primitive)
+ else:
+ raise ValueError('amgroup')
+
+ statement = AMParamStmt('AM', name, self._statements_to_string())
+ return statement
+
+ def _statements_to_string(self):
+ macro = ''
+
+ for statement in self.statements:
+ macro += statement.to_gerber()
+
+ return macro
+
+ def _render_circle(self, circle):
+ self.statements.append(AMCirclePrimitive.from_primitive(circle))
+
+ def _render_rectangle(self, rectangle):
+ self.statements.append(AMCenterLinePrimitive.from_primitive(rectangle))
+
+ def _render_line(self, line):
+ self.statements.append(AMVectorLinePrimitive.from_primitive(line))
+
+ def _render_outline(self, outline):
+ self.statements.append(AMOutlinePrimitive.from_primitive(outline))
+
+ def _render_polygon(self, polygon):
+ self.statements.append(AMPolygonPrimitive.from_primitive(polygon))
+
+ def _render_thermal(self, thermal):
+ pass
+
+
+class Rs274xContext(GerberContext):
+
+ def __init__(self, settings):
+ GerberContext.__init__(self)
+ self.comments = []
+ self.header = []
+ self.body = []
+ self.end = [EofStmt()]
+
+ # Current values so we know if we have to execute
+ # moves, levey changes before anything else
+ self._level_polarity = None
+ self._pos = (None, None)
+ self._func = None
+ self._quadrant_mode = None
+ self._dcode = None
+
+ # Primarily for testing and comarison to files, should we write
+ # flashes as a single statement or a move plus flash? Set to true
+ # to do in a single statement. Normally this can be false
+ self.condensed_flash = True
+
+ # When closing a region, force a D02 staement to close a region.
+ # This is normally not necessary because regions are closed with a G37
+ # staement, but this will add an extra statement for doubly close
+ # the region
+ self.explicit_region_move_end = False
+
+ self._next_dcode = 10
+ self._rects = {}
+ self._circles = {}
+ self._obrounds = {}
+ self._polygons = {}
+ self._macros = {}
+
+ self._i_none = 0
+ self._j_none = 0
+
+ self.settings = settings
+
+ self._start_header(settings)
+
+ def _start_header(self, settings):
+ self.header.append(FSParamStmt.from_settings(settings))
+ self.header.append(MOParamStmt.from_units(settings.units))
+
+ def _simplify_point(self, point):
+ return (point[0] if point[0] != self._pos[0] else None, point[1] if point[1] != self._pos[1] else None)
+
+ def _simplify_offset(self, point, offset):
+
+ if point[0] != offset[0]:
+ xoffset = point[0] - offset[0]
+ else:
+ xoffset = self._i_none
+
+ if point[1] != offset[1]:
+ yoffset = point[1] - offset[1]
+ else:
+ yoffset = self._j_none
+
+ return (xoffset, yoffset)
+
+ @property
+ def statements(self):
+ return self.comments + self.header + self.body + self.end
+
+ def set_bounds(self, bounds):
+ pass
+
+ def _paint_background(self):
+ pass
+
+ def _select_aperture(self, aperture):
+
+ # Select the right aperture if not already selected
+ if aperture:
+ if isinstance(aperture, Circle):
+ aper = self._get_circle(aperture.diameter)
+ elif isinstance(aperture, Rectangle):
+ aper = self._get_rectangle(aperture.width, aperture.height)
+ elif isinstance(aperture, Obround):
+ aper = self._get_obround(aperture.width, aperture.height)
+ elif isinstance(aperture, AMGroup):
+ aper = self._get_amacro(aperture)
+ else:
+ raise NotImplementedError('Line with invalid aperture type')
+
+ if aper.d != self._dcode:
+ self.body.append(ApertureStmt(aper.d))
+ self._dcode = aper.d
+
+ def _pre_render_primitive(self, primitive):
+
+ if hasattr(primitive, 'comment'):
+ self.body.append(CommentStmt(primitive.comment))
+
+ def _render_line(self, line, color):
+
+ self._select_aperture(line.aperture)
+
+ self._render_level_polarity(line)
+
+ # Get the right function
+ if self._func != CoordStmt.FUNC_LINEAR:
+ func = CoordStmt.FUNC_LINEAR
+ else:
+ func = None
+ self._func = CoordStmt.FUNC_LINEAR
+
+ if self._pos != line.start:
+ self.body.append(CoordStmt.move(func, self._simplify_point(line.start)))
+ self._pos = line.start
+ # We already set the function, so the next command doesn't require that
+ func = None
+
+ point = self._simplify_point(line.end)
+
+ # In some files, we see a lot of duplicated ponts, so omit those
+ if point[0] != None or point[1] != None:
+ self.body.append(CoordStmt.line(func, self._simplify_point(line.end)))
+ self._pos = line.end
+ elif func:
+ self.body.append(CoordStmt.mode(func))
+
+ def _render_arc(self, arc, color):
+
+ # Optionally set the quadrant mode if it has changed:
+ if arc.quadrant_mode != self._quadrant_mode:
+
+ if arc.quadrant_mode != 'multi-quadrant':
+ self.body.append(QuadrantModeStmt.single())
+ else:
+ self.body.append(QuadrantModeStmt.multi())
+
+ self._quadrant_mode = arc.quadrant_mode
+
+ # Select the right aperture if not already selected
+ self._select_aperture(arc.aperture)
+
+ self._render_level_polarity(arc)
+
+ # Find the right movement mode. Always set to be sure it is really right
+ dir = arc.direction
+ if dir == 'clockwise':
+ func = CoordStmt.FUNC_ARC_CW
+ self._func = CoordStmt.FUNC_ARC_CW
+ elif dir == 'counterclockwise':
+ func = CoordStmt.FUNC_ARC_CCW
+ self._func = CoordStmt.FUNC_ARC_CCW
+ else:
+ raise ValueError('Invalid circular interpolation mode')
+
+ if self._pos != arc.start:
+ # TODO I'm not sure if this is right
+ self.body.append(CoordStmt.move(CoordStmt.FUNC_LINEAR, self._simplify_point(arc.start)))
+ self._pos = arc.start
+
+ center = self._simplify_offset(arc.center, arc.start)
+ end = self._simplify_point(arc.end)
+ self.body.append(CoordStmt.arc(func, end, center))
+ self._pos = arc.end
+
+ def _render_region(self, region, color):
+
+ self._render_level_polarity(region)
+
+ self.body.append(RegionModeStmt.on())
+
+ for p in region.primitives:
+
+ if isinstance(p, Line):
+ self._render_line(p, color)
+ else:
+ self._render_arc(p, color)
+
+ if self.explicit_region_move_end:
+ self.body.append(CoordStmt.move(None, None))
+
+ self.body.append(RegionModeStmt.off())
+
+ def _render_level_polarity(self, region):
+ if region.level_polarity != self._level_polarity:
+ self._level_polarity = region.level_polarity
+ self.body.append(LPParamStmt.from_region(region))
+
+ def _render_flash(self, primitive, aperture):
+
+ self._render_level_polarity(primitive)
+
+ if aperture.d != self._dcode:
+ self.body.append(ApertureStmt(aperture.d))
+ self._dcode = aperture.d
+
+ if self.condensed_flash:
+ self.body.append(CoordStmt.flash(self._simplify_point(primitive.position)))
+ else:
+ self.body.append(CoordStmt.move(None, self._simplify_point(primitive.position)))
+ self.body.append(CoordStmt.flash(None))
+
+ self._pos = primitive.position
+
+ def _get_circle(self, diameter, dcode = None):
+ '''Define a circlar aperture'''
+
+ aper = self._circles.get(diameter, None)
+
+ if not aper:
+ if not dcode:
+ dcode = self._next_dcode
+ self._next_dcode += 1
+ else:
+ self._next_dcode = max(dcode + 1, self._next_dcode)
+
+ aper = ADParamStmt.circle(dcode, diameter)
+ self._circles[diameter] = aper
+ self.header.append(aper)
+
+ return aper
+
+ def _render_circle(self, circle, color):
+
+ aper = self._get_circle(circle.diameter)
+ self._render_flash(circle, aper)
+
+ def _get_rectangle(self, width, height, dcode = None):
+ '''Get a rectanglar aperture. If it isn't defined, create it'''
+
+ key = (width, height)
+ aper = self._rects.get(key, None)
+
+ if not aper:
+ if not dcode:
+ dcode = self._next_dcode
+ self._next_dcode += 1
+ else:
+ self._next_dcode = max(dcode + 1, self._next_dcode)
+
+ aper = ADParamStmt.rect(dcode, width, height)
+ self._rects[(width, height)] = aper
+ self.header.append(aper)
+
+ return aper
+
+ def _render_rectangle(self, rectangle, color):
+
+ aper = self._get_rectangle(rectangle.width, rectangle.height)
+ self._render_flash(rectangle, aper)
+
+ def _get_obround(self, width, height, dcode = None):
+
+ key = (width, height)
+ aper = self._obrounds.get(key, None)
+
+ if not aper:
+ if not dcode:
+ dcode = self._next_dcode
+ self._next_dcode += 1
+ else:
+ self._next_dcode = max(dcode + 1, self._next_dcode)
+
+ aper = ADParamStmt.obround(dcode, width, height)
+ self._obrounds[key] = aper
+ self.header.append(aper)
+
+ return aper
+
+ def _render_obround(self, obround, color):
+
+ aper = self._get_obround(obround.width, obround.height)
+ self._render_flash(obround, aper)
+
+ def _render_polygon(self, polygon, color):
+
+ aper = self._get_polygon(polygon.radius, polygon.sides, polygon.rotation, polygon.hole_radius)
+ self._render_flash(polygon, aper)
+
+ def _get_polygon(self, radius, num_vertices, rotation, hole_radius, dcode = None):
+
+ key = (radius, num_vertices, rotation, hole_radius)
+ aper = self._polygons.get(key, None)
+
+ if not aper:
+ if not dcode:
+ dcode = self._next_dcode
+ self._next_dcode += 1
+ else:
+ self._next_dcode = max(dcode + 1, self._next_dcode)
+
+ aper = ADParamStmt.polygon(dcode, radius * 2, num_vertices, rotation, hole_radius * 2)
+ self._polygons[key] = aper
+ self.header.append(aper)
+
+ return aper
+
+ def _render_drill(self, drill, color):
+ raise ValueError('Drills are not valid in RS274X files')
+
+ def _hash_amacro(self, amgroup):
+ '''Calculate a very quick hash code for deciding if we should even check AM groups for comparision'''
+
+ # We always start with an X because this forms part of the name
+ # Basically, in some cases, the name might start with a C, R, etc. That can appear
+ # to conflict with normal aperture definitions. Technically, it shouldn't because normal
+ # aperture definitions should have a comma, but in some cases the commit is omitted
+ hash = 'X'
+ for primitive in amgroup.primitives:
+
+ hash += primitive.__class__.__name__[0]
+
+ bbox = primitive.bounding_box
+ hash += str((bbox[0][1] - bbox[0][0]) * 100000)[0:2]
+ hash += str((bbox[1][1] - bbox[1][0]) * 100000)[0:2]
+
+ if hasattr(primitive, 'primitives'):
+ hash += str(len(primitive.primitives))
+
+ if isinstance(primitive, Rectangle):
+ hash += str(primitive.width * 1000000)[0:2]
+ hash += str(primitive.height * 1000000)[0:2]
+ elif isinstance(primitive, Circle):
+ hash += str(primitive.diameter * 1000000)[0:2]
+
+ if len(hash) > 20:
+ # The hash might actually get quite complex, so stop before
+ # it gets too long
+ break
+
+ return hash
+
+ def _get_amacro(self, amgroup, dcode = None):
+ # Macros are a little special since we don't have a good way to compare them quickly
+ # but in most cases, this should work
+
+ hash = self._hash_amacro(amgroup)
+ macro = None
+ macroinfo = self._macros.get(hash, None)
+
+ if macroinfo:
+
+ # We have a definition, but check that the groups actually are the same
+ for macro in macroinfo:
+
+ # Macros should have positions, right? But if the macro is selected for non-flashes
+ # then it won't have a position. This is of course a bad gerber, but they do exist
+ if amgroup.position:
+ position = amgroup.position
+ else:
+ position = (0, 0)
+
+ offset = (position[0] - macro[1].position[0], position[1] - macro[1].position[1])
+ if amgroup.equivalent(macro[1], offset):
+ break
+ macro = None
+
+ # Did we find one in the group0
+ if not macro:
+ # This is a new macro, so define it
+ if not dcode:
+ dcode = self._next_dcode
+ self._next_dcode += 1
+ else:
+ self._next_dcode = max(dcode + 1, self._next_dcode)
+
+ # Create the statements
+ # TODO
+ amrenderer = AMGroupContext()
+ statement = amrenderer.render(amgroup, hash)
+
+ self.header.append(statement)
+
+ aperdef = ADParamStmt.macro(dcode, hash)
+ self.header.append(aperdef)
+
+ # Store the dcode and the original so we can check if it really is the same
+ # If it didn't have a postition, set it to 0, 0
+ if amgroup.position == None:
+ amgroup.position = (0, 0)
+ macro = (aperdef, amgroup)
+
+ if macroinfo:
+ macroinfo.append(macro)
+ else:
+ self._macros[hash] = [macro]
+
+ return macro[0]
+
+ def _render_amgroup(self, amgroup, color):
+
+ aper = self._get_amacro(amgroup)
+ self._render_flash(amgroup, aper)
+
+ def _render_inverted_layer(self):
+ pass
+ \ No newline at end of file
diff --git a/gerber/rs274x.py b/gerber/rs274x.py
index 319d58f..260fbf8 100644
--- a/gerber/rs274x.py
+++ b/gerber/rs274x.py
@@ -21,6 +21,7 @@
import copy
import json
import re
+import sys
try:
from cStringIO import StringIO
@@ -30,6 +31,7 @@ except(ImportError):
from .gerber_statements import *
from .primitives import *
from .cam import CamFile, FileSettings
+from .utils import sq_distance
def read(filename):
@@ -93,8 +95,10 @@ class GerberFile(CamFile):
`bounds` is stored as ((min x, max x), (min y, max y))
"""
- def __init__(self, statements, settings, primitives, filename=None):
+ def __init__(self, statements, settings, primitives, apertures, filename=None):
super(GerberFile, self).__init__(statements, settings, primitives, filename)
+
+ self.apertures = apertures
@property
def comments(self):
@@ -118,6 +122,21 @@ class GerberFile(CamFile):
min_y = min(stmt.y, min_y)
max_y = max(stmt.y, max_y)
return ((min_x, max_x), (min_y, max_y))
+
+ @property
+ def bounding_box(self):
+ min_x = min_y = 1000000
+ max_x = max_y = -1000000
+
+ for prim in self.primitives:
+ bounds = prim.bounding_box
+ min_x = min(bounds[0][0], min_x)
+ max_x = max(bounds[0][1], max_x)
+
+ min_y = min(bounds[1][0], min_y)
+ max_y = max(bounds[1][1], max_y)
+
+ return ((min_x, max_x), (min_y, max_y))
def write(self, filename, settings=None):
""" Write data out to a gerber file
@@ -158,14 +177,14 @@ class GerberParser(object):
STRING = r"[a-zA-Z0-9_+\-/!?<>”’(){}.\|&@# :]+"
NAME = r"[a-zA-Z_$\.][a-zA-Z_$\.0-9+\-]+"
- FS = r"(?P<param>FS)(?P<zero>(L|T|D))?(?P<notation>(A|I))X(?P<x>[0-7][0-7])Y(?P<y>[0-7][0-7])"
+ FS = r"(?P<param>FS)(?P<zero>(L|T|D))?(?P<notation>(A|I))[NG0-9]*X(?P<x>[0-7][0-7])Y(?P<y>[0-7][0-7])[DM0-9]*"
MO = r"(?P<param>MO)(?P<mo>(MM|IN))"
LP = r"(?P<param>LP)(?P<lp>(D|C))"
- AD_CIRCLE = r"(?P<param>AD)D(?P<d>\d+)(?P<shape>C)[,]?(?P<modifiers>[^,%]*)?"
+ AD_CIRCLE = r"(?P<param>AD)D(?P<d>\d+)(?P<shape>C)[,]?(?P<modifiers>[^,%]*)"
AD_RECT = r"(?P<param>AD)D(?P<d>\d+)(?P<shape>R)[,](?P<modifiers>[^,%]*)"
AD_OBROUND = r"(?P<param>AD)D(?P<d>\d+)(?P<shape>O)[,](?P<modifiers>[^,%]*)"
AD_POLY = r"(?P<param>AD)D(?P<d>\d+)(?P<shape>P)[,](?P<modifiers>[^,%]*)"
- AD_MACRO = r"(?P<param>AD)D(?P<d>\d+)(?P<shape>{name})[,]?(?P<modifiers>[^,%]*)?".format(name=NAME)
+ AD_MACRO = r"(?P<param>AD)D(?P<d>\d+)(?P<shape>{name})[,]?(?P<modifiers>[^,%]*)".format(name=NAME)
AM = r"(?P<param>AM)(?P<name>{name})\*(?P<macro>[^%]*)".format(name=NAME)
# begin deprecated
@@ -228,8 +247,7 @@ class GerberParser(object):
return self.parse_raw(data, filename)
def parse_raw(self, data, filename=None):
- lines = [line for line in StringIO(data)]
- for stmt in self._parse(lines):
+ for stmt in self._parse(self._split_commands(data)):
self.evaluate(stmt)
self.statements.append(stmt)
@@ -237,7 +255,38 @@ class GerberParser(object):
for stmt in self.statements:
stmt.units = self.settings.units
- return GerberFile(self.statements, self.settings, self.primitives, filename)
+ return GerberFile(self.statements, self.settings, self.primitives, self.apertures.values(), filename)
+
+ def _split_commands(self, data):
+ """
+ Split the data into commands. Commands end with * (and also newline to help with some badly formatted files)
+ """
+
+ length = len(data)
+ start = 0
+ in_header = True
+
+ for cur in range(0, length):
+
+ val = data[cur]
+
+ if val == '%' and start == cur:
+ in_header = True
+ continue
+
+ if val == '\r' or val == '\n':
+ if start != cur:
+ yield data[start:cur]
+ start = cur + 1
+
+ elif not in_header and val == '*':
+ yield data[start:cur + 1]
+ start = cur + 1
+
+ elif in_header and val == '%':
+ yield data[start:cur + 1]
+ start = cur + 1
+ in_header = False
def dump_json(self):
stmts = {"statements": [stmt.__dict__ for stmt in self.statements]}
@@ -252,7 +301,7 @@ class GerberParser(object):
def _parse(self, data):
oldline = ''
- for i, line in enumerate(data):
+ for line in data:
line = oldline + line.strip()
# skip empty lines
@@ -267,7 +316,13 @@ class GerberParser(object):
did_something = True # make sure we do at least one loop
while did_something and len(line) > 0:
did_something = False
-
+
+ # consume empty data blocks
+ if line[0] == '*':
+ line = line[1:]
+ did_something = True
+ continue
+
# coord
(coord, r) = _match_one(self.COORD_STMT, line)
if coord:
@@ -275,12 +330,11 @@ class GerberParser(object):
line = r
did_something = True
continue
-
+
# aperture selection
(aperture, r) = _match_one(self.APERTURE_STMT, line)
if aperture:
yield ApertureStmt(**aperture)
-
did_something = True
line = r
continue
@@ -304,7 +358,9 @@ class GerberParser(object):
elif param["param"] == "AD":
yield ADParamStmt.from_dict(param)
elif param["param"] == "AM":
- yield AMParamStmt.from_dict(param)
+ stmt = AMParamStmt.from_dict(param)
+ stmt.units = self.settings.units
+ yield stmt
elif param["param"] == "OF":
yield OFParamStmt.from_dict(param)
elif param["param"] == "IN":
@@ -426,18 +482,28 @@ class GerberParser(object):
aperture = None
if shape == 'C':
diameter = modifiers[0][0]
- aperture = Circle(position=None, diameter=diameter)
+ aperture = Circle(position=None, diameter=diameter, units=self.settings.units)
elif shape == 'R':
width = modifiers[0][0]
height = modifiers[0][1]
- aperture = Rectangle(position=None, width=width, height=height)
+ aperture = Rectangle(position=None, width=width, height=height, units=self.settings.units)
elif shape == 'O':
width = modifiers[0][0]
height = modifiers[0][1]
- aperture = Obround(position=None, width=width, height=height)
+ aperture = Obround(position=None, width=width, height=height, units=self.settings.units)
elif shape == 'P':
- # FIXME: not supported yet?
- pass
+ outer_diameter = modifiers[0][0]
+ number_vertices = int(modifiers[0][1])
+ if len(modifiers[0]) > 2:
+ rotation = modifiers[0][2]
+ else:
+ rotation = 0
+
+ if len(modifiers[0]) > 3:
+ hole_diameter = modifiers[0][3]
+ else:
+ hole_diameter = 0
+ aperture = Polygon(position=None, sides=number_vertices, radius=outer_diameter/2.0, hole_radius=hole_diameter/2.0, rotation=rotation)
else:
aperture = self.macros[shape].build(modifiers)
@@ -446,7 +512,9 @@ 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, level_polarity=self.level_polarity))
+ # Sometimes we have regions that have no points. Skip those
+ if self.current_region:
+ self.primitives.append(Region(self.current_region, level_polarity=self.level_polarity, units=self.settings.units))
self.current_region = None
self.region_mode = stmt.mode
elif stmt.type == 'QuadrantMode':
@@ -477,6 +545,12 @@ class GerberParser(object):
elif stmt.function in ('G02', 'G2', 'G03', 'G3'):
self.interpolation = 'arc'
self.direction = ('clockwise' if stmt.function in ('G02', 'G2') else 'counterclockwise')
+
+ if stmt.only_function:
+ # Sometimes we get a coordinate statement
+ # that only sets the function. If so, don't
+ # try futher otherwise that might draw/flash something
+ return
if stmt.op:
self.op = stmt.op
@@ -484,7 +558,7 @@ class GerberParser(object):
# no implicit op allowed, force here if coord block doesn't have it
stmt.op = self.op
- if self.op == "D01":
+ if self.op == "D01" or self.op == "D1":
start = (self.x, self.y)
end = (x, y)
@@ -495,6 +569,7 @@ class GerberParser(object):
# from gerber spec revision J3, Section 4.5, page 55:
# The segments are not graphics objects in themselves; segments are part of region which is the graphics object. The segments have no thickness.
# The current aperture is associated with the region. This has no graphical effect, but allows all its attributes to be applied to the region.
+
if self.current_region is None:
self.current_region = [Line(start, end, self.apertures.get(self.aperture, Circle((0,0), 0)), level_polarity=self.level_polarity, units=self.settings.units),]
else:
@@ -502,19 +577,24 @@ class GerberParser(object):
else:
i = 0 if stmt.i is None else stmt.i
j = 0 if stmt.j is None else stmt.j
- center = (start[0] + i, start[1] + j)
+ center = self._find_center(start, end, (i, j))
if self.region_mode == 'off':
- self.primitives.append(Arc(start, end, center, self.direction, self.apertures[self.aperture], level_polarity=self.level_polarity, units=self.settings.units))
+ self.primitives.append(Arc(start, end, center, self.direction, self.apertures[self.aperture], quadrant_mode=self.quadrant_mode, level_polarity=self.level_polarity, units=self.settings.units))
else:
if self.current_region is None:
- self.current_region = [Arc(start, end, center, self.direction, self.apertures[self.aperture], level_polarity=self.level_polarity, units=self.settings.units),]
+ self.current_region = [Arc(start, end, center, self.direction, self.apertures.get(self.aperture, Circle((0,0), 0)), quadrant_mode=self.quadrant_mode, level_polarity=self.level_polarity, units=self.settings.units),]
else:
- self.current_region.append(Arc(start, end, center, self.direction, self.apertures[self.aperture], level_polarity=self.level_polarity, units=self.settings.units))
-
- elif self.op == "D02":
- pass
+ self.current_region.append(Arc(start, end, center, self.direction, self.apertures.get(self.aperture, Circle((0,0), 0)), quadrant_mode=self.quadrant_mode, level_polarity=self.level_polarity, units=self.settings.units))
+
+ elif self.op == "D02" or self.op == "D2":
+
+ if self.region_mode == "on":
+ # D02 in the middle of a region finishes that region and starts a new one
+ if self.current_region and len(self.current_region) > 1:
+ self.primitives.append(Region(self.current_region, level_polarity=self.level_polarity, units=self.settings.units))
+ self.current_region = None
- elif self.op == "D03":
+ elif self.op == "D03" or self.op == "D3":
primitive = copy.deepcopy(self.apertures[self.aperture])
# XXX: temporary fix because there are no primitives for Macros and Polygon
if primitive is not None:
@@ -528,6 +608,35 @@ class GerberParser(object):
self.primitives.append(primitive)
self.x, self.y = x, y
+
+ def _find_center(self, start, end, offsets):
+ """
+ In single quadrant mode, the offsets are always positive, which means there are 4 possible centers.
+ The correct center is the only one that results in an arc with sweep angle of less than or equal to 90 degrees
+ """
+
+ if self.quadrant_mode == 'single-quadrant':
+
+ # The Gerber spec says single quadrant only has one possible center, and you can detect
+ # based on the angle. But for real files, this seems to work better - there is usually
+ # only one option that makes sense for the center (since the distance should be the same
+ # from start and end). Find the center that makes the most sense
+ sqdist_diff_min = sys.maxint
+ center = None
+ for factors in [(1, 1), (1, -1), (-1, 1), (-1, -1)]:
+
+ test_center = (start[0] + offsets[0] * factors[0], start[1] + offsets[1] * factors[1])
+
+ sqdist_start = sq_distance(start, test_center)
+ sqdist_end = sq_distance(end, test_center)
+
+ if abs(sqdist_start - sqdist_end) < sqdist_diff_min:
+ center = test_center
+ sqdist_diff_min = abs(sqdist_start - sqdist_end)
+
+ return center
+ else:
+ return (start[0] + offsets[0], start[1] + offsets[1])
def _evaluate_aperture(self, stmt):
self.aperture = stmt.d
diff --git a/gerber/tests/golden/example_two_square_boxes.png b/gerber/tests/golden/example_two_square_boxes.png
new file mode 100644
index 0000000..4732995
--- /dev/null
+++ b/gerber/tests/golden/example_two_square_boxes.png
Binary files differ
diff --git a/gerber/tests/resources/example_two_square_boxes.gbr b/gerber/tests/resources/example_two_square_boxes.gbr
new file mode 100644
index 0000000..54a8ac1
--- /dev/null
+++ b/gerber/tests/resources/example_two_square_boxes.gbr
@@ -0,0 +1,19 @@
+G04 Ucamco ex. 1: Two square boxes*
+%FSLAX25Y25*%
+%MOMM*%
+%TF.Part,Other*%
+%LPD*%
+%ADD10C,0.010*%
+D10*
+X0Y0D02*
+G01*
+X500000Y0D01*
+Y500000D01*
+X0D01*
+Y0D01*
+X600000D02*
+X1100000D01*
+Y500000D01*
+X600000D01*
+Y0D01*
+M02* \ No newline at end of file
diff --git a/gerber/tests/test_am_statements.py b/gerber/tests/test_am_statements.py
index 0cee13d..39324e5 100644
--- a/gerber/tests/test_am_statements.py
+++ b/gerber/tests/test_am_statements.py
@@ -146,7 +146,9 @@ def test_AMOutlinePrimitive_factory():
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*')
+ # New lines don't matter for Gerber, but we insert them to make it easier to remove
+ # For test purposes we can ignore them
+ assert_equal(o.to_gerber().replace('\n', ''), '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)
@@ -229,30 +231,31 @@ def test_AMMoirePrimitive_conversion():
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)
+ assert_raises(ValueError, AMThermalPrimitive, 8, (0.0, 0.0), 7, 5, 0.2, 0.0)
+ assert_raises(TypeError, AMThermalPrimitive, 7, (0.0, '0'), 7, 5, 0.2, 0.0)
def test_AMThermalPrimitive_factory():
- t = AMThermalPrimitive.from_gerber('7,0,0,7,6,0.2*')
+ t = AMThermalPrimitive.from_gerber('7,0,0,7,6,0.2,45*')
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)
+ assert_equal(t.rotation, 45)
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*')
+ t = AMThermalPrimitive.from_gerber('7,0,0,7,6,0.2,30*')
+ assert_equal(t.to_gerber(), '7,0,0,7.0,6.0,0.2,30.0*')
def test_AMThermalPrimitive_conversion():
- t = AMThermalPrimitive(7, (25.4, 25.4), 25.4, 25.4, 25.4)
+ t = AMThermalPrimitive(7, (25.4, 25.4), 25.4, 25.4, 25.4, 0.0)
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 = AMThermalPrimitive(7, (1, 1), 1, 1, 1, 0)
t.to_metric()
assert_equal(t.position, (25.4, 25.4))
assert_equal(t.outer_diameter, 25.4)
diff --git a/gerber/tests/test_cairo_backend.py b/gerber/tests/test_cairo_backend.py
new file mode 100644
index 0000000..e298439
--- /dev/null
+++ b/gerber/tests/test_cairo_backend.py
@@ -0,0 +1,58 @@
+#! /usr/bin/env python
+# -*- coding: utf-8 -*-
+
+# Author: Garret Fick <garret@ficksworkshop.com>
+import io
+import os
+
+from ..render.cairo_backend import GerberCairoContext
+from ..rs274x import read, GerberFile
+from .tests import *
+
+
+TWO_BOXES_FILE = os.path.join(os.path.dirname(__file__),
+ 'resources/example_two_square_boxes.gbr')
+TWO_BOXES_EXPECTED = os.path.join(os.path.dirname(__file__),
+ 'golden/example_two_square_boxes.png')
+
+def test_render_polygon():
+
+ _test_render(TWO_BOXES_FILE, TWO_BOXES_EXPECTED)
+
+def _test_render(gerber_path, png_expected_path, create_output_path = None):
+ """Render the gerber file and compare to the expected PNG output.
+
+ Parameters
+ ----------
+ gerber_path : string
+ Path to Gerber file to open
+ png_expected_path : string
+ Path to the PNG file to compare to
+ create_output : string|None
+ If not None, write the generated PNG to the specified path.
+ This is primarily to help with
+ """
+
+ gerber = read(gerber_path)
+
+ # Create PNG image to the memory stream
+ ctx = GerberCairoContext()
+ gerber.render(ctx)
+
+ actual_bytes = ctx.dump(None)
+
+ # If we want to write the file bytes, do it now. This happens
+ if create_output_path:
+ with open(create_output_path, 'wb') as out_file:
+ out_file.write(actual_bytes)
+ # Creating the output is dangerous - it could overwrite the expected result.
+ # So if we are creating the output, we make the test fail on purpose so you
+ # won't forget to disable this
+ assert_false(True, 'Test created the output %s. This needs to be disabled to make sure the test behaves correctly' % (create_output_path,))
+
+ # Read the expected PNG file
+
+ with open(png_expected_path, 'rb') as expected_file:
+ expected_bytes = expected_file.read()
+
+ assert_equal(expected_bytes, actual_bytes)
diff --git a/gerber/tests/test_cam.py b/gerber/tests/test_cam.py
index 00a8285..3ae0a24 100644
--- a/gerber/tests/test_cam.py
+++ b/gerber/tests/test_cam.py
@@ -113,10 +113,19 @@ def test_zeros():
def test_filesettings_validation():
""" Test FileSettings constructor argument validation
"""
+
+ # absolute-ish is not a valid notation
assert_raises(ValueError, FileSettings, 'absolute-ish', 'inch', None, (2, 5), None)
+
+ # degrees kelvin isn't a valid unit for a CAM file
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)
+
+ # Technnically this should be an error, but Eangle files often do this incorrectly so we
+ # allow it
+ # 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)
diff --git a/gerber/tests/test_excellon.py b/gerber/tests/test_excellon.py
index a9a33c7..cd94b0f 100644
--- a/gerber/tests/test_excellon.py
+++ b/gerber/tests/test_excellon.py
@@ -78,8 +78,9 @@ def test_conversion():
for m_tool, i_tool in zip(iter(ncdrill.tools.values()), iter(ncdrill_inch.tools.values())):
assert_equal(i_tool, m_tool)
- for m, i in zip(ncdrill.primitives,inch_primitives):
- assert_equal(m, i)
+ for m, i in zip(ncdrill.primitives, inch_primitives):
+ assert_equal(m.position, i.position, '%s not equal to %s' % (m, i))
+ assert_equal(m.diameter, i.diameter, '%s not equal to %s' % (m, i))
def test_parser_hole_count():
diff --git a/gerber/tests/test_primitives.py b/gerber/tests/test_primitives.py
index f8a32da..a88497c 100644
--- a/gerber/tests/test_primitives.py
+++ b/gerber/tests/test_primitives.py
@@ -9,10 +9,18 @@ from operator import add
def test_primitive_smoketest():
p = Primitive()
- assert_raises(NotImplementedError, p.bounding_box)
+ try:
+ p.bounding_box
+ assert_false(True, 'should have thrown the exception')
+ except NotImplementedError:
+ pass
p.to_metric()
p.to_inch()
- p.offset(1, 1)
+ try:
+ p.offset(1, 1)
+ assert_false(True, 'should have thrown the exception')
+ except NotImplementedError:
+ pass
def test_line_angle():
""" Test Line primitive angle calculation
@@ -150,7 +158,7 @@ def test_arc_radius():
((0, 1), (1, 0), (0, 0), 1),]
for start, end, center, radius in cases:
- a = Arc(start, end, center, 'clockwise', 0)
+ a = Arc(start, end, center, 'clockwise', 0, 'single-quadrant')
assert_equal(a.radius, radius)
def test_arc_sweep_angle():
@@ -163,7 +171,7 @@ def test_arc_sweep_angle():
for start, end, center, direction, sweep in cases:
c = Circle((0,0), 1)
- a = Arc(start, end, center, direction, c)
+ a = Arc(start, end, center, direction, c, 'single-quadrant')
assert_equal(a.sweep_angle, sweep)
def test_arc_bounds():
@@ -175,12 +183,12 @@ def test_arc_bounds():
]
for start, end, center, direction, bounds in cases:
c = Circle((0,0), 1)
- a = Arc(start, end, center, direction, c)
+ a = Arc(start, end, center, direction, c, 'single-quadrant')
assert_equal(a.bounding_box, bounds)
def test_arc_conversion():
c = Circle((0, 0), 25.4, units='metric')
- a = Arc((2.54, 25.4), (254.0, 2540.0), (25400.0, 254000.0),'clockwise', c, units='metric')
+ a = Arc((2.54, 25.4), (254.0, 2540.0), (25400.0, 254000.0),'clockwise', c, 'single-quadrant', units='metric')
#No effect
a.to_metric()
@@ -203,7 +211,7 @@ def test_arc_conversion():
assert_equal(a.aperture.diameter, 1.0)
c = Circle((0, 0), 1.0, units='inch')
- a = Arc((0.1, 1.0), (10.0, 100.0), (1000.0, 10000.0),'clockwise', c, units='inch')
+ a = Arc((0.1, 1.0), (10.0, 100.0), (1000.0, 10000.0),'clockwise', c, 'single-quadrant', units='inch')
a.to_metric()
assert_equal(a.start, (2.54, 25.4))
assert_equal(a.end, (254.0, 2540.0))
@@ -212,7 +220,7 @@ def test_arc_conversion():
def test_arc_offset():
c = Circle((0, 0), 1)
- a = Arc((0, 0), (1, 1), (2, 2), 'clockwise', c)
+ a = Arc((0, 0), (1, 1), (2, 2), 'clockwise', c, 'single-quadrant')
a.offset(1, 0)
assert_equal(a.start,(1., 0.))
assert_equal(a.end, (2., 1.))
@@ -703,29 +711,30 @@ def test_obround_offset():
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)
+ test_cases = (((0,0), 3, 5, 0),
+ ((0, 0), 5, 6, 0),
+ ((1,1), 7, 7, 45))
+ for pos, sides, radius, hole_radius in test_cases:
+ p = Polygon(pos, sides, radius, hole_radius)
assert_equal(p.position, pos)
assert_equal(p.sides, sides)
assert_equal(p.radius, radius)
+ assert_equal(p.hole_radius, hole_radius)
def test_polygon_bounds():
""" Test polygon bounding box calculation
"""
- p = Polygon((2,2), 3, 2)
+ p = Polygon((2,2), 3, 2, 0)
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)
+ p = Polygon((2,2), 3, 4, 0)
xbounds, ybounds = p.bounding_box
assert_array_almost_equal(xbounds, (-2, 6))
assert_array_almost_equal(ybounds, (-2, 6))
def test_polygon_conversion():
- p = Polygon((2.54, 25.4), 3, 254.0, units='metric')
+ p = Polygon((2.54, 25.4), 3, 254.0, 0, units='metric')
#No effect
p.to_metric()
@@ -741,7 +750,7 @@ def test_polygon_conversion():
assert_equal(p.position, (0.1, 1.0))
assert_equal(p.radius, 10.0)
- p = Polygon((0.1, 1.0), 3, 10.0, units='inch')
+ p = Polygon((0.1, 1.0), 3, 10.0, 0, units='inch')
#No effect
p.to_inch()
@@ -758,7 +767,7 @@ def test_polygon_conversion():
assert_equal(p.radius, 254.0)
def test_polygon_offset():
- p = Polygon((0, 0), 5, 10)
+ p = Polygon((0, 0), 5, 10, 0)
p.offset(1, 0)
assert_equal(p.position,(1., 0.))
p.offset(0, 1)
@@ -997,7 +1006,7 @@ def test_drill_ctor():
"""
test_cases = (((0, 0), 2), ((1, 1), 3), ((2, 2), 5))
for position, diameter in test_cases:
- d = Drill(position, diameter)
+ d = Drill(position, diameter, None)
assert_equal(d.position, position)
assert_equal(d.diameter, diameter)
assert_equal(d.radius, diameter/2.)
@@ -1005,21 +1014,21 @@ def test_drill_ctor():
def test_drill_ctor_validation():
""" Test drill argument validation
"""
- assert_raises(TypeError, Drill, 3, 5)
- assert_raises(TypeError, Drill, (3,4,5), 5)
+ assert_raises(TypeError, Drill, 3, 5, None)
+ assert_raises(TypeError, Drill, (3,4,5), 5, None)
def test_drill_bounds():
- d = Drill((0, 0), 2)
+ d = Drill((0, 0), 2, None)
xbounds, ybounds = d.bounding_box
assert_array_almost_equal(xbounds, (-1, 1))
assert_array_almost_equal(ybounds, (-1, 1))
- d = Drill((1, 2), 2)
+ d = Drill((1, 2), 2, None)
xbounds, ybounds = d.bounding_box
assert_array_almost_equal(xbounds, (0, 2))
assert_array_almost_equal(ybounds, (1, 3))
def test_drill_conversion():
- d = Drill((2.54, 25.4), 254., units='metric')
+ d = Drill((2.54, 25.4), 254., None, units='metric')
#No effect
d.to_metric()
@@ -1036,7 +1045,7 @@ def test_drill_conversion():
assert_equal(d.diameter, 10.0)
- d = Drill((0.1, 1.0), 10., units='inch')
+ d = Drill((0.1, 1.0), 10., None, units='inch')
#No effect
d.to_inch()
@@ -1053,15 +1062,15 @@ def test_drill_conversion():
assert_equal(d.diameter, 254.0)
def test_drill_offset():
- d = Drill((0, 0), 1.)
+ d = Drill((0, 0), 1., None)
d.offset(1, 0)
assert_equal(d.position,(1., 0.))
d.offset(0, 1)
assert_equal(d.position,(1., 1.))
def test_drill_equality():
- d = Drill((2.54, 25.4), 254.)
- d1 = Drill((2.54, 25.4), 254.)
+ d = Drill((2.54, 25.4), 254., None)
+ d1 = Drill((2.54, 25.4), 254., None)
assert_equal(d, d1)
- d1 = Drill((2.54, 25.4), 254.2)
+ d1 = Drill((2.54, 25.4), 254.2, None)
assert_not_equal(d, d1)
diff --git a/gerber/utils.py b/gerber/utils.py
index 6653683..b968dc8 100644
--- a/gerber/utils.py
+++ b/gerber/utils.py
@@ -288,10 +288,25 @@ def rotate_point(point, angle, center=(0.0, 0.0)):
`point` rotated about `center` by `angle` degrees.
"""
angle = radians(angle)
- xdelta, ydelta = tuple(map(sub, point, center))
- x = center[0] + (cos(angle) * xdelta) - (sin(angle) * ydelta)
- y = center[1] + (sin(angle) * xdelta) - (cos(angle) * ydelta)
- return (x, y)
+
+ cos_angle = cos(angle)
+ sin_angle = sin(angle)
+
+ return (
+ cos_angle * (point[0] - center[0]) - sin_angle * (point[1] - center[1]) + center[0],
+ sin_angle * (point[0] - center[0]) + cos_angle * (point[1] - center[1]) + center[1])
+
+def nearly_equal(point1, point2, ndigits = 6):
+ '''Are the points nearly equal'''
+
+ return round(point1[0] - point2[0], ndigits) == 0 and round(point1[1] - point2[1], ndigits) == 0
+
+
+def sq_distance(point1, point2):
+
+ diff1 = point1[0] - point2[0]
+ diff2 = point1[1] - point2[1]
+ return diff1 * diff1 + diff2 * diff2
def listdir(directory, ignore_hidden=True, ignore_os=True):
diff --git a/test-requirements.txt b/test-requirements.txt
index c88a55a..826da33 100644
--- a/test-requirements.txt
+++ b/test-requirements.txt
@@ -1,3 +1,4 @@
# Test requirements
+cairocffi==0.6
coverage==3.7.1
nose==1.3.4