summaryrefslogtreecommitdiff
path: root/gerber
diff options
context:
space:
mode:
Diffstat (limited to 'gerber')
-rw-r--r--gerber/__init__.py3
-rw-r--r--gerber/am_statements.py224
-rw-r--r--gerber/cam.py23
-rw-r--r--gerber/common.py12
-rwxr-xr-xgerber/excellon.py351
-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/exceptions.py36
-rw-r--r--gerber/gerber_statements.py107
-rw-r--r--gerber/ipc356.py89
-rw-r--r--gerber/layers.py246
-rw-r--r--gerber/ncparam/allegro.py25
-rw-r--r--gerber/pcb.py94
-rw-r--r--gerber/primitives.py437
-rw-r--r--gerber/render/cairo_backend.py356
-rw-r--r--gerber/render/excellon_backend.py189
-rw-r--r--gerber/render/render.py53
-rw-r--r--gerber/render/rs274x_backend.py470
-rw-r--r--gerber/render/theme.py70
-rw-r--r--gerber/rs274x.py226
-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_common.py5
-rw-r--r--gerber/tests/test_excellon.py47
-rw-r--r--gerber/tests/test_layers.py33
-rw-r--r--gerber/tests/test_primitives.py67
-rw-r--r--gerber/utils.py39
32 files changed, 3462 insertions, 399 deletions
diff --git a/gerber/__init__.py b/gerber/__init__.py
index b5a9014..5cfdad7 100644
--- a/gerber/__init__.py
+++ b/gerber/__init__.py
@@ -23,4 +23,5 @@ gerber-tools provides utilities for working with Gerber (RS-274X) and Excellon
files in python.
"""
-from .common import read, loads \ No newline at end of file
+from .common import read, loads
+from .pcb import PCB
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 c567055..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):
@@ -243,7 +248,7 @@ class CamFile(object):
"""
pass
- def render(self, ctx, filename=None):
+ def render(self, ctx, invert=False, filename=None):
""" Generate image of layer.
Parameters
@@ -254,12 +259,18 @@ class CamFile(object):
filename : string <optional>
If provided, save the rendered image to `filename`
"""
+
ctx.set_bounds(self.bounds)
ctx._paint_background()
- if ctx.invert:
- ctx._paint_inverted_layer()
+ if invert:
+ ctx.invert = True
+ ctx._clear_mask()
for p in self.primitives:
ctx.render(p)
+ if invert:
+ ctx.invert = False
+ ctx._render_mask()
+
if filename is not None:
ctx.dump(filename)
diff --git a/gerber/common.py b/gerber/common.py
index 1659e3b..04b6423 100644
--- a/gerber/common.py
+++ b/gerber/common.py
@@ -17,9 +17,12 @@
from . import rs274x
from . import excellon
+from . import ipc356
+from .exceptions import ParseError
from .utils import detect_file_format
+
def read(filename):
""" Read a gerber or excellon file and return a representative object.
@@ -35,14 +38,17 @@ def read(filename):
ExcellonFile. Returns None if file is not an Excellon or Gerber file.
"""
with open(filename, 'rU') as f:
- data = f.read()
+ data = f.read()
fmt = detect_file_format(data)
if fmt == 'rs274x':
return rs274x.read(filename)
elif fmt == 'excellon':
return excellon.read(filename)
+ elif fmt == 'ipc_d_356':
+ return ipc356.read(filename)
else:
- raise TypeError('Unable to detect file format')
+ raise ParseError('Unable to detect file format')
+
def loads(data):
""" Read gerber or excellon file contents from a string and return a
@@ -59,7 +65,7 @@ def loads(data):
CncFile object representing the file, either GerberFile or
ExcellonFile. Returns None if file is not an Excellon or Gerber file.
"""
-
+
fmt = detect_file_format(data)
if fmt == 'rs274x':
return rs274x.loads(data)
diff --git a/gerber/excellon.py b/gerber/excellon.py
index 708f50b..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,13 +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
-------
@@ -70,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):
@@ -126,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
@@ -134,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):
@@ -170,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):
"""
@@ -199,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):
@@ -214,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:
@@ -222,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
@@ -282,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'
@@ -290,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
@@ -332,13 +404,13 @@ class ExcellonParser(object):
def parse_raw(self, data, filename=None):
for line in StringIO(data):
- self._parse(line.strip())
+ self._parse_line(line.strip())
for stmt in self.statements:
stmt.units = self.units
return ExcellonFile(self.statements, self.tools, self.hits,
self._settings(), filename)
- def _parse(self, line):
+ def _parse_line(self, line):
# skip empty lines
if not line.strip():
return
@@ -352,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())
@@ -363,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())
@@ -371,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())
@@ -409,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
@@ -423,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':
@@ -451,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())
@@ -470,18 +590,24 @@ 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)
self.statements.append(stmt)
-
+
# 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:
@@ -496,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())
@@ -508,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.
@@ -634,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/exceptions.py b/gerber/exceptions.py
new file mode 100644
index 0000000..65ae905
--- /dev/null
+++ b/gerber/exceptions.py
@@ -0,0 +1,36 @@
+#! /usr/bin/env python
+# -*- coding: utf-8 -*-
+
+# Copyright 2015 Hamilton Kibbe <ham@hamiltonkib.be>
+
+# 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.
+
+
+class ParseError(Exception):
+ pass
+
+
+class GerberParseError(ParseError):
+ pass
+
+
+class ExcellonParseError(ParseError):
+ pass
+
+
+class ExcellonFileError(IOError):
+ pass
+
+
+class GerberFileError(IOError):
+ pass
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/ipc356.py b/gerber/ipc356.py
index b8a7ba3..7dadd22 100644
--- a/gerber/ipc356.py
+++ b/gerber/ipc356.py
@@ -27,8 +27,11 @@ _NNAME = re.compile(r'^NNAME\d+$')
# Board Edge Coordinates
_COORD = re.compile(r'X?(?P<x>[\d\s]*)?Y?(?P<y>[\d\s]*)?')
-_SM_FIELD = {'0': 'none', '1': 'primary side', '2': 'secondary side', '3': 'both'}
-
+_SM_FIELD = {
+ '0': 'none',
+ '1': 'primary side',
+ '2': 'secondary side',
+ '3': 'both'}
def read(filename):
@@ -51,17 +54,17 @@ def read(filename):
class IPC_D_356(CamFile):
@classmethod
- def from_file(self, filename):
- p = IPC_D_356_Parser()
- return p.parse(filename)
-
+ def from_file(cls, filename):
+ parser = IPC_D_356_Parser()
+ return parser.parse(filename)
- def __init__(self, statements, settings, primitives=None):
+ def __init__(self, statements, settings, primitives=None, filename=None):
self.statements = statements
self.units = settings.units
self.angle_units = settings.angle_units
self.primitives = [TestRecord((rec.x_coord, rec.y_coord), rec.net_name,
rec.access) for rec in self.test_records]
+ self.filename = filename
@property
def settings(self):
@@ -95,8 +98,6 @@ class IPC_D_356(CamFile):
adjacent_nets.add(record.net)
nets.append(IPC356_Net(net, adjacent_nets))
return nets
-
-
@property
def components(self):
@@ -109,14 +110,12 @@ class IPC_D_356(CamFile):
@property
def outlines(self):
- return [stmt for stmt in self.statements
+ return [stmt for stmt in self.statements
if isinstance(stmt, IPC356_Outline)]
-
-
@property
def adjacency_records(self):
- return [record for record in self.statements
+ return [record for record in self.statements
if isinstance(record, IPC356_Adjacency)]
def render(self, ctx, layer='both', filename=None):
@@ -133,6 +132,7 @@ class IPC_D_356(CamFile):
class IPC_D_356_Parser(object):
# TODO: Allow multi-line statements (e.g. Altium board edge)
+
def __init__(self):
self.units = 'inch'
self.angle_units = 'degrees'
@@ -158,8 +158,7 @@ class IPC_D_356_Parser(object):
oldline = line
self._parse_line(oldline)
- return IPC_D_356(self.statements, self.settings)
-
+ return IPC_D_356(self.statements, self.settings, filename=filename)
def _parse_line(self, line):
if not len(line):
@@ -201,18 +200,23 @@ class IPC_D_356_Parser(object):
elif line[0:3] == '378':
# Conductor
- self.statements.append(IPC356_Conductor.from_line(line, self.settings))
-
+ self.statements.append(
+ IPC356_Conductor.from_line(
+ line, self.settings))
+
elif line[0:3] == '379':
# Net Adjacency
self.statements.append(IPC356_Adjacency.from_line(line))
-
+
elif line[0:3] == '389':
# Outline
- self.statements.append(IPC356_Outline.from_line(line, self.settings))
+ self.statements.append(
+ IPC356_Outline.from_line(
+ line, self.settings))
class IPC356_Comment(object):
+
@classmethod
def from_line(cls, line):
if line[0] != 'C':
@@ -228,6 +232,7 @@ class IPC356_Comment(object):
class IPC356_Parameter(object):
+
@classmethod
def from_line(cls, line):
if line[0] != 'P':
@@ -246,13 +251,14 @@ class IPC356_Parameter(object):
class IPC356_TestRecord(object):
+
@classmethod
def from_line(cls, line, settings):
offset = 0
units = settings.units
angle = settings.angle_units
- feature_types = {'1':'through-hole', '2': 'smt',
- '3':'tooling-feature', '4':'tooling-hole'}
+ feature_types = {'1': 'through-hole', '2': 'smt',
+ '3': 'tooling-feature', '4': 'tooling-hole'}
access = ['both', 'top', 'layer2', 'layer3', 'layer4', 'layer5',
'layer6', 'layer7', 'bottom']
record = {}
@@ -290,21 +296,21 @@ class IPC356_TestRecord(object):
if len(line) >= (43 + offset):
end = len(line) - 1 if len(line) < (50 + offset) else (49 + offset)
coord = int(line[42 + offset:end].strip())
- record['x_coord'] = (coord * 0.0001 if units == 'inch'
+ record['x_coord'] = (coord * 0.0001 if units == 'inch'
else coord * 0.001)
if len(line) >= (51 + offset):
end = len(line) - 1 if len(line) < (58 + offset) else (57 + offset)
coord = int(line[50 + offset:end].strip())
- record['y_coord'] = (coord * 0.0001 if units == 'inch'
- else coord * 0.001)
+ record['y_coord'] = (coord * 0.0001 if units == 'inch'
+ else coord * 0.001)
if len(line) >= (59 + offset):
end = len(line) - 1 if len(line) < (63 + offset) else (62 + offset)
dim = line[58 + offset:end].strip()
if dim != '':
record['rect_x'] = (int(dim) * 0.0001 if units == 'inch'
- else int(dim) * 0.001)
+ else int(dim) * 0.001)
if len(line) >= (64 + offset):
end = len(line) - 1 if len(line) < (68 + offset) else (67 + offset)
@@ -321,7 +327,7 @@ class IPC356_TestRecord(object):
else math.degrees(rot))
if len(line) >= (74 + offset):
- end = 74 + offset
+ end = 74 + offset
sm_info = line[73 + offset:end].strip()
record['soldermask_info'] = _SM_FIELD.get(sm_info)
@@ -337,7 +343,8 @@ class IPC356_TestRecord(object):
def __repr__(self):
return '<IPC-D-356 %s Test Record: %s>' % (self.net_name,
- self.feature_type)
+ self.feature_type)
+
class IPC356_Outline(object):
@@ -365,24 +372,27 @@ class IPC356_Outline(object):
class IPC356_Conductor(object):
+
@classmethod
def from_line(cls, line, settings):
if line[0:3] != '378':
raise ValueError('Not a valid IPC-D-356 Conductor statement')
-
+
scale = 0.0001 if settings.units == 'inch' else 0.001
net_name = line[3:17].strip()
layer = int(line[19:21])
-
+
# Parse out aperture definiting
raw_aperture = line[22:].split()[0]
aperture_dict = _COORD.match(raw_aperture).groupdict()
x = 0
y = 0
- x = int(aperture_dict['x']) * scale if aperture_dict['x'] is not '' else None
- y = int(aperture_dict['y']) * scale if aperture_dict['y'] is not '' else None
+ x = int(aperture_dict['x']) * \
+ scale if aperture_dict['x'] is not '' else None
+ y = int(aperture_dict['y']) * \
+ scale if aperture_dict['y'] is not '' else None
aperture = (x, y)
-
+
# Parse out conductor shapes
shapes = []
coord_list = ' '.join(line[22:].split()[1:])
@@ -399,7 +409,7 @@ class IPC356_Conductor(object):
shape.append((x * scale, y * scale))
shapes.append(tuple(shape))
return cls(net_name, layer, aperture, tuple(shapes))
-
+
def __init__(self, net_name, layer, aperture, shapes):
self.net_name = net_name
self.layer = layer
@@ -417,18 +427,19 @@ class IPC356_Adjacency(object):
if line[0:3] != '379':
raise ValueError('Not a valid IPC-D-356 Conductor statement')
nets = line[3:].strip().split()
-
+
return cls(nets[0], nets[1:])
def __init__(self, net, adjacent_nets):
self.net = net
self.adjacent_nets = adjacent_nets
-
+
def __repr__(self):
return '<IPC-D-356 %s Adjacency Record>' % self.net
class IPC356_EndOfFile(object):
+
def __init__(self):
pass
@@ -437,12 +448,14 @@ class IPC356_EndOfFile(object):
def __repr__(self):
return '<IPC-D-356 EOF>'
-
+
+
class IPC356_Net(object):
+
def __init__(self, name, adjacent_nets):
self.name = name
- self.adjacent_nets = set(adjacent_nets) if adjacent_nets is not None else set()
-
+ self.adjacent_nets = set(
+ adjacent_nets) if adjacent_nets is not None else set()
def __repr__(self):
return '<IPC-D-356 Net %s>' % self.name
diff --git a/gerber/layers.py b/gerber/layers.py
index b10cf16..2b73893 100644
--- a/gerber/layers.py
+++ b/gerber/layers.py
@@ -15,40 +15,212 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-top_copper_ext = ['gtl', 'cmp', 'top', ]
-top_copper_name = ['art01', 'top', 'GTL', 'layer1', 'soldcom', 'comp', ]
-
-bottom_copper_ext = ['gbl', 'sld', 'bot', 'sol', ]
-bottom_coppper_name = ['art02', 'bottom', 'bot', 'GBL', 'layer2', 'soldsold', ]
-
-internal_layer_ext = ['in', 'gt1', 'gt2', 'gt3', 'gt4', 'gt5', 'gt6', 'g1',
- 'g2', 'g3', 'g4', 'g5', 'g6', ]
-internal_layer_name = ['art', 'internal']
-
-power_plane_name = ['pgp', 'pwr', ]
-ground_plane_name = ['gp1', 'gp2', 'gp3', 'gp4', 'gt5', 'gp6', 'gnd',
- 'ground', ]
-
-top_silk_ext = ['gto', 'sst', 'plc', 'ts', 'skt', ]
-top_silk_name = ['sst01', 'topsilk', 'silk', 'slk', 'sst', ]
-
-bottom_silk_ext = ['gbo', 'ssb', 'pls', 'bs', 'skb', ]
-bottom_silk_name = ['sst', 'bsilk', 'ssb', 'botsilk', ]
-
-top_mask_ext = ['gts', 'stc', 'tmk', 'smt', 'tr', ]
-top_mask_name = ['sm01', 'cmask', 'tmask', 'mask1', 'maskcom', 'topmask',
- 'mst', ]
-
-bottom_mask_ext = ['gbs', 'sts', 'bmk', 'smb', 'br', ]
-bottom_mask_name = ['sm', 'bmask', 'mask2', 'masksold', 'botmask', 'msb', ]
-
-top_paste_ext = ['gtp', 'tm']
-top_paste_name = ['sp01', 'toppaste', 'pst']
-
-bottom_paste_ext = ['gbp', 'bm']
-bottom_paste_name = ['sp02', 'botpaste', 'psb']
-
-board_outline_ext = ['gko']
-board_outline_name = ['BDR', 'border', 'out', ]
-
-
+import os
+import re
+from collections import namedtuple
+
+from .excellon import ExcellonFile
+from .ipc356 import IPC_D_356
+
+
+Hint = namedtuple('Hint', 'layer ext name')
+
+hints = [
+ Hint(layer='top',
+ ext=['gtl', 'cmp', 'top', ],
+ name=['art01', 'top', 'GTL', 'layer1', 'soldcom', 'comp', ]
+ ),
+ Hint(layer='bottom',
+ ext=['gbl', 'sld', 'bot', 'sol', 'bottom', ],
+ name=['art02', 'bottom', 'bot', 'GBL', 'layer2', 'soldsold', ]
+ ),
+ Hint(layer='internal',
+ ext=['in', 'gt1', 'gt2', 'gt3', 'gt4', 'gt5', 'gt6', 'g1',
+ 'g2', 'g3', 'g4', 'g5', 'g6', ],
+ name=['art', 'internal', 'pgp', 'pwr', 'gp1', 'gp2', 'gp3', 'gp4',
+ 'gt5', 'gp6', 'gnd', 'ground', ]
+ ),
+ Hint(layer='topsilk',
+ ext=['gto', 'sst', 'plc', 'ts', 'skt', 'topsilk', ],
+ name=['sst01', 'topsilk', 'silk', 'slk', 'sst', ]
+ ),
+ Hint(layer='bottomsilk',
+ ext=['gbo', 'ssb', 'pls', 'bs', 'skb', 'bottomsilk', ],
+ name=['bsilk', 'ssb', 'botsilk', ]
+ ),
+ Hint(layer='topmask',
+ ext=['gts', 'stc', 'tmk', 'smt', 'tr', 'topmask', ],
+ name=['sm01', 'cmask', 'tmask', 'mask1', 'maskcom', 'topmask',
+ 'mst', ]
+ ),
+ Hint(layer='bottommask',
+ ext=['gbs', 'sts', 'bmk', 'smb', 'br', 'bottommask', ],
+ name=['sm', 'bmask', 'mask2', 'masksold', 'botmask', 'msb', ]
+ ),
+ Hint(layer='toppaste',
+ ext=['gtp', 'tm', 'toppaste', ],
+ name=['sp01', 'toppaste', 'pst']
+ ),
+ Hint(layer='bottompaste',
+ ext=['gbp', 'bm', 'bottompaste', ],
+ name=['sp02', 'botpaste', 'psb']
+ ),
+ Hint(layer='outline',
+ ext=['gko', 'outline', ],
+ name=['BDR', 'border', 'out', ]
+ ),
+ Hint(layer='ipc_netlist',
+ ext=['ipc'],
+ name=[],
+ ),
+]
+
+
+def guess_layer_class(filename):
+ try:
+ directory, name = os.path.split(filename)
+ name, ext = os.path.splitext(name.lower())
+ for hint in hints:
+ patterns = [r'^(\w*[.-])*{}([.-]\w*)?$'.format(x) for x in hint.name]
+ if ext[1:] in hint.ext or any(re.findall(p, name, re.IGNORECASE) for p in patterns):
+ return hint.layer
+ except:
+ pass
+ return 'unknown'
+
+
+def sort_layers(layers):
+ layer_order = ['outline', 'toppaste', 'topsilk', 'topmask', 'top',
+ 'internal', 'bottom', 'bottommask', 'bottomsilk',
+ 'bottompaste', 'drill', ]
+ output = []
+ drill_layers = [layer for layer in layers if layer.layer_class == 'drill']
+ internal_layers = list(sorted([layer for layer in layers if layer.layer_class == 'internal']))
+
+ for layer_class in layer_order:
+ if layer_class == 'internal':
+ output += internal_layers
+ elif layer_class == 'drill':
+ output += drill_layers
+ else:
+ for layer in layers:
+ if layer.layer_class == layer_class:
+ output.append(layer)
+ return output
+
+
+class PCBLayer(object):
+ """ Base class for PCB Layers
+
+ Parameters
+ ----------
+ source : CAMFile
+ CAMFile representing the layer
+
+
+ Attributes
+ ----------
+ filename : string
+ Source Filename
+
+ """
+ @classmethod
+ def from_gerber(cls, camfile):
+ filename = camfile.filename
+ layer_class = guess_layer_class(filename)
+ if isinstance(camfile, ExcellonFile) or (layer_class == 'drill'):
+ return DrillLayer.from_gerber(camfile)
+ elif layer_class == 'internal':
+ return InternalLayer.from_gerber(camfile)
+ if isinstance(camfile, IPC_D_356):
+ layer_class = 'ipc_netlist'
+ return cls(filename, layer_class, camfile)
+
+ def __init__(self, filename=None, layer_class=None, cam_source=None, **kwargs):
+ super(PCBLayer, self).__init__(**kwargs)
+ self.filename = filename
+ self.layer_class = layer_class
+ self.cam_source = cam_source
+ self.surface = None
+ self.primitives = cam_source.primitives if cam_source is not None else []
+
+ @property
+ def bounds(self):
+ if self.cam_source is not None:
+ return self.cam_source.bounds
+ else:
+ return None
+
+
+class DrillLayer(PCBLayer):
+ @classmethod
+ def from_gerber(cls, camfile):
+ return cls(camfile.filename, camfile)
+
+ def __init__(self, filename=None, cam_source=None, layers=None, **kwargs):
+ super(DrillLayer, self).__init__(filename, 'drill', cam_source, **kwargs)
+ self.layers = layers if layers is not None else ['top', 'bottom']
+
+
+class InternalLayer(PCBLayer):
+ @classmethod
+ def from_gerber(cls, camfile):
+ filename = camfile.filename
+ try:
+ order = int(re.search(r'\d+', filename).group())
+ except:
+ order = 0
+ return cls(filename, camfile, order)
+
+ def __init__(self, filename=None, cam_source=None, order=0, **kwargs):
+ super(InternalLayer, self).__init__(filename, 'internal', cam_source, **kwargs)
+ self.order = order
+
+ def __eq__(self, other):
+ if not hasattr(other, 'order'):
+ raise TypeError()
+ return (self.order == other.order)
+
+ def __ne__(self, other):
+ if not hasattr(other, 'order'):
+ raise TypeError()
+ return (self.order != other.order)
+
+ def __gt__(self, other):
+ if not hasattr(other, 'order'):
+ raise TypeError()
+ return (self.order > other.order)
+
+ def __lt__(self, other):
+ if not hasattr(other, 'order'):
+ raise TypeError()
+ return (self.order < other.order)
+
+ def __ge__(self, other):
+ if not hasattr(other, 'order'):
+ raise TypeError()
+ return (self.order >= other.order)
+
+ def __le__(self, other):
+ if not hasattr(other, 'order'):
+ raise TypeError()
+ return (self.order <= other.order)
+
+
+class LayerSet(object):
+ def __init__(self, name, layers, **kwargs):
+ super(LayerSet, self).__init__(**kwargs)
+ self.name = name
+ self.layers = list(layers)
+
+ def __len__(self):
+ return len(self.layers)
+
+ def __getitem__(self, item):
+ return self.layers[item]
+
+ def to_render(self):
+ return self.layers
+
+ def apply_theme(self, theme):
+ pass
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/pcb.py b/gerber/pcb.py
new file mode 100644
index 0000000..0518dd4
--- /dev/null
+++ b/gerber/pcb.py
@@ -0,0 +1,94 @@
+#! /usr/bin/env python
+# -*- coding: utf-8 -*-
+
+# copyright 2015 Hamilton Kibbe <ham@hamiltonkib.be>
+#
+# 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.
+
+
+import os
+from .exceptions import ParseError
+from .layers import PCBLayer, LayerSet, sort_layers
+from .common import read as gerber_read
+from .utils import listdir
+
+
+class PCB(object):
+
+ @classmethod
+ def from_directory(cls, directory, board_name=None, verbose=False):
+ layers = []
+ names = set()
+ # Validate
+ directory = os.path.abspath(directory)
+ if not os.path.isdir(directory):
+ raise TypeError('{} is not a directory.'.format(directory))
+ # Load gerber files
+ for filename in listdir(directory, True, True):
+ try:
+ camfile = gerber_read(os.path.join(directory, filename))
+ layer = PCBLayer.from_gerber(camfile)
+ layers.append(layer)
+ names.add(os.path.splitext(filename)[0])
+ if verbose:
+ print('Added {} layer <{}>'.format(layer.layer_class, filename))
+ except ParseError:
+ if verbose:
+ print('Skipping file {}'.format(filename))
+ # Try to guess board name
+ if board_name is None:
+ if len(names) == 1:
+ board_name = names.pop()
+ else:
+ board_name = os.path.basename(directory)
+ # Return PCB
+ return cls(layers, board_name)
+
+ def __init__(self, layers, name=None):
+ self.layers = sort_layers(layers)
+ self.name = name
+
+ def __len__(self):
+ return len(self.layers)
+
+ @property
+ def top_layers(self):
+ board_layers = [l for l in reversed(self.layers) if l.layer_class in ('topsilk', 'topmask', 'top')]
+ drill_layers = [l for l in self.drill_layers if 'top' in l.layers]
+ return board_layers + drill_layers
+
+ @property
+ def bottom_layers(self):
+ board_layers = [l for l in self.layers if l.layer_class in ('bottomsilk', 'bottommask', 'bottom')]
+ drill_layers = [l for l in self.drill_layers if 'bottom' in l.layers]
+ return board_layers + drill_layers
+
+ @property
+ def drill_layers(self):
+ return [l for l in self.layers if l.layer_class == 'drill']
+
+ @property
+ def layer_count(self):
+ """ Number of *COPPER* layers
+ """
+ return len([l for l in self.layers if l.layer_class in ('top', 'bottom', 'internal')])
+
+ @property
+ def board_bounds(self):
+ for layer in self.layers:
+ if layer.layer_class == 'outline':
+ return layer.bounds
+ for layer in self.layers:
+ if layer.layer_class == 'top':
+ return layer.bounds
+
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 345f331..0cb230b 100644
--- a/gerber/render/cairo_backend.py
+++ b/gerber/render/cairo_backend.py
@@ -13,18 +13,27 @@
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
-# limitations under the License.
-
-from .render import GerberContext
-
-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
+from .render import GerberContext, RenderSettings
+from .theme import THEMES
from ..primitives import *
+try:
+ from cStringIO import StringIO
+except(ImportError):
+ from io import StringIO
+
+
class GerberCairoContext(GerberContext):
def __init__(self, scale=300):
GerberContext.__init__(self)
@@ -32,42 +41,116 @@ class GerberCairoContext(GerberContext):
self.surface = None
self.ctx = None
self.bg = False
-
- def set_bounds(self, bounds):
+ self.mask = None
+ self.mask_ctx = None
+ self.origin_in_inch = None
+ self.size_in_inch = None
+ self._xform_matrix = None
+
+ @property
+ def origin_in_pixels(self):
+ return tuple(map(mul, self.origin_in_inch, self.scale)) if self.origin_in_inch is not None else (0.0, 0.0)
+
+ @property
+ def size_in_pixels(self):
+ return tuple(map(mul, self.size_in_inch, self.scale)) if self.size_in_inch is not None else (0.0, 0.0)
+
+ def set_bounds(self, bounds, new_surface=False):
origin_in_inch = (bounds[0][0], bounds[1][0])
size_in_inch = (abs(bounds[0][1] - bounds[0][0]), abs(bounds[1][1] - bounds[1][0]))
- size_in_pixels = map(mul, size_in_inch, self.scale)
-
- if self.surface is None:
+ size_in_pixels = tuple(map(mul, size_in_inch, self.scale))
+ self.origin_in_inch = origin_in_inch if self.origin_in_inch is None else self.origin_in_inch
+ self.size_in_inch = size_in_inch if self.size_in_inch is None else self.size_in_inch
+ if (self.surface is None) or new_surface:
self.surface_buffer = tempfile.NamedTemporaryFile()
self.surface = cairo.SVGSurface(self.surface_buffer, size_in_pixels[0], size_in_pixels[1])
self.ctx = cairo.Context(self.surface)
self.ctx.set_fill_rule(cairo.FILL_RULE_EVEN_ODD)
self.ctx.scale(1, -1)
self.ctx.translate(-(origin_in_inch[0] * self.scale[0]), (-origin_in_inch[1]*self.scale[0]) - size_in_pixels[1])
- # self.ctx.translate(-(origin_in_inch[0] * self.scale[0]), -origin_in_inch[1]*self.scale[1])
+ self.mask = cairo.SVGSurface(None, size_in_pixels[0], size_in_pixels[1])
+ self.mask_ctx = cairo.Context(self.mask)
+ self.mask_ctx.set_fill_rule(cairo.FILL_RULE_EVEN_ODD)
+ self.mask_ctx.scale(1, -1)
+ self.mask_ctx.translate(-(origin_in_inch[0] * self.scale[0]), (-origin_in_inch[1]*self.scale[0]) - size_in_pixels[1])
+ self._xform_matrix = cairo.Matrix(xx=1.0, yy=-1.0, x0=-self.origin_in_pixels[0], y0=self.size_in_pixels[1] + self.origin_in_pixels[1])
+
+ def render_layers(self, layers, filename, theme=THEMES['default']):
+ """ Render a set of layers
+ """
+ self.set_bounds(layers[0].bounds, True)
+ self._paint_background(True)
+ for layer in layers:
+ self._render_layer(layer, theme)
+ self.dump(filename)
+
+ def dump(self, filename):
+ """ Save image as `filename`
+ """
+ if filename and filename.lower().endswith(".svg"):
+ self.surface.finish()
+ self.surface_buffer.flush()
+ with open(filename, "w") as f:
+ self.surface_buffer.seek(0)
+ f.write(self.surface_buffer.read())
+ f.flush()
+ else:
+ return self.surface.write_to_png(filename)
+
+ def dump_str(self):
+ """ Return a string containing the rendered image.
+ """
+ fobj = StringIO()
+ self.surface.write_to_png(fobj)
+ return fobj.getvalue()
+
+ def dump_svg_str(self):
+ """ Return a string containg the rendered SVG.
+ """
+ self.surface.finish()
+ self.surface_buffer.flush()
+ return self.surface_buffer.read()
+
+ def _render_layer(self, layer, theme=THEMES['default']):
+ settings = theme.get(layer.layer_class, RenderSettings())
+ self.color = settings.color
+ self.alpha = settings.alpha
+ self.invert = settings.invert
+ if settings.mirror:
+ raise Warning('mirrored layers aren\'t supported yet...')
+ if self.invert:
+ self._clear_mask()
+ for prim in layer.primitives:
+ self.render(prim)
+ if self.invert:
+ self._render_mask()
def _render_line(self, line, color):
start = map(mul, line.start, self.scale)
end = map(mul, line.end, self.scale)
+ 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 line.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 isinstance(line.aperture, Circle):
width = line.aperture.diameter
- self.ctx.set_source_rgba(*color, alpha=self.alpha)
- self.ctx.set_operator(cairo.OPERATOR_OVER if (line.level_polarity == "dark" and not self.invert) else cairo.OPERATOR_CLEAR)
- self.ctx.set_line_width(width * self.scale[0])
- self.ctx.set_line_cap(cairo.LINE_CAP_ROUND)
- self.ctx.move_to(*start)
- self.ctx.line_to(*end)
- self.ctx.stroke()
+ 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()
elif isinstance(line.aperture, Rectangle):
points = [tuple(map(mul, x, self.scale)) for x in line.vertices]
- self.ctx.set_source_rgba(*color, alpha=self.alpha)
- self.ctx.set_operator(cairo.OPERATOR_OVER if (line.level_polarity == "dark" and not self.invert) else cairo.OPERATOR_CLEAR)
- self.ctx.set_line_width(0)
- self.ctx.move_to(*points[0])
+ ctx.set_line_width(0)
+ ctx.move_to(*points[0])
for point in points[1:]:
- self.ctx.line_to(*point)
- self.ctx.fill()
+ ctx.line_to(*point)
+ ctx.fill()
def _render_arc(self, arc, color):
center = map(mul, arc.center, self.scale)
@@ -76,27 +159,49 @@ 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
- self.ctx.set_source_rgba(*color, alpha=self.alpha)
- self.ctx.set_operator(cairo.OPERATOR_OVER if (arc.level_polarity == "dark" and not self.invert)else cairo.OPERATOR_CLEAR)
- self.ctx.set_line_width(width * self.scale[0])
- self.ctx.set_line_cap(cairo.LINE_CAP_ROUND)
- self.ctx.move_to(*start) # You actually have to do this...
+ 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[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':
- self.ctx.arc(*center, radius=radius, angle1=angle1, angle2=angle2)
+ ctx.arc(center[0], center[1], radius, angle1, angle2)
else:
- self.ctx.arc_negative(*center, radius=radius, angle1=angle1, angle2=angle2)
- self.ctx.move_to(*end) # ...lame
+ ctx.arc_negative(center[0], center[1], radius, angle1, angle2)
+ ctx.move_to(*end) # ...lame
+ ctx.stroke()
def _render_region(self, region, color):
- self.ctx.set_source_rgba(*color, alpha=self.alpha)
- self.ctx.set_operator(cairo.OPERATOR_OVER if (region.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)
- self.ctx.move_to(*tuple(map(mul, region.primitives[0].start, self.scale)))
+ 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 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)))
for p in region.primitives:
if isinstance(p, Line):
- self.ctx.line_to(*tuple(map(mul, p.end, self.scale)))
+ ctx.line_to(*tuple(map(mul, p.end, self.scale)))
else:
center = map(mul, p.center, self.scale)
start = map(mul, p.start, self.scale)
@@ -105,76 +210,151 @@ class GerberCairoContext(GerberContext):
angle1 = p.start_angle
angle2 = p.end_angle
if p.direction == 'counterclockwise':
- self.ctx.arc(*center, radius=radius, angle1=angle1, angle2=angle2)
+ ctx.arc(center[0], center[1], radius, angle1, angle2)
else:
- self.ctx.arc_negative(*center, radius=radius, angle1=angle1, angle2=angle2)
- self.ctx.fill()
+ ctx.arc_negative(center[0], center[1], radius, angle1, angle2)
+ ctx.fill()
def _render_circle(self, circle, color):
center = tuple(map(mul, circle.position, self.scale))
- self.ctx.set_source_rgba(*color, alpha=self.alpha)
- self.ctx.set_operator(cairo.OPERATOR_OVER if (circle.level_polarity == "dark" and not self.invert) else cairo.OPERATOR_CLEAR)
- self.ctx.set_line_width(0)
- self.ctx.arc(*center, radius=circle.radius * self.scale[0], angle1=0, angle2=2 * math.pi)
- self.ctx.fill()
+ if not self.invert:
+ ctx = self.ctx
+ ctx.set_source_rgba(*color, alpha=self.alpha)
+ ctx.set_operator(cairo.OPERATOR_OVER if circle.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.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)))
- self.ctx.set_source_rgba(*color, alpha=self.alpha)
- self.ctx.set_operator(cairo.OPERATOR_OVER if (rectangle.level_polarity == "dark" and not self.invert) else cairo.OPERATOR_CLEAR)
- self.ctx.set_line_width(0)
- self.ctx.rectangle(*ll,width=width, height=height)
- self.ctx.fill()
+
+ 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 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[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):
- self.ctx.select_font_face('monospace', cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL)
- self.ctx.set_font_size(200)
- self._render_circle(Circle(primitive.position, 0.01), color)
- self.ctx.set_source_rgb(*color)
- self.ctx.set_operator(cairo.OPERATOR_OVER if (primitive.level_polarity == "dark" and not self.invert) else cairo.OPERATOR_CLEAR)
- self.ctx.move_to(*[self.scale[0] * (coord + 0.01) for coord in primitive.position])
+ position = tuple(map(add, primitive.position, self.origin_in_inch))
+ self.ctx.set_operator(cairo.OPERATOR_OVER)
+ self.ctx.select_font_face('monospace', cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_BOLD)
+ self.ctx.set_font_size(13)
+ self._render_circle(Circle(position, 0.015), color)
+ self.ctx.set_source_rgba(*color, alpha=self.alpha)
+ self.ctx.set_operator(cairo.OPERATOR_OVER if primitive.level_polarity == "dark" else cairo.OPERATOR_CLEAR)
+ self.ctx.move_to(*[self.scale[0] * (coord + 0.015) for coord in position])
self.ctx.scale(1, -1)
self.ctx.show_text(primitive.net_name)
self.ctx.scale(1, -1)
+
+ def _clear_mask(self):
+ self.mask_ctx.set_operator(cairo.OPERATOR_OVER)
+ 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 _paint_inverted_layer(self):
- self.ctx.set_source_rgba(*self.background_color)
+ def _render_mask(self):
self.ctx.set_operator(cairo.OPERATOR_OVER)
+ ptn = cairo.SurfacePattern(self.mask)
+ ptn.set_matrix(self._xform_matrix)
+ self.ctx.set_source(ptn)
self.ctx.paint()
- self.ctx.set_operator(cairo.OPERATOR_CLEAR)
- def _paint_background(self):
- if not self.bg:
- self.bg = True
- self.ctx.set_source_rgba(*self.background_color)
+ def _paint_background(self, force=False):
+ if (not self.bg) or force:
+ 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()
-
- def dump(self, filename):
- is_svg = filename.lower().endswith(".svg")
-
- if is_svg:
- self.surface.finish()
- self.surface_buffer.flush()
-
- with open(filename, "w") as f:
- self.surface_buffer.seek(0)
- f.write(self.surface_buffer.read())
- f.flush()
-
- else:
- self.surface.write_to_png(filename)
-
- def dump_svg_str(self):
- self.surface.finish()
- self.surface_buffer.flush()
- return self.surface_buffer.read()
- \ No newline at end of file
+
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 8f49796..f521c44 100644
--- a/gerber/render/render.py
+++ b/gerber/render/render.py
@@ -23,12 +23,13 @@ Rendering
Render Gerber and Excellon files to a variety of formats. The render module
currently supports SVG rendering using the `svgwrite` library.
"""
+
+
+from ..primitives import *
from ..gerber_statements import (CommentStmt, UnknownStmt, EofStmt, ParamStmt,
CoordStmt, ApertureStmt, RegionModeStmt,
- QuadrantModeStmt,
-)
+ QuadrantModeStmt,)
-from ..primitives import *
class GerberContext(object):
""" Gerber rendering context base class
@@ -59,7 +60,6 @@ class GerberContext(object):
def __init__(self, units='inch'):
self._units = units
self._color = (0.7215, 0.451, 0.200)
- self._drill_color = (0.25, 0.25, 0.25)
self._background_color = (0.0, 0.0, 0.0)
self._alpha = 1.0
self._invert = False
@@ -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, self.drill_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,7 +203,21 @@ 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
+
+class RenderSettings(object):
+ def __init__(self, color=(0.0, 0.0, 0.0), alpha=1.0, invert=False, mirror=False):
+ self.color = color
+ self.alpha = alpha
+ self.invert = invert
+ self.mirror = mirror
+
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/render/theme.py b/gerber/render/theme.py
new file mode 100644
index 0000000..e538df8
--- /dev/null
+++ b/gerber/render/theme.py
@@ -0,0 +1,70 @@
+#! /usr/bin/env python
+# -*- coding: utf-8 -*-
+
+# Copyright 2013-2014 Paulo Henrique Silva <ph.silva@gmail.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.
+
+
+from .render import RenderSettings
+
+COLORS = {
+ 'black': (0.0, 0.0, 0.0),
+ 'white': (1.0, 1.0, 1.0),
+ 'red': (1.0, 0.0, 0.0),
+ 'green': (0.0, 1.0, 0.0),
+ 'blue' : (0.0, 0.0, 1.0),
+ 'fr-4': (0.290, 0.345, 0.0),
+ 'green soldermask': (0.0, 0.612, 0.396),
+ 'blue soldermask': (0.059, 0.478, 0.651),
+ 'red soldermask': (0.968, 0.169, 0.165),
+ 'black soldermask': (0.298, 0.275, 0.282),
+ 'purple soldermask': (0.2, 0.0, 0.334),
+ 'enig copper': (0.686, 0.525, 0.510),
+ 'hasl copper': (0.871, 0.851, 0.839)
+}
+
+
+class Theme(object):
+ def __init__(self, name=None, **kwargs):
+ self.name = 'Default' if name is None else name
+ self.background = kwargs.get('background', RenderSettings(COLORS['black'], alpha=0.0))
+ self.topsilk = kwargs.get('topsilk', RenderSettings(COLORS['white']))
+ self.bottomsilk = kwargs.get('bottomsilk', RenderSettings(COLORS['white']))
+ self.topmask = kwargs.get('topmask', RenderSettings(COLORS['green soldermask'], alpha=0.8, invert=True))
+ self.bottommask = kwargs.get('bottommask', RenderSettings(COLORS['green soldermask'], alpha=0.8, invert=True))
+ self.top = kwargs.get('top', RenderSettings(COLORS['hasl copper']))
+ self.bottom = kwargs.get('top', RenderSettings(COLORS['hasl copper']))
+ self.drill = kwargs.get('drill', RenderSettings(COLORS['black']))
+ self.ipc_netlist = kwargs.get('ipc_netlist', RenderSettings(COLORS['red']))
+
+ def __getitem__(self, key):
+ return getattr(self, key)
+
+ def get(self, key, noneval=None):
+ val = getattr(self, key)
+ return val if val is not None else noneval
+
+
+THEMES = {
+ 'default': Theme(),
+ 'OSH Park': Theme(name='OSH Park',
+ top=RenderSettings(COLORS['enig copper']),
+ bottom=RenderSettings(COLORS['enig copper']),
+ topmask=RenderSettings(COLORS['purple soldermask'], alpha=0.8, invert=True),
+ bottommask=RenderSettings(COLORS['purple soldermask'], alpha=0.8, invert=True)),
+ 'Blue': Theme(name='Blue',
+ topmask=RenderSettings(COLORS['blue soldermask'], alpha=0.8, invert=True),
+ bottommask=RenderSettings(COLORS['blue soldermask'], alpha=0.8, invert=True)),
+}
+
diff --git a/gerber/rs274x.py b/gerber/rs274x.py
index 9fd63da..260fbf8 100644
--- a/gerber/rs274x.py
+++ b/gerber/rs274x.py
@@ -21,15 +21,17 @@
import copy
import json
import re
+import sys
try:
from cStringIO import StringIO
except(ImportError):
from io import StringIO
-
+
from .gerber_statements import *
from .primitives import *
from .cam import CamFile, FileSettings
+from .utils import sq_distance
def read(filename):
@@ -49,8 +51,21 @@ def read(filename):
def loads(data):
+ """ Generate a GerberFile object from rs274x data in memory
+
+ Parameters
+ ----------
+ data : string
+ string containing gerber file contents
+
+ Returns
+ -------
+ file : :class:`gerber.rs274x.GerberFile`
+ A GerberFile created from the specified file.
+ """
return GerberParser().parse_raw(data)
+
class GerberFile(CamFile):
""" A class representing a single gerber file
@@ -80,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):
@@ -97,15 +114,27 @@ class GerberFile(CamFile):
def bounds(self):
min_x = min_y = 1000000
max_x = max_y = -1000000
-
for stmt in [stmt for stmt in self.statements if isinstance(stmt, CoordStmt)]:
if stmt.x is not None:
min_x = min(stmt.x, min_x)
max_x = max(stmt.x, max_x)
-
if stmt.y is not None:
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))
@@ -148,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
@@ -215,11 +244,10 @@ class GerberParser(object):
def parse(self, filename):
with open(filename, "rU") as fp:
data = fp.read()
- return self.parse_raw(data, filename=None)
+ 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)
@@ -227,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]}
@@ -242,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
@@ -254,27 +313,16 @@ class GerberParser(object):
oldline = line
continue
-
did_something = True # make sure we do at least one loop
while did_something and len(line) > 0:
did_something = False
-
- # Region Mode
- (mode, r) = _match_one(self.REGION_MODE_STMT, line)
- if mode:
- yield RegionModeStmt.from_gerber(line)
- line = r
+
+ # consume empty data blocks
+ if line[0] == '*':
+ line = line[1:]
did_something = True
continue
-
- # Quadrant Mode
- (mode, r) = _match_one(self.QUAD_MODE_STMT, line)
- if mode:
- yield QuadrantModeStmt.from_gerber(line)
- line = r
- did_something = True
- continue
-
+
# coord
(coord, r) = _match_one(self.COORD_STMT, line)
if coord:
@@ -282,20 +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
-
- # comment
- (comment, r) = _match_one(self.COMMENT_STMT, line)
- if comment:
- yield CommentStmt(comment["comment"])
did_something = True
line = r
continue
@@ -319,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":
@@ -350,6 +391,30 @@ class GerberParser(object):
line = r
continue
+ # Region Mode
+ (mode, r) = _match_one(self.REGION_MODE_STMT, line)
+ if mode:
+ yield RegionModeStmt.from_gerber(line)
+ line = r
+ did_something = True
+ continue
+
+ # Quadrant Mode
+ (mode, r) = _match_one(self.QUAD_MODE_STMT, line)
+ if mode:
+ yield QuadrantModeStmt.from_gerber(line)
+ line = r
+ did_something = True
+ continue
+
+ # comment
+ (comment, r) = _match_one(self.COMMENT_STMT, line)
+ if comment:
+ yield CommentStmt(comment["comment"])
+ did_something = True
+ line = r
+ continue
+
# deprecated codes
(deprecated_unit, r) = _match_one(self.DEPRECATED_UNIT, line)
if deprecated_unit:
@@ -417,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)
@@ -437,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':
@@ -468,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
@@ -475,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)
@@ -486,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:
@@ -493,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:
@@ -519,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_common.py b/gerber/tests/test_common.py
index 7c66c0f..5991e5e 100644
--- a/gerber/tests/test_common.py
+++ b/gerber/tests/test_common.py
@@ -2,6 +2,7 @@
# -*- coding: utf-8 -*-
# Author: Hamilton Kibbe <ham@hamiltonkib.be>
+from ..exceptions import ParseError
from ..common import read, loads
from ..excellon import ExcellonFile
from ..rs274x import GerberFile
@@ -31,12 +32,12 @@ def test_load_from_string():
top_copper = loads(f.read())
assert_true(isinstance(ncdrill, ExcellonFile))
assert_true(isinstance(top_copper, GerberFile))
-
+
def test_file_type_validation():
""" Test file format validation
"""
- assert_raises(TypeError, read, 'LICENSE')
+ assert_raises(ParseError, read, 'LICENSE')
diff --git a/gerber/tests/test_excellon.py b/gerber/tests/test_excellon.py
index 705adc3..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():
@@ -98,60 +99,60 @@ def test_parser_hole_sizes():
def test_parse_whitespace():
p = ExcellonParser(FileSettings())
- assert_equal(p._parse(' '), None)
+ assert_equal(p._parse_line(' '), None)
def test_parse_comment():
p = ExcellonParser(FileSettings())
- p._parse(';A comment')
+ p._parse_line(';A comment')
assert_equal(p.statements[0].comment, 'A comment')
def test_parse_format_comment():
p = ExcellonParser(FileSettings())
- p._parse('; FILE_FORMAT=9:9 ')
+ p._parse_line('; FILE_FORMAT=9:9 ')
assert_equal(p.format, (9, 9))
def test_parse_header():
p = ExcellonParser(FileSettings())
- p._parse('M48 ')
+ p._parse_line('M48 ')
assert_equal(p.state, 'HEADER')
- p._parse('M95 ')
+ p._parse_line('M95 ')
assert_equal(p.state, 'DRILL')
def test_parse_rout():
p = ExcellonParser(FileSettings())
- p._parse('G00 ')
+ p._parse_line('G00 ')
assert_equal(p.state, 'ROUT')
- p._parse('G05 ')
+ p._parse_line('G05 ')
assert_equal(p.state, 'DRILL')
def test_parse_version():
p = ExcellonParser(FileSettings())
- p._parse('VER,1 ')
+ p._parse_line('VER,1 ')
assert_equal(p.statements[0].version, 1)
- p._parse('VER,2 ')
+ p._parse_line('VER,2 ')
assert_equal(p.statements[1].version, 2)
def test_parse_format():
p = ExcellonParser(FileSettings())
- p._parse('FMAT,1 ')
+ p._parse_line('FMAT,1 ')
assert_equal(p.statements[0].format, 1)
- p._parse('FMAT,2 ')
+ p._parse_line('FMAT,2 ')
assert_equal(p.statements[1].format, 2)
def test_parse_units():
settings = FileSettings(units='inch', zeros='trailing')
p = ExcellonParser(settings)
- p._parse(';METRIC,LZ')
+ p._parse_line(';METRIC,LZ')
assert_equal(p.units, 'inch')
assert_equal(p.zeros, 'trailing')
- p._parse('METRIC,LZ')
+ p._parse_line('METRIC,LZ')
assert_equal(p.units, 'metric')
assert_equal(p.zeros, 'leading')
@@ -160,9 +161,9 @@ def test_parse_incremental_mode():
settings = FileSettings(units='inch', zeros='trailing')
p = ExcellonParser(settings)
assert_equal(p.notation, 'absolute')
- p._parse('ICI,ON ')
+ p._parse_line('ICI,ON ')
assert_equal(p.notation, 'incremental')
- p._parse('ICI,OFF ')
+ p._parse_line('ICI,OFF ')
assert_equal(p.notation, 'absolute')
@@ -170,29 +171,29 @@ def test_parse_absolute_mode():
settings = FileSettings(units='inch', zeros='trailing')
p = ExcellonParser(settings)
assert_equal(p.notation, 'absolute')
- p._parse('ICI,ON ')
+ p._parse_line('ICI,ON ')
assert_equal(p.notation, 'incremental')
- p._parse('G90 ')
+ p._parse_line('G90 ')
assert_equal(p.notation, 'absolute')
def test_parse_repeat_hole():
p = ExcellonParser(FileSettings())
p.active_tool = ExcellonTool(FileSettings(), number=8)
- p._parse('R03X1.5Y1.5')
+ p._parse_line('R03X1.5Y1.5')
assert_equal(p.statements[0].count, 3)
def test_parse_incremental_position():
p = ExcellonParser(FileSettings(notation='incremental'))
- p._parse('X01Y01')
- p._parse('X01Y01')
+ p._parse_line('X01Y01')
+ p._parse_line('X01Y01')
assert_equal(p.pos, [2.,2.])
def test_parse_unknown():
p = ExcellonParser(FileSettings())
- p._parse('Not A Valid Statement')
+ p._parse_line('Not A Valid Statement')
assert_equal(p.statements[0].stmt, 'Not A Valid Statement')
diff --git a/gerber/tests/test_layers.py b/gerber/tests/test_layers.py
new file mode 100644
index 0000000..c77084d
--- /dev/null
+++ b/gerber/tests/test_layers.py
@@ -0,0 +1,33 @@
+#! /usr/bin/env python
+# -*- coding: utf-8 -*-
+
+# Author: Hamilton Kibbe <ham@hamiltonkib.be>
+
+from .tests import *
+from ..layers import guess_layer_class, hints
+
+
+def test_guess_layer_class():
+ """ Test layer type inferred correctly from filename
+ """
+
+ # Add any specific test cases here (filename, layer_class)
+ test_vectors = [(None, 'unknown'), ('NCDRILL.TXT', 'unknown'),
+ ('example_board.gtl', 'top'),
+ ('exampmle_board.sst', 'topsilk'),
+ ('ipc-d-356.ipc', 'ipc_netlist'),]
+
+ for hint in hints:
+ for ext in hint.ext:
+ assert_equal(hint.layer, guess_layer_class('board.{}'.format(ext)))
+ for name in hint.name:
+ assert_equal(hint.layer, guess_layer_class('{}.pho'.format(name)))
+
+ for filename, layer_class in test_vectors:
+ assert_equal(layer_class, guess_layer_class(filename))
+
+
+def test_sort_layers():
+ """ Test layer ordering
+ """
+ pass
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 1c0af52..b968dc8 100644
--- a/gerber/utils.py
+++ b/gerber/utils.py
@@ -26,6 +26,7 @@ files.
# Author: Hamilton Kibbe <ham@hamiltonkib.be>
# License:
+import os
from math import radians, sin, cos
from operator import sub
@@ -219,7 +220,10 @@ def detect_file_format(data):
if 'M48' in line:
return 'excellon'
elif '%FS' in line:
- return'rs274x'
+ return 'rs274x'
+ elif ((len(line.split()) >= 2) and
+ (line.split()[0] == 'P') and (line.split()[1] == 'JOB')):
+ return 'ipc_d_356'
return 'unknown'
@@ -284,7 +288,32 @@ 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):
+ os_files = ('.DS_Store', 'Thumbs.db', 'ethumbs.db')
+ files = os.listdir(directory)
+ if ignore_hidden:
+ files = [f for f in files if not f.startswith('.')]
+ if ignore_os:
+ files = [f for f in files if not f in os_files]
+ return files