diff options
Diffstat (limited to 'gerber')
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 Binary files differnew file mode 100644 index 0000000..4732995 --- /dev/null +++ b/gerber/tests/golden/example_two_square_boxes.png 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 |