Merge pull request #58 from garretfick/merge-curtacircuitos

Epic merge from @garretfick 

Thanks a lot @garretfick and @hamiltonkibbe.
This commit is contained in:
Paulo Henrique Silva 2016-11-16 23:54:09 -02:00 committed by GitHub
commit 521fe89150
76 changed files with 3779 additions and 239 deletions

6
.gitignore vendored
View file

@ -34,7 +34,10 @@ nosetests.xml
.mr.developer.cfg
.project
.pydevproject
.idea/workspace.xml
.idea/misc.xml
.idea
.settings
# Komodo Files
*.komodoproject
@ -42,3 +45,6 @@ nosetests.xml
# OS Files
.DS_Store
Thumbs.db
# Virtual environment
venv

View file

@ -42,3 +42,18 @@ $ python setup.py install
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

View file

@ -27,6 +27,7 @@ from gerber.render import GerberCairoContext, theme
GERBER_FOLDER = os.path.abspath(os.path.join(os.path.dirname(__file__), 'gerbers'))
# Create a new drawing context
ctx = GerberCairoContext()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Before After
Before After

View file

@ -16,9 +16,15 @@
# See the License for the specific language governing permissions and
# limitations under the License.
from .utils import validate_coordinates, inch, metric
from .primitives import *
from math import asin
import math
from .primitives import *
from .utils import validate_coordinates, inch, metric, rotate_point
# TODO: Add support for aperture macro variables
__all__ = ['AMPrimitive', 'AMCommentPrimitive', 'AMCirclePrimitive',
'AMVectorLinePrimitive', 'AMOutlinePrimitive', 'AMPolygonPrimitive',
'AMMoirePrimitive', 'AMThermalPrimitive', 'AMCenterLinePrimitive',
@ -68,7 +74,13 @@ class AMPrimitive(object):
def to_metric(self):
raise NotImplementedError('Subclass must implement `to-metric`')
def to_primitive(self, position, level_polarity, units):
@property
def _level_polarity(self):
if self.exposure == 'off':
return 'clear'
return 'dark'
def to_primitive(self, units):
""" Return a Primitive instance based on the specified macro params.
"""
print('Rendering {}s is not supported yet.'.format(str(self.__class__)))
@ -126,6 +138,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
@ -171,6 +189,10 @@ class AMCirclePrimitive(AMPrimitive):
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)
if code != 1:
@ -187,13 +209,6 @@ class AMCirclePrimitive(AMPrimitive):
self.diameter = metric(self.diameter)
self.position = tuple([metric(x) for x in self.position])
def to_primitive(self, position, level_polarity, units):
# Offset the primitive from macro position
position = tuple([a + b for a , b in zip (position, self.position)])
# Return a renderable primitive
return Circle(position, self.diameter, level_polarity=level_polarity,
units=units)
def to_gerber(self, settings=None):
data = dict(code=self.code,
exposure='1' if self.exposure == 'on' else 0,
@ -202,6 +217,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.
@ -242,6 +260,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(',')
@ -274,14 +297,6 @@ class AMVectorLinePrimitive(AMPrimitive):
self.start = tuple([metric(x) for x in self.start])
self.end = tuple([metric(x) for x in self.end])
def to_primitive(self, position, level_polarity, units):
# Offset the primitive from macro position
start = tuple([a + b for a , b in zip (position, self.start)])
end = tuple([a + b for a , b in zip (position, self.end)])
# Return a renderable primitive
ap = Rectangle((0, 0), self.width, self.width)
return Line(start, end, ap, level_polarity=level_polarity, units=units)
def to_gerber(self, settings=None):
fmtstr = '{code},{exp},{width},{startx},{starty},{endx},{endy},{rotation}*'
data = dict(code=self.code,
@ -294,6 +309,28 @@ class AMVectorLinePrimitive(AMPrimitive):
rotation=self.rotation)
return fmtstr.format(**data)
def to_primitive(self, units):
"""
Convert this to a primitive. We use the Outline to represent this (instead of Line)
because the behaviour of the end caps is different for aperture macros compared to Lines
when rotated.
"""
# Use a line to generate our vertices easily
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):
""" Aperture Macro Outline primitive. Code 4.
@ -333,6 +370,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(",")
@ -376,12 +426,32 @@ 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)
def to_primitive(self, units):
"""
Convert this to a drawable primitive. This uses the Outline instead of Line
primitive to handle differences in end caps when rotated.
"""
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):
""" Aperture Macro Polygon primitive. Code 5.
@ -422,6 +492,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(",")
@ -459,14 +534,6 @@ class AMPolygonPrimitive(AMPrimitive):
self.position = tuple([metric(x) for x in self.position])
self.diameter = metric(self.diameter)
def to_primitive(self, position, level_polarity, units):
# Offset the primitive from macro position
position = tuple([a + b for a , b in zip (position, self.position)])
# Return a renderable primitive
return Polygon(position, self.vertices, self.diameter/2.,
rotation=self.rotation, level_polarity=level_polarity,
units=units)
def to_gerber(self, settings=None):
data = dict(
code=self.code,
@ -479,6 +546,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):
""" Aperture Macro Moire primitive. Code 6.
@ -574,6 +644,7 @@ class AMMoirePrimitive(AMPrimitive):
self.crosshair_thickness = metric(self.crosshair_thickness)
self.crosshair_length = metric(self.crosshair_length)
def to_gerber(self, settings=None):
data = dict(
code=self.code,
@ -589,6 +660,10 @@ 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()
return None
class AMThermalPrimitive(AMPrimitive):
""" Aperture Macro Thermal primitive. Code 7.
@ -637,9 +712,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')
@ -648,6 +724,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])
@ -668,10 +745,90 @@ 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):
""" Aperture Macro Center Line primitive. Code 21.
@ -712,6 +869,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):
modifiers = primitive.strip(' *').split(",")
@ -743,25 +908,42 @@ class AMCenterLinePrimitive(AMPrimitive):
self.width = metric(self.width)
self.height = metric(self.height)
def to_primitive(self, position, level_polarity, units):
# Offset the primitive from macro position
position = tuple([a + b for a , b in zip (position, self.center)])
# Return a renderable primitive
return Rectangle(position, self.width, self.height,
level_polarity=level_polarity, units=units)
def to_gerber(self, settings=None):
data = dict(
code=self.code,
exposure='1' if self.exposure == 'on' else '0',
width=self.width,
height=self.height,
exposure = '1' if self.exposure == 'on' else '0',
width = self.width,
height = self.height,
center="%.4g,%.4g" % self.center,
rotation=self.rotation
)
fmt = "{code},{exposure},{width},{height},{center},{rotation}*"
return fmt.format(**data)
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.
@ -815,7 +997,7 @@ class AMLowerLeftLinePrimitive(AMPrimitive):
def __init__(self, code, exposure, width, height, lower_left, rotation):
if code != 22:
raise ValueError('LowerLeftLinePrimitive code is 22')
super(AMLowerLeftLinePrimitive, self).__init__(code, exposure)
super (AMLowerLeftLinePrimitive, self).__init__(code, exposure)
self.width = width
self.height = height
validate_coordinates(lower_left)
@ -832,21 +1014,12 @@ class AMLowerLeftLinePrimitive(AMPrimitive):
self.width = metric(self.width)
self.height = metric(self.height)
def to_primitive(self, position, level_polarity, units):
# Offset the primitive from macro position
position = tuple([a + b for a , b in zip (position, self.lower_left)])
position = tuple([pos + offset for pos, offset in
zip(position, (self.width/2, self.height/2))])
# Return a renderable primitive
return Rectangle(position, self.width, self.height,
level_polarity=level_polarity, units=units)
def to_gerber(self, settings=None):
data = dict(
code=self.code,
exposure='1' if self.exposure == 'on' else '0',
width=self.width,
height=self.height,
exposure = '1' if self.exposure == 'on' else '0',
width = self.width,
height = self.height,
lower_left="%.4g,%.4g" % self.lower_left,
rotation=self.rotation
)
@ -855,7 +1028,6 @@ class AMLowerLeftLinePrimitive(AMPrimitive):
class AMUnsupportPrimitive(AMPrimitive):
@classmethod
def from_gerber(cls, primitive):
return cls(primitive)

View file

@ -74,9 +74,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']:
@ -168,6 +169,10 @@ class FileSettings(object):
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):
""" Base class for Gerber/Excellon files.
@ -265,13 +270,13 @@ class CamFile(object):
if ctx is None:
from .render import GerberCairoContext
ctx = GerberCairoContext()
ctx.set_bounds(self.bounds)
ctx.set_bounds(self.bounding_box)
ctx._paint_background()
ctx.invert = invert
ctx._new_render_layer()
for p in self.primitives:
ctx.render(p)
ctx._paint()
ctx._flatten()
if filename is not None:
ctx.dump(filename)

View file

@ -62,12 +62,10 @@ def loads(data, filename=None):
fmt = detect_file_format(data)
if fmt == 'rs274x':
return rs274x.loads(data, filename)
return rs274x.loads(data, filename=filename)
elif fmt == 'excellon':
return excellon.loads(data, filename)
return excellon.loads(data, filename=filename)
elif fmt == 'ipc_d_356':
return ipc356.loads(data, filename)
return ipc356.loads(data, filename=filename)
else:
raise ParseError('Unable to detect file format')

View file

@ -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:
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):
@ -56,8 +59,7 @@ def read(filename):
settings = FileSettings(**detect_excellon_format(data))
return ExcellonParser(settings).parse(filename)
def loads(data, filename=None):
def loads(data, filename=None, settings=None, tools=None):
""" Read data from string and return an ExcellonFile
Parameters
----------
@ -67,6 +69,9 @@ def loads(data, filename=None):
filename : string, optional
string containing the filename of the data source
tools: dict (optional)
externally defined tools
Returns
-------
file : :class:`gerber.excellon.ExcellonFile`
@ -74,12 +79,22 @@ def loads(data, filename=None):
"""
# File object should use settings from source file by default.
settings = FileSettings(**detect_excellon_format(data))
return ExcellonParser(settings).parse_raw(data, filename)
if not settings:
settings = FileSettings(**detect_excellon_format(data))
return ExcellonParser(settings, tools).parse_raw(data, filename)
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
@ -94,6 +109,64 @@ class DrillHit(object):
self.tool.to_metric()
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):
if self.tool.units == 'metric':
self.tool.to_inch()
self.start = tuple(map(inch, self.start))
self.end = tuple(map(inch, self.end))
def to_metric(self):
if self.tool.units == 'inch':
self.tool.to_metric()
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):
""" A class representing a single excellon file
@ -132,19 +205,30 @@ class ExcellonFile(CamFile):
@property
def primitives(self):
return [Drill(hit.position, hit.tool.diameter, units=self.settings.units) for hit in self.hits]
"""
Gets the primitives. Note that unlike Gerber, this generates new objects
"""
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
def bounds(self):
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):
@ -206,7 +290,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):
""" Convert units to metric
@ -220,7 +304,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:
@ -228,8 +312,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
@ -239,8 +322,8 @@ class ExcellonFile(CamFile):
for hit in self.hits:
tool = hit.tool
num = tool.number
positions[num] = (0, 0) if positions.get(
num) is None else positions[num]
positions[num] = ((0, 0) if positions.get(num) is None
else positions[num])
lengths[num] = 0.0 if lengths.get(num) is None else lengths[num]
lengths[num] = lengths[
num] + math.hypot(*tuple(map(operator.sub, positions[num], hit.position)))
@ -290,8 +373,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'
@ -299,9 +381,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 plated 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
@ -362,6 +449,24 @@ class ExcellonParser(object):
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())
self.state = 'HEADER'
@ -373,6 +478,16 @@ class ExcellonParser(object):
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())
if self.state == 'HEADER':
@ -380,12 +495,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())
@ -419,6 +537,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
self.statements.append(stmt)
@ -433,14 +554,27 @@ class ExcellonParser(object):
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':
@ -460,6 +594,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())
@ -479,9 +614,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)
@ -489,9 +628,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 gerb
if self._settings().units == "inch":
diameter = (16 + 8 * stmt.tool) / 1000.0
else:
@ -508,7 +648,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())
@ -520,23 +660,67 @@ 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))
@ -544,6 +728,49 @@ class ExcellonParser(object):
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.
@ -646,6 +873,9 @@ 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

View file

@ -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
"""

105
gerber/excellon_settings.py Normal file
View file

@ -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

View file

@ -36,7 +36,8 @@ __all__ = ['ExcellonTool', 'ToolSelectionStmt', 'CoordinateStmt',
'ExcellonStatement', 'ZAxisRoutPositionStmt',
'RetractWithClampingStmt', 'RetractWithoutClampingStmt',
'CutterCompensationOffStmt', 'CutterCompensationLeftStmt',
'CutterCompensationRightStmt', 'ZAxisInfeedRateStmt']
'CutterCompensationRightStmt', 'ZAxisInfeedRateStmt',
'NextToolSelectionStmt', 'SlotStmt']
class ExcellonStatement(object):
@ -112,9 +113,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
@ -151,6 +172,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
@ -183,11 +208,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)
@ -220,6 +249,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'
@ -268,7 +314,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
@ -576,19 +651,35 @@ class EndOfProgramStmt(ExcellonStatement):
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')
@ -651,6 +742,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):
@ -746,6 +841,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.

186
gerber/excellon_tool.py Normal file
View file

@ -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

View file

@ -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):
@ -95,6 +96,11 @@ 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):
"""
@ -168,6 +174,10 @@ 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):
param = stmt_dict.get('param')
@ -226,6 +236,11 @@ class LPParamStmt(ParamStmt):
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
@ -258,6 +273,33 @@ 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, hole_diameter):
'''Create a circular aperture definition statement'''
if hole_diameter != None:
return cls('AD', dcode, 'C', ([diameter, hole_diameter],))
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 +334,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:
@ -393,7 +437,8 @@ class AMParamStmt(ParamStmt):
else:
self.primitives.append(
AMUnsupportPrimitive.from_gerber(primitive))
return self
return AMGroup(self.primitives, stmt=self, units=self.units)
def to_inch(self):
if self.units == 'metric':
@ -820,6 +865,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']
@ -843,6 +896,32 @@ class CoordStmt(Statement):
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
@ -966,6 +1045,16 @@ class CoordStmt(Statement):
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):
""" Aperture Statement
@ -1017,6 +1106,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):
if 'G74' not in line and 'G75' not in line:
@ -1045,6 +1142,14 @@ class RegionModeStmt(Statement):
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')
mode = mode.lower()

View file

@ -109,12 +109,15 @@ def sort_layers(layers, from_top=True):
append_after = ['drill', 'drawing']
output = []
drill_layers = [layer for layer in layers if layer.layer_class == 'drill']
internal_layers = list(sorted([layer for layer in layers
if layer.layer_class == 'internal']))
for layer_class in layer_order:
if layer_class == 'internal':
output += internal_layers
elif layer_class == 'drill':
output += drill_layers
else:
for layer in layers:
if layer.layer_class == layer_class:

25
gerber/ncparam/allegro.py Normal file
View file

@ -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
"""

View file

@ -19,8 +19,10 @@
import math
from operator import add
from itertools import combinations
from .utils import validate_coordinates, inch, metric, convex_hull
from .utils import rotate_point, nearly_equal
class Primitive(object):
@ -59,6 +61,13 @@ class Primitive(object):
self._vertices = None
self._segments = None
@property
def flashed(self):
'''Is this a flashed primitive'''
raise NotImplementedError('Is flashed must be '
'implemented in subclass')
def __eq__(self, other):
return self.__dict__ == other.__dict__
@ -105,6 +114,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):
""" Convert primitive units to inches.
"""
@ -166,6 +186,9 @@ class Primitive(object):
in zip(self.position,
(x_offset, y_offset))])
def to_statement(self):
pass
def _changed(self):
""" Clear memoized properties.
@ -180,7 +203,6 @@ class Primitive(object):
for attr in self._memoized:
setattr(self, attr, None)
class Line(Primitive):
"""
"""
@ -192,6 +214,10 @@ class Line(Primitive):
self.aperture = aperture
self._to_convert = ['start', 'end', 'aperture']
@property
def flashed(self):
return False
@property
def start(self):
return self._start
@ -210,7 +236,6 @@ class Line(Primitive):
self._changed()
self._end = value
@property
def angle(self):
delta_x, delta_y = tuple(
@ -234,6 +259,14 @@ class Line(Primitive):
self._bounding_box = ((min_x, max_x), (min_y, max_y))
return self._bounding_box
@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):
@ -265,20 +298,35 @@ class Line(Primitive):
self.end = tuple([coord + offset for coord, offset
in zip(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 start(self):
return self._start
@ -306,6 +354,15 @@ class Arc(Primitive):
self._changed()
self._center = value
@property
def quadrant_mode(self):
return self._quadrant_mode
@quadrant_mode.setter
def quadrant_mode(self, quadrant_mode):
self._changed()
self._quadrant_mode = quadrant_mode
@property
def radius(self):
dy, dx = tuple([start - center for start, center
@ -380,6 +437,47 @@ class Arc(Primitive):
self._bounding_box = ((min_x, max_x), (min_y, max_y))
return self._bounding_box
@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):
self._changed()
self.start = tuple(map(add, self.start, (x_offset, y_offset)))
@ -391,12 +489,17 @@ class Circle(Primitive):
"""
"""
def __init__(self, position, diameter, **kwargs):
def __init__(self, position, diameter, hole_diameter = None, **kwargs):
super(Circle, self).__init__(**kwargs)
validate_coordinates(position)
self._position = position
self._diameter = diameter
self._to_convert = ['position', 'diameter']
self.hole_diameter = hole_diameter
self._to_convert = ['position', 'diameter', 'hole_diameter']
@property
def flashed(self):
return True
@property
def position(self):
@ -420,6 +523,12 @@ class Circle(Primitive):
def radius(self):
return self.diameter / 2.
@property
def hole_radius(self):
if self.hole_diameter != None:
return self.hole_diameter / 2.
return None
@property
def bounding_box(self):
if self._bounding_box is None:
@ -430,11 +539,26 @@ class Circle(Primitive):
self._bounding_box = ((min_x, max_x), (min_y, max_y))
return self._bounding_box
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 or self.hole_diameter != other.hole_diameter:
return False
equiv_position = tuple(map(add, other.position, offset))
return nearly_equal(self.position, equiv_position)
class Ellipse(Primitive):
"""
"""
def __init__(self, position, width, height, **kwargs):
super(Ellipse, self).__init__(**kwargs)
validate_coordinates(position)
@ -443,6 +567,10 @@ class Ellipse(Primitive):
self._height = height
self._to_convert = ['position', 'width', 'height']
@property
def flashed(self):
return True
@property
def position(self):
return self._position
@ -497,18 +625,28 @@ 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):
def __init__(self, position, width, height, hole_diameter=0, **kwargs):
super(Rectangle, self).__init__(**kwargs)
validate_coordinates(position)
self._position = position
self._width = width
self._height = height
self._to_convert = ['position', 'width', 'height']
self.hole_diameter = hole_diameter
self._to_convert = ['position', 'width', 'height', 'hole_diameter']
# TODO These are probably wrong when rotated
self._lower_left = None
self._upper_right = None
@property
def flashed(self):
return True
@property
def position(self):
return self._position
@ -536,6 +674,18 @@ class Rectangle(Primitive):
self._changed()
self._height = value
@property
def hole_radius(self):
"""The radius of the hole. If there is no hole, returns None"""
if self.hole_diameter != None:
return self.hole_diameter / 2.
return None
@property
def upper_right(self):
return (self.position[0] + (self.axis_aligned_width / 2.),
self.position[1] + (self.axis_aligned_height / 2.))
@property
def lower_left(self):
return (self.position[0] - (self.axis_aligned_width / 2.),
@ -567,11 +717,24 @@ class Rectangle(Primitive):
@property
def axis_aligned_width(self):
return (self._cos_theta * self.width) + (self._sin_theta * self.height)
return (self._cos_theta * self.width + self._sin_theta * self.height)
@property
def axis_aligned_height(self):
return (self._cos_theta * self.height) + (self._sin_theta * self.width)
return (self._cos_theta * self.height + self._sin_theta * self.width)
def equivalent(self, other, offset):
"""Is this the same as the other rect, ignoring the offset?"""
if not isinstance(other, Rectangle):
return False
if self.width != other.width or self.height != other.height or self.rotation != other.rotation or self.hole_diameter != other.hole_diameter:
return False
equiv_position = tuple(map(add, other.position, offset))
return nearly_equal(self.position, equiv_position)
class Diamond(Primitive):
@ -586,6 +749,10 @@ class Diamond(Primitive):
self._height = height
self._to_convert = ['position', 'width', 'height']
@property
def flashed(self):
return True
@property
def position(self):
return self._position
@ -639,11 +806,11 @@ class Diamond(Primitive):
@property
def axis_aligned_width(self):
return (self._cos_theta * self.width) + (self._sin_theta * self.height)
return (self._cos_theta * self.width + self._sin_theta * self.height)
@property
def axis_aligned_height(self):
return (self._cos_theta * self.height) + (self._sin_theta * self.width)
return (self._cos_theta * self.height + self._sin_theta * self.width)
class ChamferRectangle(Primitive):
@ -659,6 +826,10 @@ class ChamferRectangle(Primitive):
self._corners = corners if corners is not None else [True] * 4
self._to_convert = ['position', 'width', 'height', 'chamfer']
@property
def flashed(self):
return True
@property
def position(self):
return self._position
@ -774,6 +945,10 @@ class RoundRectangle(Primitive):
self._corners = corners
self._to_convert = ['position', 'width', 'height', 'radius']
@property
def flashed(self):
return True
@property
def position(self):
return self._position
@ -844,13 +1019,18 @@ class Obround(Primitive):
"""
"""
def __init__(self, position, width, height, **kwargs):
def __init__(self, position, width, height, hole_diameter=0, **kwargs):
super(Obround, self).__init__(**kwargs)
validate_coordinates(position)
self._position = position
self._width = width
self._height = height
self._to_convert = ['position', 'width', 'height']
self.hole_diameter = hole_diameter
self._to_convert = ['position', 'width', 'height', 'hole_diameter']
@property
def flashed(self):
return True
@property
def position(self):
@ -879,6 +1059,14 @@ class Obround(Primitive):
self._changed()
self._height = value
@property
def hole_radius(self):
"""The radius of the hole. If there is no hole, returns None"""
if self.hole_diameter != None:
return self.hole_diameter / 2.
return None
@property
def orientation(self):
return 'vertical' if self.height > self.width else 'horizontal'
@ -926,15 +1114,30 @@ 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_diameter, **kwargs):
super(Polygon, self).__init__(**kwargs)
validate_coordinates(position)
self._position = position
self.sides = sides
self._radius = radius
self._to_convert = ['position', 'radius']
self.hole_diameter = hole_diameter
self._to_convert = ['position', 'radius', 'hole_diameter']
@property
def flashed(self):
return True
@property
def diameter(self):
return self.radius * 2
@property
def hole_radius(self):
if self.hole_diameter != None:
return self.hole_diameter / 2.
return None
@property
def position(self):
@ -964,6 +1167,21 @@ class Polygon(Primitive):
self._bounding_box = ((min_x, max_x), (min_y, max_y))
return self._bounding_box
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
@property
def vertices(self):
if self._vertices is None:
@ -976,6 +1194,187 @@ class Polygon(Primitive):
for x, y in vertices]
return self._vertices
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):
# TODO Make this cached like other items
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):
if self._bounding_box is None:
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)
self._bounding_box = ((min_x, max_x), (min_y, max_y))
return self._bounding_box
def offset(self, x_offset=0, y_offset=0):
self._changed()
for p in self.primitives:
p.offset(x_offset, y_offset)
@property
def vertices(self):
if self._vertices is None:
theta = math.radians(360/self.sides)
vertices = [(self.position[0] + (math.cos(theta * side) * self.radius),
self.position[1] + (math.sin(theta * side) * self.radius))
for side in range(self.sides)]
self._vertices = [(((x * self._cos_theta) - (y * self._sin_theta)),
((x * self._sin_theta) + (y * self._cos_theta)))
for x, y in vertices]
return self._vertices
@property
def width(self):
bounding_box = self.bounding_box()
return bounding_box[0][1] - bounding_box[0][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):
"""
@ -986,10 +1385,14 @@ class Region(Primitive):
self.primitives = primitives
self._to_convert = ['primitives']
@property
def flashed(self):
return False
@property
def bounding_box(self):
if self._bounding_box is None:
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)
@ -1016,6 +1419,12 @@ class RoundButterfly(Primitive):
self.diameter = diameter
self._to_convert = ['position', 'diameter']
# TODO This does not reset bounding box correctly
@property
def flashed(self):
return True
@property
def radius(self):
return self.diameter / 2.
@ -1042,6 +1451,12 @@ class SquareButterfly(Primitive):
self.side = side
self._to_convert = ['position', 'side']
# TODO This does not reset bounding box correctly
@property
def flashed(self):
return True
@property
def bounding_box(self):
if self._bounding_box is None:
@ -1078,9 +1493,26 @@ class Donut(Primitive):
# Hexagon
self.width = 0.5 * math.sqrt(3.) * outer_diameter
self.height = outer_diameter
self._to_convert = ['position', 'width',
'height', 'inner_diameter', 'outer_diameter']
# TODO This does not reset bounding box correctly
@property
def flashed(self):
return True
@property
def lower_left(self):
return (self.position[0] - (self.width / 2.),
self.position[1] - (self.height / 2.))
@property
def upper_right(self):
return (self.position[0] + (self.width / 2.),
self.position[1] + (self.height / 2.))
@property
def bounding_box(self):
if self._bounding_box is None:
@ -1107,6 +1539,10 @@ class SquareRoundDonut(Primitive):
self.outer_diameter = outer_diameter
self._to_convert = ['position', 'inner_diameter', 'outer_diameter']
@property
def flashed(self):
return True
@property
def bounding_box(self):
if self._bounding_box is None:
@ -1119,13 +1555,19 @@ 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']
# TODO Ths won't handle the hit updates correctly
@property
def flashed(self):
return False
@property
def position(self):
@ -1159,6 +1601,44 @@ class Drill(Primitive):
self._bounding_box = ((min_x, max_x), (min_y, max_y))
return self._bounding_box
def offset(self, x_offset=0, y_offset=0):
self._changed()
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']
# TODO this needs to use cached bounding box
@property
def flashed(self):
return False
def bounding_box(self):
if self._bounding_box is None:
ll = tuple([c - self.outer_diameter / 2. for c in self.position])
ur = tuple([c + self.outer_diameter / 2. for c in self.position])
self._bounding_box = ((ll[0], ur[0]), (ll[1], ur[1]))
return self._bounding_box
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

View file

@ -12,14 +12,19 @@
# 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.
try:
import cairo
except ImportError:
import cairocffi as cairo
import cairocffi as cairo
import os
from operator import mul
import tempfile
import copy
import os
from .render import GerberContext, RenderSettings
from .theme import THEMES
@ -105,10 +110,13 @@ class GerberCairoContext(GerberContext):
verbose=verbose)
self.dump(filename, verbose)
def dump(self, filename, verbose=False):
def dump(self, filename=None, verbose=False):
""" Save image as `filename`
"""
is_svg = os.path.splitext(filename.lower())[1] == '.svg'
try:
is_svg = os.path.splitext(filename.lower())[1] == '.svg'
except:
is_svg = False
if verbose:
print('[Render]: Writing image to {}'.format(filename))
if is_svg:
@ -119,7 +127,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 byte-string containing the rendered image.
@ -154,7 +162,7 @@ class GerberCairoContext(GerberContext):
for prim in layer.primitives:
self.render(prim)
# Add layer to image
self._paint(settings.color, settings.alpha)
self._flatten(settings.color, settings.alpha)
def _render_line(self, line, color):
start = [pos * scale for pos, scale in zip(line.start, self.scale)]
@ -184,10 +192,17 @@ 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)
self.ctx.set_operator(cairo.OPERATOR_SOURCE
if arc.level_polarity == 'dark' and
(not self.invert) else cairo.OPERATOR_CLEAR)
(not self.invert) else cairo.OPERATOR_CLEAR)
self.ctx.set_line_width(width * self.scale[0])
self.ctx.set_line_cap(cairo.LINE_CAP_ROUND)
self.ctx.move_to(*start) # You actually have to do this...
@ -225,12 +240,23 @@ class GerberCairoContext(GerberContext):
center = self.scale_point(circle.position)
self.ctx.set_operator(cairo.OPERATOR_SOURCE
if circle.level_polarity == 'dark' and
(not self.invert) else cairo.OPERATOR_CLEAR)
(not self.invert) else cairo.OPERATOR_CLEAR)
self.ctx.set_line_width(0)
self.ctx.arc(*center, radius=(circle.radius * self.scale[0]), angle1=0,
angle2=(2 * math.pi))
self.ctx.fill()
if circle.hole_diameter > 0:
# Render the center clear
self.ctx.set_source_rgba(color[0], color[1], color[2], self.alpha)
self.ctx.set_operator(cairo.OPERATOR_CLEAR)
self.ctx.arc(center[0], center[1],
radius=circle.hole_radius * self.scale[0], angle1=0,
angle2=2 * math.pi)
self.ctx.fill()
def _render_rectangle(self, rectangle, color):
lower_left = self.scale_point(rectangle.lower_left)
width, height = tuple([abs(coord) for coord in
@ -239,19 +265,126 @@ class GerberCairoContext(GerberContext):
self.ctx.set_operator(cairo.OPERATOR_SOURCE
if rectangle.level_polarity == 'dark' and
(not self.invert) else cairo.OPERATOR_CLEAR)
if rectangle.rotation != 0:
self.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
lower_left[0] = lower_left[0] - center[0]
lower_left[1] = lower_left[1] - center[1]
matrix.rotate(rectangle.rotation)
self.ctx.transform(matrix)
if rectangle.hole_diameter > 0:
self.ctx.push_group()
self.ctx.set_line_width(0)
self.ctx.rectangle(*lower_left, width=width, height=height)
self.ctx.fill()
if rectangle.hole_diameter > 0:
# Render the center clear
self.ctx.set_source_rgba(color[0], color[1], color[2], self.alpha)
self.ctx.set_operator(cairo.OPERATOR_CLEAR
if rectangle.level_polarity == 'dark'
and (not self.invert)
else cairo.OPERATOR_SOURCE)
center = map(mul, rectangle.position, self.scale)
self.ctx.arc(center[0], center[1],
radius=rectangle.hole_radius * self.scale[0], angle1=0,
angle2=2 * math.pi)
self.ctx.fill()
if rectangle.rotation != 0:
self.ctx.restore()
def _render_obround(self, obround, color):
if obround.hole_diameter > 0:
self.ctx.push_group()
self._render_circle(obround.subshapes['circle1'], color)
self._render_circle(obround.subshapes['circle2'], color)
self._render_rectangle(obround.subshapes['rectangle'], color)
if obround.hole_diameter > 0:
# Render the center clear
self.ctx.set_source_rgba(color[0], color[1], color[2], self.alpha)
self.ctx.set_operator(cairo.OPERATOR_CLEAR)
center = map(mul, obround.position, self.scale)
self.ctx.arc(center[0], center[1],
radius=obround.hole_radius * self.scale[0], angle1=0,
angle2=2 * math.pi)
self.ctx.fill()
self.ctx.pop_group_to_source()
self.ctx.paint_with_alpha(1)
def _render_polygon(self, polygon, color):
# TODO Ths does not handle rotation of a polygon
self.ctx.set_operator(cairo.OPERATOR_SOURCE
if polygon.level_polarity == 'dark' and
(not self.invert) else cairo.OPERATOR_CLEAR)
if polygon.hole_radius > 0:
self.ctx.push_group()
vertices = polygon.vertices
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
if polygon.level_polarity == 'dark'
and (not self.invert)
else cairo.OPERATOR_SOURCE)
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()
def _render_drill(self, circle, color=None):
color = color if color is not None else self.drill_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
self.ctx.set_operator(cairo.OPERATOR_SOURCE
if slot.level_polarity == 'dark' and
(not self.invert) else cairo.OPERATOR_CLEAR)
self.ctx.set_line_width(width * self.scale[0])
self.ctx.set_line_cap(cairo.LINE_CAP_ROUND)
self.ctx.move_to(*start)
self.ctx.line_to(*end)
self.ctx.stroke()
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 = [pos + origin for pos, origin in
zip(primitive.position, self.origin_in_inch)]
@ -285,7 +418,7 @@ class GerberCairoContext(GerberContext):
self.active_layer = layer
self.active_matrix = matrix
def _paint(self, color=None, alpha=None):
def _flatten(self, color=None, alpha=None):
color = color if color is not None else self.color
alpha = alpha if alpha is not None else self.alpha
ptn = cairo.SurfacePattern(self.active_layer)

View file

@ -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

View file

@ -65,7 +65,7 @@ class GerberContext(object):
self._background_color = (0.0, 0.0, 0.0)
self._drill_color = (0.0, 0.0, 0.0)
self._alpha = 1.0
self.invert = False
self._invert = False
self.ctx = None
@property
@ -127,7 +127,20 @@ class GerberContext(object):
raise ValueError('Alpha must be between 0.0 and 1.0')
self._alpha = alpha
@property
def invert(self):
return self._invert
@invert.setter
def invert(self, invert):
self._invert = invert
def render(self, primitive):
if not primitive:
return
self._pre_render_primitive(primitive)
color = self.color
if isinstance(primitive, Line):
self._render_line(primitive, color)
@ -144,11 +157,32 @@ 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
@ -174,6 +208,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

View file

@ -0,0 +1,494 @@
"""Renders an in-memory Gerber file to statements which can be written to a string
"""
from copy import deepcopy
try:
from cStringIO import StringIO
except(ImportError):
from io import StringIO
from .render import GerberContext
from ..am_statements import *
from ..gerber_statements import *
from ..primitives import AMGroup, Arc, Circle, Line, Obround, Outline, Polygon, Rectangle
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, aperture.hole_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, hole_diameter, dcode = None):
'''Define a circlar aperture'''
aper = self._circles.get((diameter, hole_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, hole_diameter)
self._circles[(diameter, hole_diameter)] = aper
self.header.append(aper)
return aper
def _render_circle(self, circle, color):
aper = self._get_circle(circle.diameter, circle.hole_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
def _new_render_layer(self):
# TODO Might need to implement this
pass
def _flatten(self):
# TODO Might need to implement this
pass
def dump(self):
"""Write the rendered file to a StringIO steam"""
statements = map(lambda stmt: stmt.to_gerber(self.settings), self.statements)
stream = StringIO()
for statement in statements:
stream.write(statement + '\n')
return stream

View file

@ -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):
@ -97,9 +99,11 @@ class GerberFile(CamFile):
"""
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):
return [comment.comment for comment in self.statements
@ -114,13 +118,31 @@ class GerberFile(CamFile):
def bounds(self):
min_x = min_y = 1000000
max_x = max_y = -1000000
for stmt in [stmt for stmt in self.statements if isinstance(stmt, CoordStmt)]:
if stmt.x is not None:
min_x = min(stmt.x, min_x)
max_x = max(stmt.x, max_x)
if stmt.y is not None:
min_y = min(stmt.y, min_y)
max_y = max(stmt.y, max_y)
return ((min_x, max_x), (min_y, max_y))
@property
def bounding_box(self):
min_x = min_y = 1000000
max_x = max_y = -1000000
for prim in self.primitives:
bounds = prim.bounding_box
min_x = min(bounds[0][0], min_x)
max_x = max(bounds[0][1], max_x)
min_y = min(bounds[1][0], min_y)
max_y = max(bounds[1][1], max_y)
return ((min_x, max_x), (min_y, max_y))
def write(self, filename, settings=None):
@ -162,14 +184,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
@ -233,8 +255,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)
@ -242,7 +263,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]}
@ -257,7 +309,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
@ -273,6 +325,12 @@ class GerberParser(object):
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:
@ -285,7 +343,6 @@ class GerberParser(object):
(aperture, r) = _match_one(self.APERTURE_STMT, line)
if aperture:
yield ApertureStmt(**aperture)
did_something = True
line = r
continue
@ -309,7 +366,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":
@ -432,19 +491,46 @@ class GerberParser(object):
aperture = None
if shape == 'C':
diameter = modifiers[0][0]
aperture = Circle(position=None, diameter=diameter)
if len(modifiers[0]) >= 2:
hole_diameter = modifiers[0][1]
else:
hole_diameter = None
aperture = Circle(position=None, diameter=diameter, hole_diameter=hole_diameter, units=self.settings.units)
elif shape == 'R':
width = modifiers[0][0]
height = modifiers[0][1]
aperture = Rectangle(position=None, width=width, height=height)
if len(modifiers[0]) >= 3:
hole_diameter = modifiers[0][2]
else:
hole_diameter = None
aperture = Rectangle(position=None, width=width, height=height, hole_diameter=hole_diameter, units=self.settings.units)
elif shape == 'O':
width = modifiers[0][0]
height = modifiers[0][1]
aperture = Obround(position=None, width=width, height=height)
if len(modifiers[0]) >= 3:
hole_diameter = modifiers[0][2]
else:
hole_diameter = None
aperture = Obround(position=None, width=width, height=height, hole_diameter=hole_diameter, units=self.settings.units)
elif shape == 'P':
diameter = modifiers[0][0]
sides = modifiers[0][1]
aperture = Polygon(position=None, radius=diameter/2.0, sides=sides)
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 = None
aperture = Polygon(position=None, sides=number_vertices, radius=outer_diameter/2.0, hole_diameter=hole_diameter, rotation=rotation)
else:
aperture = self.macros[shape].build(modifiers)
@ -454,8 +540,11 @@ 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':
@ -488,13 +577,19 @@ class GerberParser(object):
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
else:
# 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)
@ -507,9 +602,10 @@ class GerberParser(object):
else:
# 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
# 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,
@ -525,31 +621,38 @@ 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],
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],
self.apertures.get(self.aperture, Circle((0,0), 0)),
quadrant_mode=self.quadrant_mode,
level_polarity=self.level_polarity,
units=self.settings.units), ]
units=self.settings.units),]
else:
self.current_region.append(Arc(start, end, center, self.direction,
self.apertures[self.aperture],
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":
pass
elif self.op == "D02" or self.op == "D2":
elif self.op == "D03":
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" or self.op == "D3":
primitive = copy.deepcopy(self.apertures[self.aperture])
if primitive is not None:
if not isinstance(primitive, AMParamStmt):
@ -567,6 +670,35 @@ class GerberParser(object):
self.primitives.append(renderable)
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

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 556 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View file

@ -0,0 +1,16 @@
%FSLAX23Y23*%
%MOIN*%
%ADD10C,0.01*%
G74*
D10*
%LPD*%
G01X1100Y600D02*
G03X700Y1000I-400J0D01*
G03X300Y600I0J-400D01*
G03X700Y200I400J0D01*
G03X1100Y600I0J400D01*
G01X300D02*
X1100D01*
X700Y200D02*
Y1000D01*
M02*

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View file

@ -0,0 +1,16 @@
%FSLAX25Y25*%
%MOMM*%
%ADD10C,0.01*%
D10*
%LPD*%
G01X0Y0D02*
X500000D01*
Y500000D01*
X0D01*
Y0D01*
X600000D02*
X1100000D01*
Y500000D01*
X600000D01*
Y0D01*
M02*

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View file

@ -0,0 +1,16 @@
G04 Umaco example for exposure modifier and clearing area*
%FSLAX26Y26*%
%MOIN*%
%AMSQUAREWITHHOLE*
21,0.1,1,1,0,0,0*
1,0,0.5,0,0*%
%ADD10SQUAREWITHHOLE*%
%ADD11C,1*%
G01*
%LPD*%
D11*
X-1000000Y-250000D02*
X1000000Y250000D01*
D10*
X0Y0D03*
M02*

View file

@ -0,0 +1,24 @@
G04 ex2: overlapping*
%FSLAX24Y24*%
%MOMM*%
%SRX1Y1I0.000J0.000*%
%ADD10C,1.00000*%
G01*
%LPD*%
G36*
X0Y50000D02*
Y100000D01*
X100000D01*
Y0D01*
X0D01*
Y50000D01*
G04 first fully coincident linear segment*
X10000D01*
X50000Y10000D01*
X90000Y50000D01*
X50000Y90000D01*
X10000Y50000D01*
G04 second fully coincident linear segment*
X0D01*
G37*
M02*

View file

@ -0,0 +1,18 @@
G04 Umaco uut-in example*
%FSLAX24Y24*%
G75*
G36*
X20000Y100000D02*
G01*
X120000D01*
Y20000D01*
X20000D01*
Y60000D01*
X50000D01*
G03*
X50000Y60000I30000J0D01*
G01*
X20000D01*
Y100000D01*
G37*
M02*

View file

@ -0,0 +1,28 @@
G04 multiple cutins*
%FSLAX24Y24*%
%MOMM*%
%SRX1Y1I0.000J0.000*%
%ADD10C,1.00000*%
%LPD*%
G36*
X1220000Y2570000D02*
G01*
Y2720000D01*
X1310000D01*
Y2570000D01*
X1250000D01*
Y2600000D01*
X1290000D01*
Y2640000D01*
X1250000D01*
Y2670000D01*
X1290000D01*
Y2700000D01*
X1250000D01*
Y2670000D01*
Y2640000D01*
Y2600000D01*
Y2570000D01*
X1220000D01*
G37*
M02*

View file

@ -0,0 +1,10 @@
G04 Flashes of circular apertures*
%FSLAX24Y24*%
%MOMM*%
%ADD10C,0.5*%
%ADD11C,0.5X0.25*%
D10*
X000000Y000000D03*
D11*
X010000D03*
M02*

View file

@ -0,0 +1,10 @@
G04 Flashes of rectangular apertures*
%FSLAX24Y24*%
%MOMM*%
%ADD10O,0.46X0.26*%
%ADD11O,0.46X0.26X0.19*%
D10*
X000000Y000000D03*
D11*
X010000D03*
M02*

View file

@ -0,0 +1,10 @@
G04 Flashes of rectangular apertures*
%FSLAX24Y24*%
%MOMM*%
%ADD10P,.40X6*%
%ADD11P,.40X6X0.0X0.19*%
D10*
X000000Y000000D03*
D11*
X010000D03*
M02*

View file

@ -0,0 +1,10 @@
G04 Flashes of rectangular apertures*
%FSLAX24Y24*%
%MOMM*%
%ADD10R,0.44X0.25*%
%ADD11R,0.44X0.25X0.19*%
D10*
X000000Y000000D03*
D11*
X010000D03*
M02*

View file

@ -0,0 +1,23 @@
G04 ex1: non overlapping*
%FSLAX24Y24*%
%MOMM*%
%ADD10C,1.00000*%
G01*
%LPD*%
G36*
X0Y50000D02*
Y100000D01*
X100000D01*
Y0D01*
X0D01*
Y50000D01*
G04 first fully coincident linear segment*
X-10000D01*
X-50000Y10000D01*
X-90000Y50000D01*
X-50000Y90000D01*
X-10000Y50000D01*
G04 second fully coincident linear segment*
X0D01*
G37*
M02*

View file

@ -0,0 +1,13 @@
G04 Demonstrates that apertures with holes do not clear the area - only the aperture hole*
%FSLAX26Y26*%
%MOIN*%
%ADD10C,1X0.5*%
%ADD11C,0.1*%
G01*
%LPD*%
D11*
X-1000000Y-250000D02*
X1000000Y250000D01*
D10*
X0Y0D03*
M02*

View file

@ -0,0 +1,39 @@
G04 This file illustrates how to use levels to create holes*
%FSLAX25Y25*%
%MOMM*%
G01*
G04 First level: big square - dark polarity*
%LPD*%
G36*
X250000Y250000D02*
X1750000D01*
Y1750000D01*
X250000D01*
Y250000D01*
G37*
G04 Second level: big circle - clear polarity*
%LPC*%
G36*
G75*
X500000Y1000000D02*
G03*
X500000Y1000000I500000J0D01*
G37*
G04 Third level: small square - dark polarity*
%LPD*%
G36*
X750000Y750000D02*
X1250000D01*
Y1250000D01*
X750000D01*
Y750000D01*
G37*
G04 Fourth level: small circle - clear polarity*
%LPC*%
G36*
G75*
X1150000Y1000000D02*
G03*
X1150000Y1000000I250000J0D01*
G37*
M02*

View file

@ -0,0 +1,20 @@
G04 Non-overlapping contours*
%FSLAX24Y24*%
%MOMM*%
%ADD10C,1.00000*%
G01*
%LPD*%
G36*
X0Y50000D02*
Y100000D01*
X100000D01*
Y0D01*
X0D01*
Y50000D01*
X-10000D02*
X-50000Y10000D01*
X-90000Y50000D01*
X-50000Y90000D01*
X-10000Y50000D01*
G37*
M02*

View file

@ -0,0 +1,20 @@
G04 Non-overlapping and touching*
%FSLAX24Y24*%
%MOMM*%
%ADD10C,1.00000*%
G01*
%LPD*%
G36*
X0Y50000D02*
Y100000D01*
X100000D01*
Y0D01*
X0D01*
Y50000D01*
D02*
X-50000Y10000D01*
X-90000Y50000D01*
X-50000Y90000D01*
X0Y50000D01*
G37*
M02*

View file

@ -0,0 +1,20 @@
G04 Overlapping contours*
%FSLAX24Y24*%
%MOMM*%
%ADD10C,1.00000*%
G01*
%LPD*%
G36*
X0Y50000D02*
Y100000D01*
X100000D01*
Y0D01*
X0D01*
Y50000D01*
X10000D02*
X50000Y10000D01*
X90000Y50000D01*
X50000Y90000D01*
X10000Y50000D01*
G37*
M02*

View file

@ -0,0 +1,20 @@
G04 Overlapping and touching*
%FSLAX24Y24*%
%MOMM*%
%ADD10C,1.00000*%
G01*
%LPD*%
G36*
X0Y50000D02*
Y100000D01*
X100000D01*
Y0D01*
X0D01*
Y50000D01*
D02*
X50000Y10000D01*
X90000Y50000D01*
X50000Y90000D01*
X0Y50000D01*
G37*
M02*

View file

@ -0,0 +1,16 @@
G04 Ucamco ex. 4.6.4: Simple contour*
%FSLAX25Y25*%
%MOIN*%
%ADD10C,0.010*%
G36*
X200000Y300000D02*
G01*
X700000D01*
Y100000D01*
X1100000Y500000D01*
X700000Y900000D01*
Y700000D01*
X200000D01*
Y300000D01*
G37*
M02*

View file

@ -0,0 +1,15 @@
G04 Ucamco ex. 4.6.5: Single contour #1*
%FSLAX25Y25*%
%MOMM*%
%ADD11C,0.01*%
G01*
D11*
X3000Y5000D01*
G36*
X50000Y50000D02*
X60000D01*
Y60000D01*
X50000D01*
Y50000Y50000D01*
G37*
M02*

View file

@ -0,0 +1,15 @@
G04 Ucamco ex. 4.6.5: Single contour #2*
%FSLAX25Y25*%
%MOMM*%
%ADD11C,0.01*%
G01*
D11*
X3000Y5000D01*
X50000Y50000D02*
G36*
X60000D01*
Y60000D01*
X50000D01*
Y50000Y50000D01*
G37*
M02*

View file

@ -0,0 +1,15 @@
G04 Ucamco ex. 4.6.5: Single contour #2*
%FSLAX25Y25*%
%MOMM*%
%ADD11C,0.01*%
G01*
D11*
X3000Y5000D01*
X50000Y50000D01*
G36*
X60000D01*
Y60000D01*
X50000D01*
Y50000Y50000D01*
G37*
M02*

View file

@ -0,0 +1,18 @@
G04 Ucamco ex. 4.5.8: Single quadrant*
%FSLAX23Y23*%
%MOIN*%
%ADD10C,0.010*%
G74*
D10*
X1100Y600D02*
G03*
X700Y1000I400J0D01*
X300Y600I0J400D01*
X700Y200I400J0D01*
X1100Y600I0J400D01*
X300D02*
G01*
X1100D01*
X700Y200D02*
Y1000D01*
M02*

View file

@ -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*

File diff suppressed because one or more lines are too long

View file

@ -160,7 +160,11 @@ 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():
@ -253,33 +257,40 @@ def test_AMMoirePrimitive_conversion():
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)

View file

@ -0,0 +1,189 @@
#! /usr/bin/env python
# -*- coding: utf-8 -*-
# Author: Garret Fick <garret@ficksworkshop.com>
import os
from ..render.cairo_backend import GerberCairoContext
from ..rs274x import read
from .tests import *
from nose.tools import assert_tuple_equal
def _DISABLED_test_render_two_boxes():
"""Umaco exapmle of two boxes"""
_test_render('resources/example_two_square_boxes.gbr', 'golden/example_two_square_boxes.png')
def _DISABLED_test_render_single_quadrant():
"""Umaco exapmle of a single quadrant arc"""
_test_render('resources/example_single_quadrant.gbr', 'golden/example_single_quadrant.png')
def _DISABLED_test_render_simple_contour():
"""Umaco exapmle of a simple arrow-shaped contour"""
gerber = _test_render('resources/example_simple_contour.gbr', 'golden/example_simple_contour.png')
# Check the resulting dimensions
assert_tuple_equal(((2.0, 11.0), (1.0, 9.0)), gerber.bounding_box)
def _DISABLED_test_render_single_contour_1():
"""Umaco example of a single contour
The resulting image for this test is used by other tests because they must generate the same output."""
_test_render('resources/example_single_contour_1.gbr', 'golden/example_single_contour.png')
def _DISABLED_test_render_single_contour_2():
"""Umaco exapmle of a single contour, alternate contour end order
The resulting image for this test is used by other tests because they must generate the same output."""
_test_render('resources/example_single_contour_2.gbr', 'golden/example_single_contour.png')
def _DISABLED_test_render_single_contour_3():
"""Umaco exapmle of a single contour with extra line"""
_test_render('resources/example_single_contour_3.gbr', 'golden/example_single_contour_3.png')
def _DISABLED_test_render_not_overlapping_contour():
"""Umaco example of D02 staring a second contour"""
_test_render('resources/example_not_overlapping_contour.gbr', 'golden/example_not_overlapping_contour.png')
def _DISABLED_test_render_not_overlapping_touching():
"""Umaco example of D02 staring a second contour"""
_test_render('resources/example_not_overlapping_touching.gbr', 'golden/example_not_overlapping_touching.png')
def test_render_overlapping_touching():
"""Umaco example of D02 staring a second contour"""
_test_render('resources/example_overlapping_touching.gbr', 'golden/example_overlapping_touching.png')
def test_render_overlapping_contour():
"""Umaco example of D02 staring a second contour"""
_test_render('resources/example_overlapping_contour.gbr', 'golden/example_overlapping_contour.png')
def _DISABLED_test_render_level_holes():
"""Umaco example of using multiple levels to create multiple holes"""
# TODO This is clearly rendering wrong. I'm temporarily checking this in because there are more
# rendering fixes in the related repository that may resolve these.
_test_render('resources/example_level_holes.gbr', 'golden/example_overlapping_contour.png')
def _DISABLED_test_render_cutin():
"""Umaco example of using a cutin"""
# TODO This is clearly rendering wrong.
_test_render('resources/example_cutin.gbr', 'golden/example_cutin.png', '/Users/ham/Desktop/cutin.png')
def _DISABLED_test_render_fully_coincident():
"""Umaco example of coincident lines rendering two contours"""
_test_render('resources/example_fully_coincident.gbr', 'golden/example_fully_coincident.png')
def _DISABLED_test_render_coincident_hole():
"""Umaco example of coincident lines rendering a hole in the contour"""
_test_render('resources/example_coincident_hole.gbr', 'golden/example_coincident_hole.png')
def test_render_cutin_multiple():
"""Umaco example of a region with multiple cutins"""
_test_render('resources/example_cutin_multiple.gbr', 'golden/example_cutin_multiple.png')
def _DISABLED_test_flash_circle():
"""Umaco example a simple circular flash with and without a hole"""
_test_render('resources/example_flash_circle.gbr', 'golden/example_flash_circle.png',
'/Users/ham/Desktop/flashcircle.png')
def _DISABLED_test_flash_rectangle():
"""Umaco example a simple rectangular flash with and without a hole"""
_test_render('resources/example_flash_rectangle.gbr', 'golden/example_flash_rectangle.png')
def _DISABLED_test_flash_obround():
"""Umaco example a simple obround flash with and without a hole"""
_test_render('resources/example_flash_obround.gbr', 'golden/example_flash_obround.png')
def _DISABLED_test_flash_polygon():
"""Umaco example a simple polygon flash with and without a hole"""
_test_render('resources/example_flash_polygon.gbr', 'golden/example_flash_polygon.png')
def _DISABLED_test_holes_dont_clear():
"""Umaco example that an aperture with a hole does not clear the area"""
_test_render('resources/example_holes_dont_clear.gbr', 'golden/example_holes_dont_clear.png')
def _DISABLED_test_render_am_exposure_modifier():
"""Umaco example that an aperture macro with a hole does not clear the area"""
_test_render('resources/example_am_exposure_modifier.gbr', 'golden/example_am_exposure_modifier.png')
def _resolve_path(path):
return os.path.join(os.path.dirname(__file__),
path)
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_path = _resolve_path(gerber_path)
png_expected_path = _resolve_path(png_expected_path)
if create_output_path:
create_output_path = _resolve_path(create_output_path)
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()
# Don't directly use assert_equal otherwise any failure pollutes the test results
equal = (expected_bytes == actual_bytes)
assert_true(equal)
return gerber

View file

@ -116,14 +116,22 @@ 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',

View file

@ -38,4 +38,4 @@ def test_load_from_string():
def test_file_type_validation():
""" Test file format validation
"""
assert_raises(ParseError, read, 'LICENSE')
assert_raises(ParseError, read, __file__)

View file

@ -81,7 +81,8 @@ def test_conversion():
assert_equal(i_tool, m_tool)
for m, i in zip(ncdrill.primitives, inch_primitives):
assert_equal(m, i)
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():

View file

@ -485,9 +485,14 @@ def test_AMParamStmt_dump():
macro = '5,1,8,25.4,25.4,25.4,0.0'
s = AMParamStmt.from_dict({'param': 'AM', 'name': name, 'macro': macro})
s.build()
assert_equal(s.to_gerber(), '%AMPOLYGON*5,1,8,25.4,25.4,25.4,0.0*%')
#TODO - Store Equations and update on unit change...
s = AMParamStmt.from_dict({'param': 'AM', 'name': 'OC8', 'macro': '5,1,8,0,0,1.08239X$1,22.5'})
s.build()
#assert_equal(s.to_gerber(), '%AMOC8*5,1,8,0,0,1.08239X$1,22.5*%')
assert_equal(s.to_gerber(), '%AMOC8*5,1,8,0,0,0,22.5*%')
def test_AMParamStmt_string():
name = 'POLYGON'

View file

@ -17,6 +17,7 @@ def test_read():
assert(isinstance(ipcfile, IPCNetlist))
def test_parser():
ipcfile = read(IPC_D_356_FILE)
assert_equal(ipcfile.settings.units, 'inch')

View file

@ -2,17 +2,29 @@
# -*- coding: utf-8 -*-
# Author: Hamilton Kibbe <ham@hamiltonkib.be>
from operator import add
from ..primitives import *
from .tests import *
from operator import add
def test_primitive_smoketest():
p = Primitive()
try:
p.bounding_box
assert_false(True, 'should have thrown the exception')
except NotImplementedError:
pass
#assert_raises(NotImplementedError, p.bounding_box)
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():
@ -159,7 +171,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)
@ -172,8 +184,8 @@ def test_arc_sweep_angle():
((1, 0), (-1, 0), (0, 0), 'counterclockwise', math.radians(180)), ]
for start, end, center, direction, sweep in cases:
c = Circle((0, 0), 1)
a = Arc(start, end, center, direction, c)
c = Circle((0,0), 1)
a = Arc(start, end, center, direction, c, 'single-quadrant')
assert_equal(a.sweep_angle, sweep)
@ -186,15 +198,15 @@ def test_arc_bounds():
# TODO: ADD MORE TEST CASES HERE
]
for start, end, center, direction, bounds in cases:
c = Circle((0, 0), 1)
a = Arc(start, end, center, direction, c)
c = Circle((0,0), 1)
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')
'clockwise', c, 'single-quadrant', units='metric')
# No effect
a.to_metric()
@ -218,7 +230,7 @@ def test_arc_conversion():
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')
'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))
@ -228,7 +240,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.))
@ -246,6 +258,13 @@ def test_circle_radius():
assert_equal(c.radius, 1)
def test_circle_hole_radius():
""" Test Circle primitive hole radius calculation
"""
c = Circle((1, 1), 4, 2)
assert_equal(c.hole_radius, 1)
def test_circle_bounds():
""" Test Circle bounding box calculation
"""
@ -254,35 +273,81 @@ def test_circle_bounds():
def test_circle_conversion():
"""Circle conversion of units"""
# Circle initially metric, no hole
c = Circle((2.54, 25.4), 254.0, units='metric')
c.to_metric() # shouldn't do antyhing
assert_equal(c.position, (2.54, 25.4))
assert_equal(c.diameter, 254.)
assert_equal(c.hole_diameter, None)
c.to_inch()
assert_equal(c.position, (0.1, 1.))
assert_equal(c.diameter, 10.)
assert_equal(c.hole_diameter, None)
# no effect
c.to_inch()
assert_equal(c.position, (0.1, 1.))
assert_equal(c.diameter, 10.)
assert_equal(c.hole_diameter, None)
# Circle initially metric, with hole
c = Circle((2.54, 25.4), 254.0, 127.0, units='metric')
c.to_metric() #shouldn't do antyhing
assert_equal(c.position, (2.54, 25.4))
assert_equal(c.diameter, 254.)
assert_equal(c.hole_diameter, 127.)
c.to_inch()
assert_equal(c.position, (0.1, 1.))
assert_equal(c.diameter, 10.)
assert_equal(c.hole_diameter, 5.)
# no effect
c.to_inch()
assert_equal(c.position, (0.1, 1.))
assert_equal(c.diameter, 10.)
assert_equal(c.hole_diameter, 5.)
# Circle initially inch, no hole
c = Circle((0.1, 1.0), 10.0, units='inch')
# No effect
c.to_inch()
assert_equal(c.position, (0.1, 1.))
assert_equal(c.diameter, 10.)
assert_equal(c.hole_diameter, None)
c.to_metric()
assert_equal(c.position, (2.54, 25.4))
assert_equal(c.diameter, 254.)
assert_equal(c.hole_diameter, None)
# no effect
c.to_metric()
assert_equal(c.position, (2.54, 25.4))
assert_equal(c.diameter, 254.)
assert_equal(c.hole_diameter, None)
c = Circle((0.1, 1.0), 10.0, 5.0, units='inch')
#No effect
c.to_inch()
assert_equal(c.position, (0.1, 1.))
assert_equal(c.diameter, 10.)
assert_equal(c.hole_diameter, 5.)
c.to_metric()
assert_equal(c.position, (2.54, 25.4))
assert_equal(c.diameter, 254.)
assert_equal(c.hole_diameter, 127.)
# no effect
c.to_metric()
assert_equal(c.position, (2.54, 25.4))
assert_equal(c.diameter, 254.)
assert_equal(c.hole_diameter, 127.)
def test_circle_offset():
@ -373,6 +438,16 @@ def test_rectangle_ctor():
assert_equal(r.width, width)
assert_equal(r.height, height)
def test_rectangle_hole_radius():
""" Test rectangle hole diameter calculation
"""
r = Rectangle((0,0), 2, 2)
assert_equal(0, r.hole_radius)
r = Rectangle((0,0), 2, 2, 1)
assert_equal(0.5, r.hole_radius)
def test_rectangle_bounds():
""" Test rectangle bounding box calculation
@ -388,6 +463,9 @@ def test_rectangle_bounds():
def test_rectangle_conversion():
"""Test converting rectangles between units"""
# Initially metric no hole
r = Rectangle((2.54, 25.4), 254.0, 2540.0, units='metric')
r.to_metric()
@ -405,6 +483,28 @@ def test_rectangle_conversion():
assert_equal(r.width, 10.0)
assert_equal(r.height, 100.0)
# Initially metric with hole
r = Rectangle((2.54, 25.4), 254.0, 2540.0, 127.0, units='metric')
r.to_metric()
assert_equal(r.position, (2.54,25.4))
assert_equal(r.width, 254.0)
assert_equal(r.height, 2540.0)
assert_equal(r.hole_diameter, 127.0)
r.to_inch()
assert_equal(r.position, (0.1, 1.0))
assert_equal(r.width, 10.0)
assert_equal(r.height, 100.0)
assert_equal(r.hole_diameter, 5.0)
r.to_inch()
assert_equal(r.position, (0.1, 1.0))
assert_equal(r.width, 10.0)
assert_equal(r.height, 100.0)
assert_equal(r.hole_diameter, 5.0)
# Initially inch, no hole
r = Rectangle((0.1, 1.0), 10.0, 100.0, units='inch')
r.to_inch()
assert_equal(r.position, (0.1, 1.0))
@ -421,6 +521,26 @@ def test_rectangle_conversion():
assert_equal(r.width, 254.0)
assert_equal(r.height, 2540.0)
# Initially inch with hole
r = Rectangle((0.1, 1.0), 10.0, 100.0, 5.0, units='inch')
r.to_inch()
assert_equal(r.position, (0.1, 1.0))
assert_equal(r.width, 10.0)
assert_equal(r.height, 100.0)
assert_equal(r.hole_diameter, 5.0)
r.to_metric()
assert_equal(r.position, (2.54,25.4))
assert_equal(r.width, 254.0)
assert_equal(r.height, 2540.0)
assert_equal(r.hole_diameter, 127.0)
r.to_metric()
assert_equal(r.position, (2.54, 25.4))
assert_equal(r.width, 254.0)
assert_equal(r.height, 2540.0)
assert_equal(r.hole_diameter, 127.0)
def test_rectangle_offset():
r = Rectangle((0, 0), 1, 2)
@ -756,31 +876,32 @@ 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_diameter in test_cases:
p = Polygon(pos, sides, radius, hole_diameter)
assert_equal(p.position, pos)
assert_equal(p.sides, sides)
assert_equal(p.radius, radius)
assert_equal(p.hole_diameter, hole_diameter)
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()
@ -796,7 +917,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()
@ -814,7 +935,7 @@ def test_polygon_conversion():
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)
@ -1074,7 +1195,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.)
@ -1083,25 +1204,26 @@ 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
#No effect
d.to_metric()
assert_equal(d.position, (2.54, 25.4))
assert_equal(d.diameter, 254.0)
@ -1110,12 +1232,12 @@ def test_drill_conversion():
assert_equal(d.position, (0.1, 1.0))
assert_equal(d.diameter, 10.0)
# No effect
#No effect
d.to_inch()
assert_equal(d.position, (0.1, 1.0))
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()
@ -1133,7 +1255,7 @@ def test_drill_conversion():
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)
@ -1141,8 +1263,8 @@ def test_drill_offset():
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)

View file

@ -39,10 +39,9 @@ def test_size_parameter():
def test_conversion():
import copy
top_copper = read(TOP_COPPER_FILE)
assert_equal(top_copper.units, 'inch')
top_copper_inch = copy.deepcopy(top_copper)
top_copper_inch = read(TOP_COPPER_FILE)
top_copper.to_metric()
for statement in top_copper_inch.statements:
statement.to_metric()

View file

@ -0,0 +1,185 @@
#! /usr/bin/env python
# -*- coding: utf-8 -*-
# Author: Garret Fick <garret@ficksworkshop.com>
import io
import os
from ..render.rs274x_backend import Rs274xContext
from ..rs274x import read
from .tests import *
def test_render_two_boxes():
"""Umaco exapmle of two boxes"""
_test_render('resources/example_two_square_boxes.gbr', 'golden/example_two_square_boxes.gbr')
def _test_render_single_quadrant():
"""Umaco exapmle of a single quadrant arc"""
# TODO there is probably a bug here
_test_render('resources/example_single_quadrant.gbr', 'golden/example_single_quadrant.gbr')
def _test_render_simple_contour():
"""Umaco exapmle of a simple arrow-shaped contour"""
_test_render('resources/example_simple_contour.gbr', 'golden/example_simple_contour.gbr')
def _test_render_single_contour_1():
"""Umaco example of a single contour
The resulting image for this test is used by other tests because they must generate the same output."""
_test_render('resources/example_single_contour_1.gbr', 'golden/example_single_contour.gbr')
def _test_render_single_contour_2():
"""Umaco exapmle of a single contour, alternate contour end order
The resulting image for this test is used by other tests because they must generate the same output."""
_test_render('resources/example_single_contour_2.gbr', 'golden/example_single_contour.gbr')
def _test_render_single_contour_3():
"""Umaco exapmle of a single contour with extra line"""
_test_render('resources/example_single_contour_3.gbr', 'golden/example_single_contour_3.gbr')
def _test_render_not_overlapping_contour():
"""Umaco example of D02 staring a second contour"""
_test_render('resources/example_not_overlapping_contour.gbr', 'golden/example_not_overlapping_contour.gbr')
def _test_render_not_overlapping_touching():
"""Umaco example of D02 staring a second contour"""
_test_render('resources/example_not_overlapping_touching.gbr', 'golden/example_not_overlapping_touching.gbr')
def _test_render_overlapping_touching():
"""Umaco example of D02 staring a second contour"""
_test_render('resources/example_overlapping_touching.gbr', 'golden/example_overlapping_touching.gbr')
def _test_render_overlapping_contour():
"""Umaco example of D02 staring a second contour"""
_test_render('resources/example_overlapping_contour.gbr', 'golden/example_overlapping_contour.gbr')
def _DISABLED_test_render_level_holes():
"""Umaco example of using multiple levels to create multiple holes"""
# TODO This is clearly rendering wrong. I'm temporarily checking this in because there are more
# rendering fixes in the related repository that may resolve these.
_test_render('resources/example_level_holes.gbr', 'golden/example_overlapping_contour.gbr')
def _DISABLED_test_render_cutin():
"""Umaco example of using a cutin"""
# TODO This is clearly rendering wrong.
_test_render('resources/example_cutin.gbr', 'golden/example_cutin.gbr')
def _test_render_fully_coincident():
"""Umaco example of coincident lines rendering two contours"""
_test_render('resources/example_fully_coincident.gbr', 'golden/example_fully_coincident.gbr')
def _test_render_coincident_hole():
"""Umaco example of coincident lines rendering a hole in the contour"""
_test_render('resources/example_coincident_hole.gbr', 'golden/example_coincident_hole.gbr')
def _test_render_cutin_multiple():
"""Umaco example of a region with multiple cutins"""
_test_render('resources/example_cutin_multiple.gbr', 'golden/example_cutin_multiple.gbr')
def _test_flash_circle():
"""Umaco example a simple circular flash with and without a hole"""
_test_render('resources/example_flash_circle.gbr', 'golden/example_flash_circle.gbr')
def _test_flash_rectangle():
"""Umaco example a simple rectangular flash with and without a hole"""
_test_render('resources/example_flash_rectangle.gbr', 'golden/example_flash_rectangle.gbr')
def _test_flash_obround():
"""Umaco example a simple obround flash with and without a hole"""
_test_render('resources/example_flash_obround.gbr', 'golden/example_flash_obround.gbr')
def _test_flash_polygon():
"""Umaco example a simple polygon flash with and without a hole"""
_test_render('resources/example_flash_polygon.gbr', 'golden/example_flash_polygon.gbr')
def _test_holes_dont_clear():
"""Umaco example that an aperture with a hole does not clear the area"""
_test_render('resources/example_holes_dont_clear.gbr', 'golden/example_holes_dont_clear.gbr')
def _test_render_am_exposure_modifier():
"""Umaco example that an aperture macro with a hole does not clear the area"""
_test_render('resources/example_am_exposure_modifier.gbr', 'golden/example_am_exposure_modifier.gbr')
def _resolve_path(path):
return os.path.join(os.path.dirname(__file__),
path)
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_path = _resolve_path(gerber_path)
png_expected_path = _resolve_path(png_expected_path)
if create_output_path:
create_output_path = _resolve_path(create_output_path)
gerber = read(gerber_path)
# Create GBR output from the input file
ctx = Rs274xContext(gerber.settings)
gerber.render(ctx)
actual_contents = ctx.dump()
# 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_contents.getvalue())
# 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, 'r') as expected_file:
expected_contents = expected_file.read()
assert_equal(expected_contents, actual_contents.getvalue())
return gerber

View file

@ -291,10 +291,25 @@ def rotate_point(point, angle, center=(0.0, 0.0)):
`point` rotated about `center` by `angle` degrees.
"""
angle = radians(angle)
x_delta, y_delta = tuple(map(sub, point, center))
x = center[0] + (cos(angle) * x_delta) - (sin(angle) * y_delta)
y = center[1] + (sin(angle) * x_delta) - (cos(angle) * y_delta)
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):

View file

@ -1,3 +1,4 @@
# Test requirements
cairocffi==0.6
coverage==3.7.1
nose==1.3.4