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